/** * Phase 1 tab-registration spec: mounts the CLIENT half against a mock * better-sidebar registry and verifies the five `apes:*` tabs register with * the right descriptors, the disposer tears them down (HMR-safe), and a * missing `betterSidebar` peer degrades without throwing. * * The React components are NOT rendered here (Phase 0 testing convention: * node vitest environment, no DOM). The descriptors' `component` field is * asserted to be a function; the JSX it returns is only evaluated when the * host actually mounts the tab, which a live DSH shell does (Phase 1+ mount * smoke). What this spec pins is the REGISTRATION CONTRACT: the ids, the * title/order/single shape, and the disposer lifecycle. * * The mock registry records every registered descriptor and returns a * disposer that removes it — mirroring the real better-sidebar * `registerTab` (returns `() => void`, invoked by cordis on fiber teardown). */ import { describe, expect, it } from 'vitest' import { apply, filterTasksRows } from '../src/client/index.tsx' /** The five tab ids this plugin owns (the + menu contract). */ const EXPECTED_IDS = ['apes:tasks', 'apes:agents', 'apes:apps', 'apes:messages', 'apes:profile'] /** One registered descriptor shape the mock records (the fields we assert). */ interface Registered { id: string title: string | (() => string) order?: number single?: boolean component: unknown badge?: (ctx: unknown, scope: unknown, state: unknown) => string | number | null | undefined } /** * A mock better-sidebar registry: `registerTab` records the descriptor and * returns a disposer that removes it (the real service's contract). */ function makeMockRegistry() { const registered: Registered[] = [] return { registry: { registerTab: (descriptor: Registered) => { registered.push(descriptor) return () => { const i = registered.findIndex((r) => r.id === descriptor.id) if (i !== -1) registered.splice(i, 1) } }, getTabs: () => registered, subscribe: () => () => undefined, }, registered, } } /** A no-op locale service (registerLocales only needs register/getSnapshot/subscribe). */ function makeMockLocale() { return { getSnapshot: () => ({ active: 'zh' }), subscribe: () => () => undefined, register: () => () => undefined, } } /** * Drive a cordis effect: invoke the effect body, capture its returned * disposer, and expose `teardown()` so a test can simulate fiber unmount. */ function makeEffectHarness() { let disposer: (() => void) | undefined return { effect: (fn: () => void | (() => void)) => { const result = fn() if (typeof result === 'function') disposer = result return () => disposer?.() }, teardown: () => disposer?.(), } } describe('apes-sidebar client tab registration (Phase 1)', () => { it('registers all five apes:* tabs into the better-sidebar registry', () => { const { registry, registered } = makeMockRegistry() const eff = makeEffectHarness() apply({ betterSidebar: registry, locale: makeMockLocale(), ...eff } as never) expect(registered.map((r) => r.id).sort()).toEqual([...EXPECTED_IDS].sort()) expect(registry.getTabs()).toHaveLength(5) }) it('gives every tab a title function, an ascending order, single-instance, and a component fn', () => { const { registered } = makeMockRegistry() const eff = makeEffectHarness() apply({ betterSidebar: makeMockRegistry().registry, locale: makeMockLocale(), ...eff } as never) // Re-run on the registry whose descriptors we captured. const { registry, registered: reg2 } = makeMockRegistry() const eff2 = makeEffectHarness() apply({ betterSidebar: registry, locale: makeMockLocale(), ...eff2 } as never) for (const d of reg2) { expect(EXPECTED_IDS).toContain(d.id) // title is a function (i18n-friendly) and resolves to a non-empty string. expect(typeof d.title === 'function' ? typeof d.title() : typeof d.title).toBe('string') if (typeof d.title === 'function') expect(d.title().length).toBeGreaterThan(0) // order is present and numeric. expect(typeof d.order).toBe('number') // single-instance sugar (each apes:* tab is a singleton panel). expect(d.single).toBe(true) // component is a render function. expect(typeof d.component).toBe('function') } // orders are strictly ascending in registration order. const orders = reg2.map((d) => d.order as number) for (let i = 1; i < orders.length; i++) { expect(orders[i]!).toBeGreaterThan(orders[i - 1]!) } }) it('the combined disposer removes all five tabs (HMR-safe teardown)', () => { const { registry } = makeMockRegistry() const eff = makeEffectHarness() apply({ betterSidebar: registry, locale: makeMockLocale(), ...eff } as never) expect(registry.getTabs()).toHaveLength(5) // Simulate fiber unmount: cordis invokes the effect's returned disposer. eff.teardown() expect(registry.getTabs()).toHaveLength(0) // A second teardown is a safe no-op (double-dispose is swallowed). expect(() => eff.teardown()).not.toThrow() }) it('does not register when betterSidebar is absent (bare DSH degrades)', () => { const eff = makeEffectHarness() expect(() => apply({ locale: makeMockLocale(), ...eff } as never)).not.toThrow() // No effect was run (the guard returns before ctx.effect). // The effect harness captured nothing. expect(eff.effect).toBeTypeOf('function') }) it('registers synchronously when no effect API is present (non-cordis harness)', () => { const { registry } = makeMockRegistry() // No `effect` on the context → apply registers synchronously. apply({ betterSidebar: registry, locale: makeMockLocale() } as never) expect(registry.getTabs()).toHaveLength(5) }) it('tab ids follow the apes:* namespace (never wb:* or builtin ids)', () => { const { registered } = makeMockRegistry() const eff = makeEffectHarness() apply({ betterSidebar: makeMockRegistry().registry, locale: makeMockLocale(), ...eff } as never) for (const d of registered) { expect(d.id.startsWith('apes:')).toBe(true) expect(d.id).not.toMatch(/^wb:/) } }) }) /** * Build a mock `ctx.sessions.list.getSnapshot()` feed for badge assertions. * `byId` maps session id → a summary with optional `running` / `origin`. */ function makeMockSessionsCtx(byId: Record) { const snapshot = { current: undefined as string | undefined, byId } return { sessions: { list: { getSnapshot: () => snapshot, subscribe: () => () => undefined, }, }, } } /** * Phase 2 badge contract: `apes:tasks` shows the running-session count, * `apes:agents` shows the subagent count, and BOTH hide (return null) when * their count is 0. The badge is a pure `(ctx) => …` function, so it is * exercised directly against a mock sessions feed — no DOM, no rendering. */ describe('apes-sidebar tab badges (Phase 2)', () => { /** Register into a fresh mock registry and return the captured descriptors. */ function registeredDescriptors() { const { registry, registered } = makeMockRegistry() const eff = makeEffectHarness() apply({ betterSidebar: registry, locale: makeMockLocale(), ...eff } as never) return registered } function badgeOf(registered: Registered[], id: string) { return registered.find((d) => d.id === id)?.badge } it('apes:tasks badge is the running-session count (2 running → 2)', () => { const badge = badgeOf(registeredDescriptors(), 'apes:tasks') expect(badge).toBeTypeOf('function') const ctx = makeMockSessionsCtx({ a: { running: true }, b: { running: true }, c: { running: false }, }) expect(badge!(ctx as never, {} as never, {} as never)).toBe(2) }) it('apes:tasks badge is null when nothing is running (hidden pill)', () => { const badge = badgeOf(registeredDescriptors(), 'apes:tasks') const ctx = makeMockSessionsCtx({ a: { running: false }, b: {} }) expect(badge!(ctx as never, {} as never, {} as never)).toBeNull() }) it('apes:agents badge is the subagent count (1 subagent → 1)', () => { const badge = badgeOf(registeredDescriptors(), 'apes:agents') expect(badge).toBeTypeOf('function') const ctx = makeMockSessionsCtx({ parent: {}, child: { origin: 'subagent' }, other: { running: true }, // running but NOT a subagent → not counted }) expect(badge!(ctx as never, {} as never, {} as never)).toBe(1) }) it('apes:agents badge is null when there are no subagents (hidden pill)', () => { const badge = badgeOf(registeredDescriptors(), 'apes:agents') const ctx = makeMockSessionsCtx({ a: {}, b: { running: true } }) expect(badge!(ctx as never, {} as never, {} as never)).toBeNull() }) it('apps/messages/profile have NO badge (information placeholders, no count)', () => { const registered = registeredDescriptors() for (const id of ['apes:apps', 'apes:messages', 'apes:profile']) { expect(badgeOf(registered, id)).toBeUndefined() } }) }) /** * Phase 2 per-tab state: the apes:tasks `showInactive` filter. When the user * toggles the preference off, only running sessions render. This is a pure * decision function (the hook's uSES↔settings wiring needs a render, but the * filter itself is plain and unit-testable). */ describe('apes:tasks showInactive per-tab state filter (Phase 2)', () => { const sessions = [ { id: 'a', running: true }, { id: 'b', running: false }, { id: 'c', running: true }, { id: 'd' }, // running undefined → treated as not running ] it('showInactive=true renders every session', () => { expect(filterTasksRows(sessions, true)).toHaveLength(4) }) it('showInactive=false renders only running sessions (2 of 4)', () => { const rows = filterTasksRows(sessions, false) expect(rows).toHaveLength(2) expect(rows.map((s) => s.id)).toEqual(['a', 'c']) }) it('showInactive=false on an all-running list keeps everything', () => { expect(filterTasksRows([{ running: true }, { running: true }], false)).toHaveLength(2) }) it('showInactive=false on an all-inactive list yields empty', () => { expect(filterTasksRows([{ running: false }, {}], false)).toHaveLength(0) }) })