|
|
@@ -0,0 +1,546 @@
|
|
|
+// @vitest-environment jsdom
|
|
|
+/**
|
|
|
+ * SlotTestRuntime behavior: root declaration + rendering, session
|
|
|
+ * add/update/switch/remove through the real renderer, shared store identity
|
|
|
+ * and scope pruning, feature mount/dispose cascade, and runtime disposal
|
|
|
+ * idempotence. All through the production SlotsService + createSlotRenderer
|
|
|
+ * stack — this suite is the fixture the migrated feature specs rely on.
|
|
|
+ */
|
|
|
+import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
|
+import { cleanup } from '@testing-library/react'
|
|
|
+import { defineStore } from '@deepseek-ai/dsh-client-runtime/client'
|
|
|
+import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
|
|
+import type { PropsRenderSlots, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
|
|
|
+import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
|
|
|
+
|
|
|
+declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
|
+ interface SlotMap {
|
|
|
+ 'trt.panel': { kind: 'single'; scope: 'root'; owner: { label?: string } }
|
|
|
+ 'trt.chat': { kind: 'single'; scope: 'session' }
|
|
|
+ 'trt.rows': { kind: 'list'; scope: 'root' }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+afterEach(cleanup)
|
|
|
+
|
|
|
+type FrameProps = PropsRenderSlots<'trt.panel' | 'trt.chat' | 'trt.rows'>
|
|
|
+
|
|
|
+/** Root frame declaring all three suite slots (render sites for each kind). */
|
|
|
+function Frame({ renderSlot, SessionProvider }: FrameProps) {
|
|
|
+ return (
|
|
|
+ <>
|
|
|
+ {renderSlot('trt.panel', { label: 'from-owner' }, { fallback: <i>no panel</i> })}
|
|
|
+ <SessionProvider empty={() => <i>no session</i>}>
|
|
|
+ {() => renderSlot('trt.chat', {})}
|
|
|
+ </SessionProvider>
|
|
|
+ {renderSlot('trt.rows', {})}
|
|
|
+ </>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+const CHILDREN = {
|
|
|
+ 'trt.panel': { kind: 'single', scope: 'root' },
|
|
|
+ 'trt.chat': { kind: 'single', scope: 'session' },
|
|
|
+ 'trt.rows': { kind: 'list', scope: 'root' },
|
|
|
+} as const
|
|
|
+
|
|
|
+async function runtimeWithFrame() {
|
|
|
+ const runtime = await SlotTestRuntime.create()
|
|
|
+ await runtime.root.declare(CHILDREN, Frame)
|
|
|
+ return runtime
|
|
|
+}
|
|
|
+
|
|
|
+describe('root declaration and rendering', () => {
|
|
|
+ it('renders declared slots through the real renderer: fallback, then a live registration, then unload', async () => {
|
|
|
+ const runtime = await runtimeWithFrame()
|
|
|
+ const view = runtime.renderRoot()
|
|
|
+ expect(view.container.textContent).toContain('no panel')
|
|
|
+
|
|
|
+ let dispose = (): void => {}
|
|
|
+ await runtime.flush() // no-op guard: flush outside mutations is safe
|
|
|
+ await (async () => {
|
|
|
+ dispose = runtime.slots.register(
|
|
|
+ { name: 'trt.panel' },
|
|
|
+ ({ label }: { label?: string }) => <b>panel:{label}</b>)
|
|
|
+ await runtime.flush()
|
|
|
+ })()
|
|
|
+ expect(view.container.textContent).toContain('panel:from-owner')
|
|
|
+ dispose()
|
|
|
+ await runtime.flush()
|
|
|
+ expect(view.container.textContent).toContain('no panel')
|
|
|
+ await runtime.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('fails loud when rendering with no root declaration (production boot-order check)', async () => {
|
|
|
+ const runtime = await SlotTestRuntime.create()
|
|
|
+ expect(() => runtime.renderRoot()).toThrow(/'root' has no registration/)
|
|
|
+ await runtime.dispose()
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+describe('sessions', () => {
|
|
|
+ it('drives SessionProvider: empty state, current session, switch, live snapshot updates', async () => {
|
|
|
+ const runtime = await runtimeWithFrame()
|
|
|
+ runtime.slots.register({ name: 'trt.chat' }, (props: SessionStandardProps) => {
|
|
|
+ const running = props.useSession(s => s.running)
|
|
|
+ return <span>chat:{props.sessionId}:{String(running)}</span>
|
|
|
+ })
|
|
|
+ const view = runtime.renderRoot()
|
|
|
+ expect(view.container.textContent).toContain('no session')
|
|
|
+
|
|
|
+ await runtime.sessions.add({ id: 's1' })
|
|
|
+ expect(view.container.textContent).toContain('chat:s1:false')
|
|
|
+
|
|
|
+ await runtime.sessions.updateSnapshot('s1', (draft) => { draft.running = true })
|
|
|
+ expect(view.container.textContent).toContain('chat:s1:true')
|
|
|
+
|
|
|
+ await runtime.sessions.add({ id: 's2' }) // becomes current by default
|
|
|
+ expect(view.container.textContent).toContain('chat:s2:false')
|
|
|
+
|
|
|
+ await runtime.sessions.setCurrent(undefined)
|
|
|
+ expect(view.container.textContent).toContain('no session')
|
|
|
+ await runtime.sessions.setCurrent('s1')
|
|
|
+ expect(view.container.textContent).toContain('chat:s1:true')
|
|
|
+ await runtime.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('add with current:false keeps the selection; unknown ids fail loud on the mutators', async () => {
|
|
|
+ const runtime = await runtimeWithFrame()
|
|
|
+ await runtime.sessions.add({ id: 's1' })
|
|
|
+ await runtime.sessions.add({ id: 's2' }, { current: false })
|
|
|
+ expect(runtime.sessions.list.getSnapshot().current).toBe('s1')
|
|
|
+ expect(runtime.sessions.list.getSnapshot().ids).toEqual(['s1', 's2'])
|
|
|
+ await expect(runtime.sessions.add({ id: 's1' })).rejects.toThrow(/already added/)
|
|
|
+ await expect(runtime.sessions.setCurrent('ghost')).rejects.toThrow(/not added/)
|
|
|
+ await expect(runtime.sessions.updateSnapshot('ghost', () => {})).rejects.toThrow(/not added/)
|
|
|
+ await expect(runtime.sessions.remove('ghost')).rejects.toThrow(/not added/)
|
|
|
+ expect(() => runtime.sessions.behavior('ghost')).toThrow(/not added/)
|
|
|
+ await runtime.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('mints REAL-tag scopes lazily and resolves them through the production scopeOf; bindings expose the behavior face', async () => {
|
|
|
+ const runtime = await runtimeWithFrame()
|
|
|
+ const prompt = vi.fn()
|
|
|
+ await runtime.sessions.add({ id: 's1', session: { prompt } })
|
|
|
+
|
|
|
+ expect(runtime.sessions.provideInfo('ghost')).toBeUndefined()
|
|
|
+ expect(runtime.sessions.scope('ghost')).toBeUndefined()
|
|
|
+ expect(runtime.sessions.binding('ghost')).toBeUndefined()
|
|
|
+
|
|
|
+ const scope = runtime.sessions.scope('s1')!
|
|
|
+ expect(runtime.sessions.scope('s1')).toBe(scope) // stable per session
|
|
|
+ expect(runtime.sessions.scopeOf(scope)).toBe('s1')
|
|
|
+ expect(runtime.sessions.scopeOf(runtime.ctx)).toBeUndefined()
|
|
|
+ // sessionOf resolves the behavior face off the scope tag.
|
|
|
+ expect(runtime.sessions.sessionOf(scope)).toBe(runtime.sessions.behavior('s1'))
|
|
|
+ expect(runtime.sessions.sessionOf(runtime.ctx)).toBeUndefined()
|
|
|
+
|
|
|
+ const binding = runtime.sessions.binding('s1')!
|
|
|
+ expect(binding.sessionId).toBe('s1')
|
|
|
+ expect(binding.ctx).toBe(scope)
|
|
|
+ ;(binding.session as { prompt: () => void }).prompt()
|
|
|
+ expect(prompt).toHaveBeenCalledOnce()
|
|
|
+ expect(runtime.sessions.behavior('s1')).toBe(binding.session)
|
|
|
+ // The binding's session doubles as the conversation observable face.
|
|
|
+ expect((binding.session as { getSnapshot(): { sessionId: string } }).getSnapshot().sessionId).toBe('s1')
|
|
|
+
|
|
|
+ // A scoped service resolves through the scope ctx (scope-addressed pattern).
|
|
|
+ runtime.provide('probe', { hello: 'world' })
|
|
|
+ expect(scope.get('probe')).toEqual({ hello: 'world' })
|
|
|
+ await runtime.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('materializes provide bundles: built-in session hook, custom providers, no-session projection', async () => {
|
|
|
+ const runtime = await runtimeWithFrame()
|
|
|
+ await runtime.sessions.add({ id: 's1' })
|
|
|
+
|
|
|
+ const info = runtime.sessions.provideInfo('s1')!
|
|
|
+ expect(info.sessionId).toBe('s1')
|
|
|
+ expect(info.hooks['session']).toBeDefined() // the built-in useSession source
|
|
|
+ expect(runtime.sessions.provideInfo('s1')).toBe(info) // identity-stable
|
|
|
+
|
|
|
+ // A feature provider (the ui-conversation input pattern): declared names
|
|
|
+ // materialize per session and land in the no-session roster as undefined.
|
|
|
+ const off = runtime.sessions.provide({
|
|
|
+ hooks: ['probe'],
|
|
|
+ props: ['probeActions'],
|
|
|
+ resolve: binding => ({
|
|
|
+ hooks: { probe: { getSnapshot: () => binding.sessionId, subscribe: () => () => {} } },
|
|
|
+ props: { probeActions: { poke: () => {} } },
|
|
|
+ }),
|
|
|
+ })
|
|
|
+ const enriched = runtime.sessions.provideInfo('s1')!
|
|
|
+ expect(enriched.hooks['probe']?.getSnapshot()).toBe('s1')
|
|
|
+ expect(enriched.props['probeActions']).toBeDefined()
|
|
|
+ const maybe = runtime.sessions.maybeProvideInfo(undefined)
|
|
|
+ expect(maybe.sessionId).toBeUndefined()
|
|
|
+ expect(Object.keys(maybe.hooks)).toEqual(['session', 'probe'])
|
|
|
+ expect(runtime.sessions.maybeProvideInfo('s1')).toBe(runtime.sessions.provideInfo('s1'))
|
|
|
+ expect(runtime.sessions.maybeProvideInfo('ghost').sessionId).toBeUndefined()
|
|
|
+
|
|
|
+ // Misdeclared providers fail loud AT REGISTRATION (the production
|
|
|
+ // channel rebuilds live bundles eagerly and rolls the roster back):
|
|
|
+ // missing hook, missing prop, duplicate hook, duplicate prop.
|
|
|
+ expect(() => runtime.sessions.provide({ hooks: ['void'], resolve: () => ({}) }))
|
|
|
+ .toThrow(/missing hook "void"/)
|
|
|
+ expect(() => runtime.sessions.provide({ props: ['void'], resolve: () => ({}) }))
|
|
|
+ .toThrow(/missing prop "void"/)
|
|
|
+ expect(() => runtime.sessions.provide({
|
|
|
+ hooks: ['session'],
|
|
|
+ resolve: () => ({ hooks: { session: { getSnapshot: () => 0, subscribe: () => () => {} } } }),
|
|
|
+ })).toThrow(/duplicate hook "session"/)
|
|
|
+ const propA = runtime.sessions.provide({ props: ['twice'], resolve: () => ({ props: { twice: 1 } }) })
|
|
|
+ expect(() => runtime.sessions.provide({ props: ['twice'], resolve: () => ({ props: { twice: 2 } }) }))
|
|
|
+ .toThrow(/duplicate prop "twice"/)
|
|
|
+ propA()
|
|
|
+ // The rejected registrations rolled back: the roster still materializes.
|
|
|
+ expect(runtime.sessions.provideInfo('s1')).toBeDefined()
|
|
|
+ off()
|
|
|
+ off() // disposer is idempotent
|
|
|
+ expect(Object.keys(runtime.sessions.maybeProvideInfo(undefined).hooks)).toEqual(['session'])
|
|
|
+ await runtime.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('records service-face calls; open() moves the selection and clear() empties it', async () => {
|
|
|
+ const runtime = await runtimeWithFrame()
|
|
|
+ await runtime.sessions.add({ id: 's1' })
|
|
|
+ await runtime.sessions.add({ id: 's2' })
|
|
|
+ runtime.sessions.open('s1' as SessionId)
|
|
|
+ await runtime.flush()
|
|
|
+ expect(runtime.sessions.list.getSnapshot().current).toBe('s1')
|
|
|
+ runtime.sessions.clear()
|
|
|
+ await runtime.flush()
|
|
|
+ expect(runtime.sessions.list.getSnapshot().current).toBeUndefined()
|
|
|
+ expect(runtime.sessions.calls).toEqual([
|
|
|
+ { method: 'open', args: ['s1'] },
|
|
|
+ { method: 'clear', args: [] },
|
|
|
+ ])
|
|
|
+ await runtime.dispose()
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+describe('stores', () => {
|
|
|
+ const createSuiteStore = () => defineStore({
|
|
|
+ init: () => ({ note: '' }),
|
|
|
+ persist: 'trt.store',
|
|
|
+ actions: { setNote: (d, note: string) => { d.note = note } },
|
|
|
+ })
|
|
|
+
|
|
|
+ it('resolves per-session instances via the host face: shared identity, isolation, action-driven re-render', async () => {
|
|
|
+ const runtime = await runtimeWithFrame()
|
|
|
+ const handle = createSuiteStore()
|
|
|
+ runtime.slots.register(
|
|
|
+ { name: 'trt.chat', store: handle },
|
|
|
+ (props: SessionStandardProps & { useStore: <S>(sel: (s: { note: string }) => S) => S }) =>
|
|
|
+ <span>note:{props.useStore(s => s.note)}</span>)
|
|
|
+ const view = runtime.renderRoot()
|
|
|
+ await runtime.sessions.add({ id: 's1' })
|
|
|
+
|
|
|
+ expect(() => runtime.storeOf('trt.panel')).toThrow(/no registration/)
|
|
|
+ const store = runtime.storeOf('trt.chat', 's1')
|
|
|
+ await runtime.flush()
|
|
|
+ ;(store.actions['setNote'] as (note: string) => void)('hello')
|
|
|
+ await runtime.flush()
|
|
|
+ expect(view.container.textContent).toContain('note:hello')
|
|
|
+ expect(runtime.storeOf('trt.chat', 's1')).toBe(store) // cached per scope key
|
|
|
+
|
|
|
+ await runtime.sessions.add({ id: 's2' })
|
|
|
+ const other = runtime.storeOf('trt.chat', 's2')
|
|
|
+ expect(other).not.toBe(store)
|
|
|
+ expect(other.getSnapshot()).toEqual({ note: '' })
|
|
|
+ await runtime.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('storeOf guards: before renderRoot, and for storeless entries', async () => {
|
|
|
+ const runtime = await runtimeWithFrame()
|
|
|
+ runtime.slots.register({ name: 'trt.panel' }, () => null)
|
|
|
+ expect(() => runtime.storeOf('trt.panel')).toThrow(/before renderRoot/)
|
|
|
+ runtime.renderRoot()
|
|
|
+ expect(() => runtime.storeOf('trt.panel')).toThrow(/declares no store/)
|
|
|
+ await runtime.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('remove() prunes the session store scope: persisted state clears, a re-added session starts fresh', async () => {
|
|
|
+ const runtime = await runtimeWithFrame()
|
|
|
+ const handle = createSuiteStore()
|
|
|
+ runtime.slots.register({ name: 'trt.chat', store: handle }, () => null)
|
|
|
+ runtime.renderRoot()
|
|
|
+ await runtime.sessions.add({ id: 's1' })
|
|
|
+
|
|
|
+ const doomed = runtime.storeOf('trt.chat', 's1')
|
|
|
+ ;(doomed.actions['setNote'] as (note: string) => void)('buried')
|
|
|
+ expect(localStorage.getItem('trt.store.s1')).not.toBeNull()
|
|
|
+
|
|
|
+ await runtime.sessions.remove('s1')
|
|
|
+ expect(localStorage.getItem('trt.store.s1')).toBeNull()
|
|
|
+ expect(runtime.sessions.list.getSnapshot().ids).toEqual([])
|
|
|
+ expect(runtime.sessions.provideInfo('s1')).toBeUndefined()
|
|
|
+
|
|
|
+ await runtime.sessions.add({ id: 's1' })
|
|
|
+ const reborn = runtime.storeOf('trt.chat', 's1')
|
|
|
+ expect(reborn).not.toBe(doomed)
|
|
|
+ expect(reborn.getSnapshot()).toEqual({ note: '' })
|
|
|
+ await runtime.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('remove() also disposes a minted scope fiber; removing a non-current session keeps the selection', async () => {
|
|
|
+ const runtime = await runtimeWithFrame()
|
|
|
+ await runtime.sessions.add({ id: 's1' })
|
|
|
+ await runtime.sessions.add({ id: 's2' }, { current: false })
|
|
|
+ const scope = runtime.sessions.scope('s1')!
|
|
|
+ await runtime.sessions.remove('s2')
|
|
|
+ expect(runtime.sessions.list.getSnapshot().current).toBe('s1')
|
|
|
+ await runtime.sessions.remove('s1')
|
|
|
+ expect(scope.fiber.uid).toBeNull() // disposed fiber loses its uid
|
|
|
+ expect(runtime.sessions.list.getSnapshot().current).toBeUndefined()
|
|
|
+ await runtime.dispose()
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+describe('workspaces', () => {
|
|
|
+ it('feeds useWorkspaces and records/stubs intent actions', async () => {
|
|
|
+ const runtime = await runtimeWithFrame()
|
|
|
+ runtime.slots.register(
|
|
|
+ { name: 'trt.panel' },
|
|
|
+ (props: { useWorkspaces: <S>(sel: (s: { phase: string }) => S) => S }) =>
|
|
|
+ <span>ws:{props.useWorkspaces(s => s.phase)}</span>)
|
|
|
+ const view = runtime.renderRoot()
|
|
|
+ expect(view.container.textContent).toContain('ws:ready')
|
|
|
+
|
|
|
+ await runtime.workspaces.update((draft) => { draft.phase = 'pending' })
|
|
|
+ expect(view.container.textContent).toContain('ws:pending')
|
|
|
+
|
|
|
+ runtime.workspaces.startSession('w1' as WorkspaceId)
|
|
|
+ await expect(runtime.workspaces.connectWorkspace('w2' as WorkspaceId)).resolves.toBe('session-of-w2')
|
|
|
+ expect(runtime.workspaces.calls).toEqual([
|
|
|
+ { method: 'startSession', args: ['w1'] },
|
|
|
+ { method: 'connectWorkspace', args: ['w2'] },
|
|
|
+ ])
|
|
|
+ const stub = vi.fn(() => Promise.resolve('other' as never))
|
|
|
+ runtime.workspaces.stub('connectWorkspace', stub)
|
|
|
+ await expect(runtime.workspaces.connectWorkspace('w3' as WorkspaceId)).resolves.toBe('other')
|
|
|
+ expect(stub).toHaveBeenCalledOnce()
|
|
|
+ await runtime.dispose()
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+describe('feature mount and disposal', () => {
|
|
|
+ it('mounts a plugin on a real fiber; dispose() cascades entries, declared children, and services', async () => {
|
|
|
+ const runtime = await runtimeWithFrame()
|
|
|
+ runtime.provide('layout', { openDetails: vi.fn() })
|
|
|
+ const feature = await runtime.mount({
|
|
|
+ inject: ['slots', 'layout'],
|
|
|
+ apply: (ctx: typeof runtime.ctx) => {
|
|
|
+ ctx.provide('feature-service', { ok: true })
|
|
|
+ ctx.slots.register({
|
|
|
+ name: 'trt.rows',
|
|
|
+ id: 'row-1',
|
|
|
+ children: { 'trt.rows.hole': { kind: 'single', scope: 'root' } },
|
|
|
+ } as never, ((props: { renderSlot: (key: string, owner: object) => unknown }) =>
|
|
|
+ <div data-testid="row">{props.renderSlot('trt.rows.hole', {}) as React.ReactNode}</div>) as never)
|
|
|
+ },
|
|
|
+ })
|
|
|
+ const view = runtime.renderRoot()
|
|
|
+ expect(view.getByTestId('row')).toBeTruthy()
|
|
|
+ expect(runtime.ctx.get('feature-service')).toEqual({ ok: true })
|
|
|
+ expect(runtime.slots.entries('trt.rows')).toHaveLength(1)
|
|
|
+
|
|
|
+ await feature.dispose()
|
|
|
+ await feature.dispose() // idempotent
|
|
|
+ expect(runtime.slots.entries('trt.rows')).toHaveLength(0)
|
|
|
+ expect(runtime.slots.spec('trt.rows.hole' as never)).toBeUndefined()
|
|
|
+ expect(runtime.ctx.get('feature-service')).toBeUndefined()
|
|
|
+ expect(view.queryByTestId('row')).toBeNull()
|
|
|
+ await runtime.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('mount fails loud on missing services instead of suspending forever', async () => {
|
|
|
+ const runtime = await runtimeWithFrame()
|
|
|
+ await expect(runtime.mount({ inject: ['slots', 'absent-service'], apply: () => {} }))
|
|
|
+ .rejects.toThrow(/missing service\(s\) absent-service/)
|
|
|
+ await runtime.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('runtime dispose is idempotent, unmounts views, disposes mounted features, and clears persisted state', async () => {
|
|
|
+ const runtime = await runtimeWithFrame()
|
|
|
+ const feature = await runtime.mount({
|
|
|
+ inject: ['slots'],
|
|
|
+ apply: (ctx: typeof runtime.ctx) => { ctx.slots.register({ name: 'trt.panel' }, () => <b>p</b>) },
|
|
|
+ })
|
|
|
+ const view = runtime.renderRoot()
|
|
|
+ expect(view.container.textContent).toContain('p')
|
|
|
+ localStorage.setItem('trt.leftover', 'x')
|
|
|
+
|
|
|
+ await runtime.dispose()
|
|
|
+ expect(view.container.innerHTML).toBe('')
|
|
|
+ expect(feature.fiber.uid).toBeNull()
|
|
|
+ expect(localStorage.getItem('trt.leftover')).toBeNull()
|
|
|
+ await runtime.dispose() // idempotent
|
|
|
+ await expect(runtime.dispose()).resolves.toBeUndefined()
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+describe('single-slot mounting (declare + renderSlot)', () => {
|
|
|
+ it('renders one slot inside its data-slot wrapper and updates owner props in place', async () => {
|
|
|
+ const runtime = await SlotTestRuntime.create()
|
|
|
+ await runtime.declare({ 'trt.panel': { kind: 'single', scope: 'root' } })
|
|
|
+ runtime.slots.register(
|
|
|
+ { name: 'trt.panel' },
|
|
|
+ ({ label }: { label?: string }) => <b data-testid="panel">{label ?? 'none'}</b>)
|
|
|
+ const slot = runtime.renderSlot('trt.panel', { label: 'first' })
|
|
|
+ expect(slot.container.getAttribute('data-slot')).toBe('trt.panel')
|
|
|
+ expect(slot.view.getByTestId('panel').textContent).toBe('first')
|
|
|
+
|
|
|
+ const panel = slot.view.getByTestId('panel')
|
|
|
+ slot.update({ label: 'second' })
|
|
|
+ expect(slot.view.getByTestId('panel').textContent).toBe('second')
|
|
|
+ // In-place re-render: the element identity survived the owner flip.
|
|
|
+ expect(slot.view.getByTestId('panel')).toBe(panel)
|
|
|
+ await runtime.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('views sibling slots of one tree separately and rejects undeclared keys', async () => {
|
|
|
+ const runtime = await SlotTestRuntime.create()
|
|
|
+ await runtime.declare({
|
|
|
+ 'trt.panel': { kind: 'single', scope: 'root' },
|
|
|
+ 'trt.rows': { kind: 'list', scope: 'root' },
|
|
|
+ })
|
|
|
+ runtime.slots.register({ name: 'trt.panel' }, () => <b>panel</b>)
|
|
|
+ runtime.slots.register({ name: 'trt.rows', id: 'r1' }, () => <i>row</i>)
|
|
|
+ const panel = runtime.renderSlot('trt.panel', {})
|
|
|
+ const rows = runtime.renderSlot('trt.rows', {})
|
|
|
+ expect(panel.container.textContent).toBe('panel')
|
|
|
+ expect(rows.container.textContent).toBe('row')
|
|
|
+ expect(() => runtime.renderSlot('trt.chat', {})).toThrow(/without declare\(\)/)
|
|
|
+ await runtime.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('folds class hashes and collapses svg internals in snapshots, leaving the live DOM alone', async () => {
|
|
|
+ const runtime = await SlotTestRuntime.create()
|
|
|
+ await runtime.declare({ 'trt.panel': { kind: 'single', scope: 'root' } })
|
|
|
+ runtime.slots.register({ name: 'trt.panel' }, () => (
|
|
|
+ <div className="_frame_a1b2c3 plain">
|
|
|
+ <span className="_label_ff00aa">styled</span>
|
|
|
+ <svg viewBox="0 0 16 16" aria-hidden="true">
|
|
|
+ <path d="M0 0L16 16" fill="currentColor" />
|
|
|
+ </svg>
|
|
|
+ </div>
|
|
|
+ ))
|
|
|
+ const slot = runtime.renderSlot('trt.panel', {})
|
|
|
+ expect(slot.container).toMatchSnapshot()
|
|
|
+ // The serializer works on a clone: the live DOM keeps hashes and paths.
|
|
|
+ expect(slot.container.querySelector('div')!.className).toBe('_frame_a1b2c3 plain')
|
|
|
+ expect(slot.container.querySelector('svg path')).not.toBeNull()
|
|
|
+ await runtime.dispose()
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+describe('fixture session face', () => {
|
|
|
+ it('fail-loud stubs name the missing verb; supplied overrides run instead', async () => {
|
|
|
+ const runtime = await SlotTestRuntime.create()
|
|
|
+ await runtime.sessions.add({ id: 's1' })
|
|
|
+ const bare = runtime.sessions.behavior('s1')
|
|
|
+ expect(() => bare.prompt()).toThrow(/prompt is not stubbed/)
|
|
|
+ expect(() => bare.cancel()).toThrow(/cancel is not stubbed/)
|
|
|
+ expect(() => bare.loadOlder()).toThrow(/loadOlder is not stubbed/)
|
|
|
+ await runtime.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('projections faces are identity-stable per key, read absent, and notify on set', async () => {
|
|
|
+ const runtime = await SlotTestRuntime.create()
|
|
|
+ await runtime.sessions.add({ id: 's1' })
|
|
|
+ const session = runtime.sessions.behavior('s1')
|
|
|
+ const face = session.projections.faceOf('todos')
|
|
|
+ expect(session.projections.faceOf('todos')).toBe(face)
|
|
|
+ expect(face.getSnapshot()).toBeUndefined()
|
|
|
+ const seen: unknown[] = []
|
|
|
+ const off = face.subscribe(() => { seen.push(face.getSnapshot()) })
|
|
|
+ session.projections.set('todos', [1, 2])
|
|
|
+ expect(seen).toEqual([[1, 2]])
|
|
|
+ off()
|
|
|
+ session.projections.set('todos', [3])
|
|
|
+ expect(seen).toEqual([[1, 2]]) // unsubscribed
|
|
|
+ // A never-subscribed key sets without listeners (the empty-notify arm).
|
|
|
+ session.projections.set('untouched', 1)
|
|
|
+ // The provide bundle hands the same store to the render side.
|
|
|
+ const info = runtime.sessions.provideInfo('s1')!
|
|
|
+ expect(info.projections?.faceOf('todos').getSnapshot()).toEqual([3])
|
|
|
+ // A roster change rebuilds the ALREADY-materialized bundle eagerly
|
|
|
+ // (production channel semantics: mounted entries must see the provider)
|
|
|
+ // and skips never-materialized records (they pick the roster up lazily).
|
|
|
+ await runtime.sessions.add({ id: 's-lazy' }, { current: false })
|
|
|
+ const offProbe = runtime.sessions.provide({
|
|
|
+ hooks: ['probe2'],
|
|
|
+ resolve: () => ({ hooks: { probe2: { getSnapshot: () => 1, subscribe: () => () => {} } } }),
|
|
|
+ })
|
|
|
+ const rebuilt = runtime.sessions.provideInfo('s1')!
|
|
|
+ expect(rebuilt).not.toBe(info)
|
|
|
+ expect(rebuilt.hooks['probe2']).toBeDefined()
|
|
|
+ offProbe()
|
|
|
+ await runtime.dispose()
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+describe('workspaces action face', () => {
|
|
|
+ it('records every IWorkspaces verb with inert defaults and honors stubs', async () => {
|
|
|
+ const runtime = await SlotTestRuntime.create()
|
|
|
+ const ws = runtime.workspaces
|
|
|
+ const created = await ws.create({ name: 'alpha' })
|
|
|
+ expect(created.title).toBe('alpha')
|
|
|
+ const registered = await ws.create({ path: '/tmp/beta' })
|
|
|
+ expect(registered.path).toBe('/tmp/beta')
|
|
|
+ await expect(ws.pickDirectory()).resolves.toBeNull()
|
|
|
+ const renamed = await ws.rename('w1' as WorkspaceId, 'Renamed')
|
|
|
+ expect(renamed.title).toBe('Renamed')
|
|
|
+ await ws.delete('w1' as WorkspaceId)
|
|
|
+ await ws.openPath('/proj/file.ts')
|
|
|
+ const moved = await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId, 's2' as SessionId)
|
|
|
+ expect(moved.sessionIds).toEqual(['s1'])
|
|
|
+ expect(ws.calls.map(c => c.method)).toEqual(
|
|
|
+ ['create', 'create', 'pickDirectory', 'rename', 'delete', 'openPath', 'insertSessionBefore'])
|
|
|
+
|
|
|
+ ws.stub('create', () => Promise.resolve({ workspaceId: 'ws-x', title: 'X', path: '/x', sessionIds: [] } as never))
|
|
|
+ ws.stub('pickDirectory', () => Promise.resolve('/picked'))
|
|
|
+ ws.stub('rename', () => Promise.resolve({ workspaceId: 'w1', title: 'S', path: '/s', sessionIds: [] } as never))
|
|
|
+ ws.stub('delete', () => Promise.resolve())
|
|
|
+ ws.stub('openPath', () => Promise.resolve())
|
|
|
+ ws.stub('insertSessionBefore', () => Promise.resolve({ workspaceId: 'w1', title: '', path: '', sessionIds: [] } as never))
|
|
|
+ expect((await ws.create({ name: 'y' })).title).toBe('X')
|
|
|
+ await expect(ws.pickDirectory()).resolves.toBe('/picked')
|
|
|
+ expect((await ws.rename('w1' as WorkspaceId, 'z')).title).toBe('S')
|
|
|
+ await ws.delete('w1' as WorkspaceId)
|
|
|
+ await ws.openPath('/other')
|
|
|
+ expect((await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId)).sessionIds).toEqual([])
|
|
|
+ await runtime.dispose()
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+describe('single-slot mounting edge arms', () => {
|
|
|
+ it('renderSlot fails loud after dispose and after an external unmount', async () => {
|
|
|
+ const runtime = await SlotTestRuntime.create()
|
|
|
+ await runtime.declare({ 'trt.panel': { kind: 'single', scope: 'root' } })
|
|
|
+ runtime.slots.register({ name: 'trt.panel' }, () => <b>p</b>)
|
|
|
+ runtime.renderSlot('trt.panel', {})
|
|
|
+ // RTL cleanup empties the mounted tree behind the runtime's back: the
|
|
|
+ // wrapper lookup names the state instead of returning a dead container.
|
|
|
+ cleanup()
|
|
|
+ expect(() => runtime.renderSlot('trt.panel', {})).toThrow(/rendered no wrapper/)
|
|
|
+ await runtime.dispose()
|
|
|
+ // After dispose the root registration is gone: the production boot-order
|
|
|
+ // check fires before any wrapper lookup.
|
|
|
+ expect(() => runtime.renderSlot('trt.panel', {})).toThrow(/'root' has no registration/)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('serializes childless svg untouched next to scoped classes', async () => {
|
|
|
+ const runtime = await SlotTestRuntime.create()
|
|
|
+ await runtime.declare({ 'trt.panel': { kind: 'single', scope: 'root' } })
|
|
|
+ runtime.slots.register({ name: 'trt.panel' }, () => (
|
|
|
+ <div className="_frame_a1b2c3">
|
|
|
+ <svg viewBox="0 0 1 1" aria-hidden="true" />
|
|
|
+ </div>
|
|
|
+ ))
|
|
|
+ const slot = runtime.renderSlot('trt.panel', {})
|
|
|
+ expect(slot.container).toMatchSnapshot()
|
|
|
+ await runtime.dispose()
|
|
|
+ })
|
|
|
+})
|