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.
336 lines
13 KiB
TypeScript
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('智能体')
|
|
})
|
|
})
|