Files
apes-sidebar/tests/native-tabs.spec.ts
figmar 96cc334143 spike(A): publish apes:profile as an official right-sidebar tab type
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.
2026-10-03 20:42:13 +08:00

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()
})
})