/** * apes:* tab registration tests (Phase 1/1b → C7: 5 tabs). The fork registers * its information-layer tabs (tasks / agents / apps / messages / profile) * through the same BetterSidebarService the upstream builtins use (dogfood). * `apesTasks` is back (C7 reversal of C6): the native DSH session tree focuses * ONE lineage, while this panel is a flat, whole-workspace list with a running * badge and an idle filter — the overlap is complementary. * * The spec pins: the registration contract (id / single / order / component / * icon), the two Phase-2 increments (running-count + subagent-count `badge` * functions, and the C4 `visible` pause on the sessions-list feed), the * `showInactive` plugin-toggle channel, the PURE row helpers, and the isolated * i18n contract (zh / ja / en parity + active-locale resolution). * * Runs in the node vitest environment like builtins.spec.ts — no DOM, no * real DSH host (a live cordis scratch mount is covered by the mount e2e). */ import { describe, expect, it } from 'vitest' // Browser globals before the client graph loads (mirror of builtins.spec.ts). import './browser-globals.ts' import type { Context } from '../src/context-types.ts' import type { SidebarSessionList, SidebarSessionSummary } from '../src/context-types.ts' import { createBetterSidebarService } from '../src/client/service.ts' import type { SessionScope, SidebarState, TabComponentProps } from '../src/client/service.ts' import { createSidebarStore } from '../src/client/state.ts' import { registerApesTabs } from '../src/client/apes/index.ts' import { apesZh, apesEn, apesJa, attachApesLocale, t, type ApesCopyKey } from '../src/client/apes/locales.ts' import { NOOP_SUBSCRIBE, TASKS_SHOW_INACTIVE_KEY, TASKS_TAB_ID, apesTabs, feedSubscribe, runningSessionCount, subagentCount, tasksRows, tasksShowInactive, } from '../src/client/apes/tabs.tsx' const APES_TAB_IDS = ['apesAgents', 'apesApps', 'apesMessages', 'apesProfile', 'apesTasks'] function setup(): { service: ReturnType store: ReturnType dispose: () => void } { const store = createSidebarStore() const service = createBetterSidebarService(store) const dispose = registerApesTabs({} as Context, service) return { service, store, dispose } } /** A durable session row fixture. */ function row(id: string, over: Partial = {}): SidebarSessionSummary { return { id, displayTitle: id, ...over } } /** A session-list snapshot fixture. */ function snap(current: string | undefined, rows: readonly SidebarSessionSummary[]): SidebarSessionList { return { current, byId: Object.fromEntries(rows.map(r => [r.id, r])) } } /** A minimal ctx carrying only the sessions-list feed the apes views read. */ function fakeCtx(list: SidebarSessionList, onSubscribe?: () => void): Context { return { sessions: { list: { getSnapshot: () => list, subscribe: () => { onSubscribe?.() return () => {} }, }, }, } as unknown as Context } /** Invoke a descriptor's badge with a fixture snapshot. */ function badgeOf(id: string, list: SidebarSessionList, sessionId = 'main'): string | number | null | undefined { const descriptor = apesTabs.find(d => d.id === id) expect(descriptor, `${id} descriptor`).toBeDefined() return descriptor?.badge?.( fakeCtx(list), { sessionId } as SessionScope, {} as SidebarState, ) } describe('apes:* tab registrations', () => { it('registers the 5 apes tabs with the apes: namespace', () => { const { service, dispose } = setup() try { expect(service.getTabs().map(t => t.id).sort()).toEqual(APES_TAB_IDS) } finally { dispose() } }) it('every apes tab is single-instance, visible, and component-carrying', () => { const { service, dispose } = setup() try { for (const id of APES_TAB_IDS) { const tab = service.getTab(id) expect(tab, `${id} registered`).toBeDefined() expect(tab?.single, `${id} single`).toBe(true) expect(tab?.hidden, `${id} hidden`).not.toBe(true) expect(tab?.component, `${id} component`).toBeDefined() expect(tab?.icon, `${id} icon`).toBeDefined() expect(typeof tab?.title === 'function' ? tab.title() : tab?.title, `${id} title`).toBeTruthy() } } finally { dispose() } }) it('apes tabs sort after the upstream builtins (order 40+) in reading order', () => { const { service, dispose } = setup() try { const orders = { tasks: service.getTab(TASKS_TAB_ID)?.order, agents: service.getTab('apesAgents')?.order, apps: service.getTab('apesApps')?.order, messages: service.getTab('apesMessages')?.order, profile: service.getTab('apesProfile')?.order, } // C8: order 40-50 places apes tabs immediately after the native builtins // (10-35), not in a far-away 900+ band — visual unity in the tab strip. for (const [id, order] of Object.entries(orders)) { expect(order, `${id} order`).toBeGreaterThanOrEqual(40) } // Strictly ordered: tasks < agents < apps < messages < profile. expect(orders.tasks!).toBeLessThan(orders.agents!) expect(orders.agents!).toBeLessThan(orders.apps!) expect(orders.apps!).toBeLessThan(orders.messages!) expect(orders.messages!).toBeLessThan(orders.profile!) } finally { dispose() } }) it('the disposer unregisters all apes tabs (HMR-safe)', () => { const store = createSidebarStore() const service = createBetterSidebarService(store) const dispose = registerApesTabs({} as Context, service) expect(service.getTabs()).toHaveLength(5) dispose() expect(service.getTabs()).toHaveLength(0) }) }) describe('apesTasks plugin toggles', () => { it('declares the showInactive switch under the tasks descriptor', () => { const descriptor = apesTabs.find(d => d.id === TASKS_TAB_ID) const toggles = descriptor?.settings?.pluginToggles ?? [] expect(toggles).toHaveLength(1) const toggle = toggles[0] expect(toggle?.key).toBe(TASKS_SHOW_INACTIVE_KEY) expect(toggle?.key).toBe('showInactive') expect(toggle?.type).toBe('switch') expect(typeof toggle?.title === 'function' ? toggle.title() : toggle?.title).toBeTruthy() }) it('defaults to showing every session when no pref is stored', () => { const store = { getPrefs: () => ({}) } as unknown as TabComponentProps['store'] expect(tasksShowInactive(store)).toBe(true) }) it('reads the boolean from pluginSettings[apesTasks]', () => { const store = { getPrefs: () => ({ pluginSettings: { [TASKS_TAB_ID]: { [TASKS_SHOW_INACTIVE_KEY]: false } } }), } as unknown as TabComponentProps['store'] expect(tasksShowInactive(store)).toBe(false) }) it('ignores non-boolean values and failing stores (fail soft)', () => { const odd = { getPrefs: () => ({ pluginSettings: { [TASKS_TAB_ID]: { [TASKS_SHOW_INACTIVE_KEY]: 'nope' } } }), } as unknown as TabComponentProps['store'] expect(tasksShowInactive(odd)).toBe(true) const broken = { getPrefs: () => { throw new Error('no prefs') } } as unknown as TabComponentProps['store'] expect(tasksShowInactive(broken)).toBe(true) }) }) describe('apesTasks row selection (PURE)', () => { it('ranks current first, then running, then idle', () => { const list = snap('idle-b', [ row('idle-a'), row('run-2', { running: true }), row('idle-b'), row('run-1', { running: true }), ]) expect(tasksRows(list, true).map(r => r.id)).toEqual(['idle-b', 'run-1', 'run-2', 'idle-a']) }) it('hides idle sessions when showInactive is false', () => { const list = snap('main', [row('main'), row('work', { running: true }), row('sleeping')]) // C-2 fidelity: v2.0.0 filters on `running` alone, so an idle *current* // session is dropped too — the toggle means "idle sessions disappear", // not "always keep the one I am looking at". expect(tasksRows(list, false).map(r => r.id)).toEqual(['work']) expect(tasksRows(list, true).map(r => r.id)).toEqual(['main', 'work', 'sleeping']) }) it('never lists side-chat threads (they are not topology)', () => { const list = snap('main', [row('main'), row('side', { origin: 'subagent', displayTitle: 'Side: main' })]) expect(tasksRows(list, true).map(r => r.id)).toEqual(['main']) }) it('counts running sessions for the badge', () => { const list = snap('main', [ row('main', { running: true }), row('child', { running: true, origin: 'subagent', parentId: 'main' }), row('idle'), row('side', { origin: 'subagent', displayTitle: 'Side: main' }), ]) expect(runningSessionCount(list)).toBe(2) }) }) describe('apesAgents badge + topology count', () => { it('counts the subagents of the current session tree only', () => { const list = snap('main', [ row('main'), row('c1', { origin: 'subagent', parentId: 'main' }), row('c2', { origin: 'subagent', parentId: 'c1' }), row('other'), row('o1', { origin: 'subagent', parentId: 'other' }), ]) expect(subagentCount(list, 'main')).toBe(2) expect(subagentCount(list, 'c1')).toBe(2) expect(subagentCount(list, 'other')).toBe(1) expect(subagentCount(list, 'unknown')).toBe(0) }) it('excludes side-chat threads from the count', () => { const list = snap('main', [ row('main'), row('side', { origin: 'subagent', parentId: 'main', displayTitle: 'Side: main' }), row('c1', { origin: 'subagent', parentId: 'main' }), ]) expect(subagentCount(list, 'main')).toBe(1) }) }) describe('apes badge contract', () => { it('badges the tasks tab with the running count, null when idle', () => { expect(badgeOf(TASKS_TAB_ID, snap('main', [row('a', { running: true }), row('b')]))).toBe(1) expect(badgeOf(TASKS_TAB_ID, snap('main', [row('a'), row('b')]))).toBeNull() }) it('badges the agents tab with the subagent count, null when childless', () => { expect(badgeOf('apesAgents', snap('main', [row('main'), row('c1', { origin: 'subagent', parentId: 'main' })]), 'main')).toBe(1) expect(badgeOf('apesAgents', snap('main', [row('main')]), 'main')).toBeNull() }) it('never badges the placeholder/static tabs', () => { for (const id of ['apesApps', 'apesMessages', 'apesProfile']) { expect(apesTabs.find(d => d.id === id)?.badge, `${id} badge`).toBeUndefined() } }) }) describe('apes C4 visible pause', () => { it('does not touch the sessions feed while the tab is hidden', () => { let subscribed = 0 const ctx = fakeCtx(snap('main', [row('main')]), () => { subscribed += 1 }) feedSubscribe(ctx, false) expect(subscribed).toBe(0) const stop = feedSubscribe(ctx, true) (() => {}) expect(subscribed).toBe(1) expect(typeof stop).toBe('function') stop() }) it('exposes a stable no-op subscription', () => { const stop = NOOP_SUBSCRIBE() expect(typeof stop).toBe('function') expect(() => stop()).not.toThrow() }) }) describe('apes i18n contract', () => { it('every apesZh key has an en entry (no missing translations)', () => { const zhKeys = Object.keys(apesZh) as ApesCopyKey[] for (const key of zhKeys) { expect(apesEn[key], `en entry for ${key}`).toBeDefined() expect(typeof apesEn[key]).toBe('string') expect((apesEn[key] ?? '').length).toBeGreaterThan(0) } }) it('every apesZh key has a ja entry (AGENTS §5)', () => { const zhKeys = Object.keys(apesZh) as ApesCopyKey[] for (const key of zhKeys) { expect(apesJa[key], `ja entry for ${key}`).toBeDefined() expect(typeof apesJa[key]).toBe('string') expect((apesJa[key] ?? '').length).toBeGreaterThan(0) } }) it('resolves zh when the DSH preference is zh', () => { attachApesLocale({ getSnapshot: () => ({ active: 'zh-CN' }) }) expect(t('apesAgentsTitle')).toBe('智能体') expect(t('apesAppsTitle')).toBe('应用') expect(t('apesProfileTitle')).toBe('画像') }) it('resolves the new tasks/messages titles in zh', () => { attachApesLocale({ getSnapshot: () => ({ active: 'zh' }) }) expect(t('apesTasksTitle')).toBe('任务') expect(t('apesMessagesTitle')).toBe('消息') }) it('resolves en when the DSH preference is en', () => { attachApesLocale({ getSnapshot: () => ({ active: 'en' }) }) expect(t('apesAgentsTitle')).toBe('Agents') expect(t('apesAppsTitle')).toBe('Apps') expect(t('apesProfileTitle')).toBe('Profile') expect(t('apesTasksTitle')).toBe('Tasks') }) it('resolves ja when the DSH preference is ja', () => { attachApesLocale({ getSnapshot: () => ({ active: 'ja-JP' }) }) expect(t('apesAgentsTitle')).toBe('エージェント') expect(t('apesTasksTitle')).toBe('タスク') expect(t('apesMessagesTitle')).toBe('メッセージ') }) it('falls back to zh when no locale service is attached', () => { attachApesLocale(undefined) expect(t('apesAgentsTitle')).toBe('智能体') }) })