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:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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__
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user