Files
apes-sidebar/tests/apes-tabs.spec.ts
T
figmar aa8f417aa3
CI / ci (pull_request) Canceled after 0s
CI / ci-windows (pull_request) Canceled after 0s
CI / plugin-mount (pull_request) Canceled after 0s
fix(settings): use the host's @deepseek-ai/schemastery so volatile prefs apply
Every side-card switch was dead: settings.update returned OK and
cordis.patch.yml was written, yet the running plugin never saw the new value
and nothing reported an error.

Root cause: the plugin declared Config with the plain npm `schemastery`
(3.18.0), which has no volatile support. The host resolves plugin config via
`runtime.Config['~standard'].validate()` — i.e. the plugin's OWN schemastery —
so `meta.volatile` nodes were never wrapped in cosmokit volatile references.
`cordis-plugin-loader`'s `_commitVolatile()` bails out as soon as
`volatileEntries(fiber.config)` is empty, so every volatile-only edit was
silently dropped: no in-place update, no plugin restart.

Fix: import z from '@deepseek-ai/schemastery' (peer provided by the host at
mount; pinned 3.18.4 as a devDependency for the dev tree), matching the
repo's existing pnpm-workspace convention for @deepseek-ai/* peers.

Verified: probe reports tabsIsVolatile=true and revision climbing 0->6 across
six writes, each read-back matching the written value (previously the value
was frozen and revision stuck at 1).

Also in this change:
- apes tab ids lose their colon (apes:tasks -> apesTasks, ...): the host's
  settings projection dropped map keys containing a colon.
- sidebar prefs ns follows the profile entry id (`better-sidebar`).
- subagent rows degrade to summary rows when the root catalog never hydrates.
- docs/feedback-0.2.0-rc2-contract-gaps.md: four host contract gaps, gap 4
  documenting the silent volatile-write drop above.

Gates: typecheck 0, lint 0, build 0, test 131 files / 1385 passed.
2026-10-13 22:27:19 +08:00

336 lines
13 KiB
TypeScript

/**
* 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<typeof createBetterSidebarService>
store: ReturnType<typeof createSidebarStore>
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> = {}): 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('智能体')
})
})