feat(apes-sidebar): Phase 0 工程骨架 — host/client 分裂 + i18n + 不变量面

从 0 重写的 v2.0.0 DSH 侧边栏插件第一阶段:
- 工程骨架:tsdown 双构建(host ESM + client CJS closure)、vitest、strict TS
- host 半:namespace 插件形状(name/inject/apply,无 default),minimal apply
- client 半:closure factory 注册 5 个 apes:* tab 占位 + zh/en i18n
- 不变量面:assert/semver/peer guard(dsh-better-sidebar >= 0.18.0)
- 依赖策略:dsh-better-sidebar=peerDependency(不打包),cordis=devDependency(仅类型)
- 门禁:pnpm typecheck + build + test 全绿(14 单测通过)
- AGENTS.md 工程约定 + .gitignore(排除 lib/node_modules/maxkb-integration)

模板:dsh-better-sidebar。作用域锁定信息层(tasks/agents/apps/messages/profile),
不含 file/terminal/browser(归 better-sidebar)、不含 team/room(归 background-agents)。
This commit is contained in:
figmar
2026-09-04 17:14:22 +08:00
parent c319fb106e
commit 0e91e99f49
19 changed files with 5838 additions and 0 deletions
+128
View File
@@ -0,0 +1,128 @@
/**
* Locale contract for the client half. The apes:* tabs are bilingual
* (zh default, en fallback) — the same shape tutti-os ships. This spec pins
* the i18n surface against the REAL locale module API:
* - t() resolves the active locale, falling back en → raw key
* - registerLocales() syncs activeLocale from a mock ApesLocaleService and
* keeps it in sync as the runtime emits new snapshots
* - subscribeLocale() re-render hook + clean detach
* The React hook (useApesLocale) needs a renderer; it is a thin
* useSyncExternalStore wrapper over the same getSnapshot/subscribe pair and
* is exercised indirectly here.
*/
import { describe, expect, it } from 'vitest'
import {
t,
getActiveLocale,
subscribeLocale,
registerLocales,
} from '../src/client/locales.ts'
import type { ApesLocaleService } from '../src/context-types.ts'
/** Build a mock ApesLocaleService whose active locale we can flip. */
function makeLocaleService(initialActive = 'zh'): {
service: ApesLocaleService
setActive: (loc: string) => void
registered: Array<{ ns: string; locale: string }>
} {
let active = initialActive
const subs = new Set<() => void>()
const registered: Array<{ ns: string; locale: string }> = []
const service: ApesLocaleService = {
getSnapshot: () => ({ active }),
subscribe: (fn) => {
subs.add(fn)
return () => subs.delete(fn)
},
register: (ns, locale) => {
registered.push({ ns, locale })
return () => {}
},
}
return {
service,
setActive: (loc) => {
active = loc
for (const fn of [...subs]) fn()
},
registered,
}
}
describe('locales module (real API)', () => {
it('resolves keys in the default zh locale', () => {
// Before any registerLocales call, activeLocale is the module default.
const active = getActiveLocale()
expect(['zh', 'en']).toContain(active)
// Every registered key resolves to a non-empty string.
expect(t('tab.tasks.title').length).toBeGreaterThan(0)
expect(t('tab.agents.title').length).toBeGreaterThan(0)
// A key in neither locale falls back to the raw key (flag the gap).
expect(t('does.not.exist')).toBe('does.not.exist')
})
it('registerLocales registers zh+en under the apesSidebar namespace', () => {
const { service, registered } = makeLocaleService('zh')
const dispose = registerLocales(service)
expect(registered.map((r) => r.locale).sort()).toEqual(['en', 'zh'])
expect(registered.every((r) => r.ns === 'apesSidebar')).toBe(true)
dispose() // clean disposer, no throw on double-run
expect(() => dispose()).not.toThrow()
})
it('registerLocales seeds activeLocale from the runtime snapshot', () => {
const { service } = makeLocaleService('en')
const dispose = registerLocales(service)
expect(getActiveLocale()).toBe('en')
expect(t('tab.tasks.title')).toBe('Tasks')
dispose()
})
it('keeps activeLocale in sync as the runtime emits a new active locale', () => {
const { service, setActive } = makeLocaleService('zh')
const dispose = registerLocales(service)
expect(getActiveLocale()).toBe('zh')
const before = t('tab.tasks.title')
setActive('en')
expect(getActiveLocale()).toBe('en')
expect(t('tab.tasks.title')).toBe('Tasks')
// The value actually changed.
expect(before).not.toBe(t('tab.tasks.title'))
dispose()
})
it('subscribeLocale notifies on locale change and detaches cleanly', () => {
const { service, setActive } = makeLocaleService('zh')
const dispose = registerLocales(service)
const seen: string[] = []
const unsub = subscribeLocale(() => seen.push(getActiveLocale()))
setActive('en')
expect(seen).toEqual(['en'])
unsub()
setActive('zh')
// After detach, no further observation.
expect(seen).toEqual(['en'])
dispose()
})
it('is a no-op (clean disposer) when the locale runtime is absent', () => {
const dispose = registerLocales(undefined)
expect(typeof dispose).toBe('function')
expect(() => dispose()).not.toThrow()
})
it('tolerates a runtime whose subscribe throws (early boot guard)', () => {
const service: ApesLocaleService = {
getSnapshot: () => ({ active: 'en' }),
subscribe: () => {
throw new Error('runtime not ready')
},
register: () => () => {},
}
// The try/catch in registerLocales swallows the subscribe failure and
// still returns a working disposer.
const dispose = registerLocales(service)
expect(typeof dispose).toBe('function')
expect(() => dispose()).not.toThrow()
})
})
+110
View File
@@ -0,0 +1,110 @@
/**
* Phase 0 smoke spec: mounts the host plugin against a minimal fake context,
* verifies the plugin export shape (no stray default, so the cordis Loader
* keeps name/inject/apply), and exercises the invariant surface. Runs with
* `pnpm test`.
*
* The client half (lib/client.js closure factory) is a browser artifact — it
* registers tabs into the better-sidebar host registry at runtime, which
* needs a live DSH shell. The client registration contract is therefore
* covered by a focused unit spec (locales) + the build's purity gate; a full
* mount smoke (cordis scratch profile) lands in Phase 1 with the real tabs.
*/
import { describe, expect, it } from 'vitest'
import * as host from '../src/index.ts'
import {
InvariantError,
assert,
assertBetterSidebarPeer,
compareSemver,
} from '../src/invariant.ts'
/**
* Run the real namespace export through `Loader.unwrapExports`; a stray
* default would discard `name`, `inject`, and `apply`. Same guard the
* official plugin repos ship.
*/
describe('apes-sidebar host plugin shape', () => {
it('has the namespace-plugin export shape (no stray default)', () => {
expect('default' in host).toBe(false)
expect(typeof host.apply).toBe('function')
expect(host.name).toBe('apes-sidebar')
expect(host.inject).toEqual(['logger'])
})
it('apply mounts a logger line through the effect (Phase 0 minimal)', () => {
const logs: string[] = []
let effects = 0
const ctx = {
logger: { info: (msg: string) => logs.push(msg) },
effect: (fn: () => void | (() => void)) => {
effects += 1
const cleanup = fn()
return () => {
if (typeof cleanup === 'function') cleanup()
}
},
}
host.apply(ctx as never)
expect(effects).toBe(1)
expect(logs).toEqual(['[apes-sidebar] host half mounted (Phase 0 skeleton)'])
})
it('apply survives a missing logger (degrades, never throws)', () => {
let effects = 0
const ctx = {
effect: (fn: () => void | (() => void)) => {
effects += 1
fn()
},
}
expect(() => host.apply(ctx as never)).not.toThrow()
expect(effects).toBe(1)
})
it('apply without an effect API still runs once synchronously', () => {
const logs: string[] = []
const ctx = { logger: { info: (msg: string) => logs.push(msg) } }
host.apply(ctx as never)
expect(logs).toEqual(['[apes-sidebar] host half mounted (Phase 0 skeleton)'])
})
})
describe('apes-sidebar invariant surface', () => {
it('assert throws an InvariantError on a falsy condition', () => {
expect(() => assert(false, 'boom')).toThrow(InvariantError)
expect(() => assert(false, 'boom')).toThrow('boom')
// A truthy condition passes silently.
expect(() => assert(true, 'ok')).not.toThrow()
expect(() => assert(1, 'ok')).not.toThrow()
})
it('compareSemver orders versions (numeric + rc prerelease)', () => {
expect(compareSemver('0.17.9', '0.18.0')).toBeLessThan(0)
expect(compareSemver('0.18.0', '0.18.0')).toBe(0)
expect(compareSemver('0.19.0', '0.18.0')).toBeGreaterThan(0)
expect(compareSemver('1.0.0', '0.99.99')).toBeGreaterThan(0)
// Prerelease sorts below the release it qualifies.
expect(compareSemver('0.18.0-rc.1', '0.18.0')).toBeLessThan(0)
expect(compareSemver('0.18.0-rc.1', '0.18.0-rc.2')).toBeLessThan(0)
})
it('assertBetterSidebarPeer warns on an old peer (no logger → no throw)', () => {
const warnings: string[] = []
const warn = (msg: string) => warnings.push(msg)
// An old peer (0.17.x) triggers a warn.
;(globalThis as Record<string, unknown>).__apesPeer__ = { version: '0.17.9' }
assertBetterSidebarPeer({ warn })
expect(warnings).toHaveLength(1)
expect(warnings[0]).toContain('0.17.9')
// A new peer (0.18.x) does not.
warnings.length = 0
;(globalThis as Record<string, unknown>).__apesPeer__ = { version: '0.18.0' }
assertBetterSidebarPeer({ warn })
expect(warnings).toHaveLength(0)
// No logger at all: still no throw.
;(globalThis as Record<string, unknown>).__apesPeer__ = { version: '0.17.0' }
expect(() => assertBetterSidebarPeer(undefined)).not.toThrow()
delete (globalThis as Record<string, unknown>).__apesPeer__
})
})