- client/index.tsx 改用 dsh-better-sidebar 真实 TabDescriptor/ TabComponentProps/Context(type-only,擦除不打包) - 注册 5 个面板:apes:tasks(读 sessions,running/current/subagent 标记)、apes:agents(subagent 过滤 + 按 parent 分组)、apes:apps / apes:messages / apes:profile(占位) - 全部走 t() i18n,ctx.effect 包裹注册(HMR 安全 disposer) - betterSidebar 缺失时优雅降级(bare DSH 不破坏 shell) - context-types.ts 删除被取代的 Phase 0 占位镜像,保留 ApesLocaleService - package.json devDeps 补 dsh-better-sidebar(dev-only 类型) - tests/tabs.spec.ts: mock registry 验证 5 个 apes:* id 注册 + disposer 撤销 + 缺失降级 + namespace 锁 门禁全绿:typecheck / build / test(20/20) cordis scratch 真实挂载冒烟留 Phase 2(需真实 DSH host 进程)
157 lines
6.1 KiB
TypeScript
157 lines
6.1 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 } 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
|
|
}
|
|
|
|
/**
|
|
* 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:/)
|
|
}
|
|
})
|
|
})
|