Phase A spike of the C8 root fix: instead of only rendering in the fork's
own shell, apes:profile now also registers with DSH's OFFICIAL right
sidebar through its documented two-stage extension protocol:
1. ctx.sidebarRightTabs.register({ id, kind, priority:'extension',
title, guide }) — the type declaration
2. ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register(
{ name:'sidebar.right.pane.tab', key: id }, Body)) — the keyed body
The registry is reached through ctx.inject(['sidebarRightTabs'], ...) (the
same pattern intercept.tsx uses for the async remote.session namespace), so
activation order cannot strand the bridge and a deployment without the
official sidebar stays a silent no-op — the fork's own shell is untouched.
The body reuses the existing ApesProfileView (content is shared, never
duplicated); only the shell-scope binding differs. Guide entry order 70
keeps the apes trio's position in the official entry list.
Verified: typecheck clean; build clean; 1255 passed / 9 skipped (120 files,
6 new bridge specs pinning the two-stage contract, both no-op paths, and the
HMR-safe unwind).
Local commit only — not pushed until the in-app spike is confirmed.
157 lines
5.7 KiB
TypeScript
157 lines
5.7 KiB
TypeScript
/**
|
|
* Native right-sidebar bridge tests (Phase A spike).
|
|
*
|
|
* `registerApesNativeTabs` publishes apes:profile as an OFFICIAL
|
|
* `@deepseek-ai/dsh-client-ui-sidebar-right` tab type over the public
|
|
* two-stage protocol: the type declaration on `ctx.sidebarRightTabs`, then the
|
|
* keyed body on the `sidebar.right.pane.tab` seat. The bridge reaches the
|
|
* registry through `ctx.inject(['sidebarRightTabs'], …)` so activation order
|
|
* cannot strand it, and a deployment without the official sidebar must stay a
|
|
* silent no-op instead of breaking the fork's own shell.
|
|
*
|
|
* The spec drives that contract with a fake cordis context (a recording
|
|
* `inject` + `effect`), so it pins: the dependency name, the definition's
|
|
* identity/band/guide entry, the body's seat key, the disposer's unwind, and
|
|
* both no-op paths. No DOM and no real DSH host — same node environment as
|
|
* apes-tabs.spec.ts.
|
|
*/
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
// Browser globals before the client graph loads (mirror of apes-tabs.spec.ts).
|
|
import './browser-globals.ts'
|
|
|
|
import { registerApesNativeTabs } from '../src/client/apes/native-tabs.tsx'
|
|
|
|
/** One recorded type declaration. */
|
|
interface RecordedDefinition {
|
|
id: string
|
|
kind: string
|
|
priority?: string
|
|
title: () => string
|
|
guide?: Array<{ order: number; title: () => string; description?: () => string; icon?: unknown }>
|
|
}
|
|
|
|
/** One recorded body registration. */
|
|
interface RecordedBody {
|
|
options: { name: string; key?: string; inject?: (...args: unknown[]) => Record<string, unknown> }
|
|
component: unknown
|
|
}
|
|
|
|
/** Build a fake client context plus the registries it hands the bridge. */
|
|
function harness(options: { service?: boolean } = {}) {
|
|
const definitions: RecordedDefinition[] = []
|
|
const bodies: RecordedBody[] = []
|
|
let typeUnwound = false
|
|
let bodyUnwound = false
|
|
|
|
const registry = {
|
|
register(definition: RecordedDefinition) {
|
|
definitions.push(definition)
|
|
return () => { if (!typeUnwound) typeUnwound = true }
|
|
},
|
|
}
|
|
const slots = {
|
|
inject(key: string, callback: () => () => void) {
|
|
// The seat exists: run the declaration callback like the real registry.
|
|
expect(key).toBe('sidebar.right.pane.tab')
|
|
return callback()
|
|
},
|
|
register(registration: RecordedBody['options'], component: unknown) {
|
|
bodies.push({ options: registration, component })
|
|
return () => { if (!bodyUnwound) bodyUnwound = true }
|
|
},
|
|
}
|
|
const effects: Array<() => void> = []
|
|
const fctx = {
|
|
get: (name: string) => (options.service === false ? undefined : name === 'sidebarRightTabs' ? registry : undefined),
|
|
slots,
|
|
effect: (callback: () => () => void) => { effects.push(callback()) },
|
|
}
|
|
const inject = vi.fn((deps: string[], callback: (inner: unknown) => void) => {
|
|
expect(deps).toEqual(['sidebarRightTabs'])
|
|
callback(fctx)
|
|
return { dispose: () => { for (const dispose of effects) dispose() } }
|
|
})
|
|
return {
|
|
ctx: { inject },
|
|
inject,
|
|
definitions,
|
|
bodies,
|
|
get typeUnwound() { return typeUnwound },
|
|
get bodyUnwound() { return bodyUnwound },
|
|
}
|
|
}
|
|
|
|
describe('apes native right-sidebar bridge (spike)', () => {
|
|
it('waits on ctx.inject for the official registry service', () => {
|
|
const h = harness()
|
|
const dispose = registerApesNativeTabs(h.ctx)
|
|
expect(h.inject).toHaveBeenCalledTimes(1)
|
|
expect(h.inject.mock.calls[0]![0]).toEqual(['sidebarRightTabs'])
|
|
dispose()
|
|
})
|
|
|
|
it('registers the profile type in the extension band with a guide entry', () => {
|
|
const h = harness()
|
|
const dispose = registerApesNativeTabs(h.ctx)
|
|
try {
|
|
expect(h.definitions).toHaveLength(1)
|
|
const def = h.definitions[0]!
|
|
expect(def.id).toBe('dsh-apes-sidebar/profile')
|
|
expect(def.kind).toBe('apes:profile')
|
|
// `extension` is the band for types contributed from outside the product.
|
|
expect(def.priority).toBe('extension')
|
|
// The title is read fresh (locale switches must follow).
|
|
expect(def.title()).toBe('画像')
|
|
expect(def.guide).toHaveLength(1)
|
|
const entry = def.guide![0]!
|
|
expect(entry.order).toBe(70)
|
|
expect(entry.title()).toBe('画像')
|
|
expect(typeof entry.description?.()).toBe('string')
|
|
expect(entry.icon).toBeDefined()
|
|
} finally {
|
|
dispose()
|
|
}
|
|
})
|
|
|
|
it('registers the body under the type id on the keyed seat', () => {
|
|
const h = harness()
|
|
const dispose = registerApesNativeTabs(h.ctx)
|
|
try {
|
|
expect(h.bodies).toHaveLength(1)
|
|
const body = h.bodies[0]!
|
|
expect(body.options.name).toBe('sidebar.right.pane.tab')
|
|
// The seat key MUST equal the registered id — that is how the panel
|
|
// dispatches a tab body to our type.
|
|
expect(body.options.key).toBe(h.definitions[0]!.id)
|
|
expect(body.component).toBeDefined()
|
|
// The body's injected props carry the client context the view reads.
|
|
expect(body.options.inject?.()).toEqual({ ctx: h.ctx })
|
|
} finally {
|
|
dispose()
|
|
}
|
|
})
|
|
|
|
it('the disposer unwinds both stages (HMR-safe)', () => {
|
|
const h = harness()
|
|
const dispose = registerApesNativeTabs(h.ctx)
|
|
expect(h.definitions).toHaveLength(1)
|
|
expect(h.bodies).toHaveLength(1)
|
|
dispose()
|
|
expect(h.typeUnwound).toBe(true)
|
|
expect(h.bodyUnwound).toBe(true)
|
|
})
|
|
|
|
it('is a silent no-op when the official registry service never arrives', () => {
|
|
const h = harness({ service: false })
|
|
const dispose = registerApesNativeTabs(h.ctx)
|
|
expect(h.inject).toHaveBeenCalledTimes(1)
|
|
expect(h.definitions).toHaveLength(0)
|
|
expect(h.bodies).toHaveLength(0)
|
|
expect(() => dispose()).not.toThrow()
|
|
})
|
|
|
|
it('is a no-op for a context without inject (non-cordis harness)', () => {
|
|
expect(() => registerApesNativeTabs({} as never)()).not.toThrow()
|
|
})
|
|
})
|