Files
apes-sidebar/tests/tabs.spec.ts
T
figmar 48e16c73c7 feat(apes-sidebar): Phase 2 — per-tab state(tasks showInactive 持久化)
- useTasksPrefs:ctx.settings.register('apesSidebar.tasks') namespace,
  uSES 观察 + update 写回,applies:'live' 跨 reload 持久化
- tasks 面板加 showInactive 开关:OFF 时只渲染 running 会话
- filterTasksRows 纯函数导出(hook 需渲染,过滤决策单测锁定)
- locales:tasks.showInactive zh/en
- tests/tabs.spec.ts:+4 个 per-tab state 过滤测试(25→29)
- AGENTS.md:Phase 2 更新(per-tab state 落地,scratch 冒烟 +
  apps·msgs·profile feed 延后——better-sidebar 无对应数据源 + 无 dsh CLI)

门禁:typecheck rc=0 · build rc=0 · test 29/29
2026-09-04 20:11:06 +08:00

268 lines
10 KiB
TypeScript

/**
* 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<string, { running?: boolean; origin?: string }>) {
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)
})
})