diff --git a/packages/test-support/client-runtime/package.json b/packages/test-support/client-runtime/package.json index 8d9984b6bb..88298cd72b 100644 --- a/packages/test-support/client-runtime/package.json +++ b/packages/test-support/client-runtime/package.json @@ -54,6 +54,7 @@ "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", diff --git a/packages/test-support/client-runtime/src/index.ts b/packages/test-support/client-runtime/src/index.ts index 7d0d37c9cb..caad7ac5d1 100644 --- a/packages/test-support/client-runtime/src/index.ts +++ b/packages/test-support/client-runtime/src/index.ts @@ -29,8 +29,10 @@ import { apply as applyUiSession, inject as uiSessionInject, } from '@deepseek-ai/dsh-client-ui-session/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { PanelInfo } from '@deepseek-ai/dsh-client-ui-layout/client' import type { - ChildrenDecl, ComposedProps, HostObservable, OwnerOf, SlotComponent, SlotMap, SlotRenderer, + ChildrenDecl, ComposedProps, HostObservable, OwnerOf, RenderOpts, SlotComponent, SlotMap, SlotRenderer, SlotRendererHost, SnapshotSelectorHook, StoreInstanceLike, } from '@deepseek-ai/dsh-client-ui-slots' import { registerDomSnapshotSerializer } from './snapshot.ts' @@ -125,7 +127,7 @@ export interface TestFileUpload { * {@link SlotView.update} drive React through the standard uSES boundary. */ class OwnerPropsCell { - private readonly owners = new Map() + private readonly owners = new Map() private readonly listeners = new Set<() => void>() private version = 0 @@ -147,15 +149,16 @@ class OwnerPropsCell { * caller wraps in act). * @param key - slot key. * @param owner - owner props share. + * @param opts - explicit keyed or list selection for the render site. */ - set(key: string, owner: object): void { - this.owners.set(key, owner) + set(key: string, owner: object, opts?: RenderOpts): void { + this.owners.set(key, { owner, opts }) this.version += 1 for (const fn of [...this.listeners]) fn() } /** Keys with supplied owner props, in first-supply order. */ - entries(): readonly (readonly [string, object])[] { + entries(): readonly (readonly [string, { owner: object; opts: RenderOpts | undefined }])[] { return [...this.owners.entries()] } } @@ -217,6 +220,8 @@ export class SlotTestRuntime { readonly sessions: TestSessions /** Workspaces double (list observable, recorded intent actions). */ readonly workspaces: TestWorkspaces + /** Test-owned panel selection used by the framework's usePanelInfo hook. */ + readonly panelInfo = createSnapshotStore({ activePanelId: null }) /** Mutable file-upload stub; replace `upload` in suites that exercise the capability. */ readonly fileUpload: TestFileUpload @@ -233,6 +238,7 @@ export class SlotTestRuntime { private readonly autoDeclared = new Set() private autoRootView: RenderResult | undefined private readonly disposeWorkspaceSource: () => void + private readonly disposePanelInfoSource: () => void private constructor(ctx: Context, slots: SlotRegistry) { this.ctx = ctx @@ -248,6 +254,7 @@ export class SlotTestRuntime { ctx.provide('workspaces', this.workspaces) ctx.provide('fileUpload', this.fileUpload as never) this.disposeWorkspaceSource = slots.provideRoot({ hooks: { workspaces: this.workspaces.list } }) + this.disposePanelInfoSource = slots.provideRoot({ hooks: { panelInfo: this.panelInfo } }) // Capturing install: the production renderer does the rendering; the // wrapper only takes the host face for storeOf (no machinery copied). const renderer = createSlotRenderer() @@ -309,6 +316,11 @@ export class SlotTestRuntime { this.disposeWorkspaceSource() } + /** Release the default panel hook before mounting the production Layout owner. */ + releasePanelInfoSource(): void { + this.disposePanelInfoSource() + } + /** * Render the root slot tree through the ctx-level entry (the shell's own * entry point): `ctx.slots.renderSlot('root', {})` under Testing Library. @@ -333,13 +345,13 @@ export class SlotTestRuntime { async declare(children: ChildrenDecl): Promise { for (const key of Object.keys(children)) this.autoDeclared.add(key) const cell = this.ownerCell - const AutoFrame = (props: { renderSlot: (key: string, owner: object) => ReactNode }) => { + const AutoFrame = (props: { renderSlot: (key: string, owner: object, opts?: RenderOpts) => ReactNode }) => { useSyncExternalStore(cell.subscribe, cell.getVersion) // Keyed Fragments only: the renderer's outlet anchor is the one // `[data-slot]` element — the frame adding its own would nest // duplicate anchors under the same key. - return createElement(Fragment, null, cell.entries().map(([key, owner]) => - createElement(Fragment, { key }, props.renderSlot(key, owner)))) + return createElement(Fragment, null, cell.entries().map(([key, { owner, opts }]) => + createElement(Fragment, { key }, props.renderSlot(key, owner, opts)))) } await this.root.declare(children as never, AutoFrame as never) } @@ -352,16 +364,17 @@ export class SlotTestRuntime { * slot of the same tree. * @param key - a key declared through {@link SlotTestRuntime.declare}. * @param owner - owner props share for the render site. + * @param opts - explicit keyed or list selection; retained by view updates. * @returns the slot-local view (snapshot container, scoped queries, owner updates). */ - renderSlot(key: K, owner: OwnerOf): SlotView { + renderSlot(key: K, owner: OwnerOf, opts?: RenderOpts): SlotView { if (!this.autoDeclared.has(key)) { throw new Error(`renderSlot('${key}') without declare() — declare the key first (or use root.declare for a custom frame)`) } const install = (next: object): void => { // Synchronous cell write inside act: the frame re-renders through uSES. act(() => { - this.ownerCell.set(key, next) + this.ownerCell.set(key, next, opts) }) } install(owner) @@ -421,6 +434,7 @@ export class SlotTestRuntime { for (const view of this.views.splice(0)) view.unmount() for (const handle of this.handles.splice(0)) await handle.dispose() this.root.release() + this.disposePanelInfoSource() await this.sessions.disposeScopes() localStorage.clear() } diff --git a/packages/test-support/client-runtime/tests/helpers.client.spec.tsx b/packages/test-support/client-runtime/tests/helpers.client.spec.tsx index 484fa8cc01..e5efdc20f4 100644 --- a/packages/test-support/client-runtime/tests/helpers.client.spec.tsx +++ b/packages/test-support/client-runtime/tests/helpers.client.spec.tsx @@ -5,6 +5,8 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-chat/client' import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { MainPanelId, PanelInfo } from '@deepseek-ai/dsh-client-ui-layout/client' +import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' import { bindSnapshotSelector, @@ -17,6 +19,12 @@ import { const originalLanguages = [...navigator.languages] const originalLanguage = navigator.language +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SlotMap { + 'trt.panel-info': { kind: 'keyed'; scope: 'root'; owner: { label: string } } + } +} + usePinnedBrowserLanguages('zh-CN', 'en-US') afterEach(cleanup) afterAll(() => { @@ -38,6 +46,34 @@ function entry(seq: number): SessionLiveEventEntry { } describe('fixture helpers', () => { + it('drives panel hooks, retains keyed selection on owner updates, and releases the default source', async () => { + const runtime = await SlotTestRuntime.create() + try { + await runtime.declare({ 'trt.panel-info': { kind: 'keyed', scope: 'root' } }) + runtime.slots.register({ name: 'trt.panel-info', key: 'probe' }, + ({ usePanelInfo, label }: PropsRuntime<'trt.panel-info'>) => ( + {label}:{usePanelInfo(info => info.activePanelId) ?? 'conversation'} + )) + const view = runtime.renderSlot('trt.panel-info', { label: 'first' }, { entryKey: 'probe' }) + expect(view.container.textContent).toBe('first:conversation') + act(() => { runtime.panelInfo.set({ activePanelId: 'custom' as MainPanelId }) }) + expect(view.container.textContent).toBe('first:custom') + view.update({ label: 'next' }) + expect(view.container.textContent).toBe('next:custom') + const replacement = createSnapshotStore({ activePanelId: null }) + await act(async () => { + runtime.releasePanelInfoSource() + await runtime.mount({ + inject: ['slots'], + apply(ctx) { ctx.slots.provideRoot({ hooks: { panelInfo: replacement } }) }, + }) + }) + expect(view.container.textContent).toBe('next:conversation') + } finally { + await runtime.dispose() + } + }) + it('rejects an upload until a suite replaces the default stub', async () => { const runtime = await SlotTestRuntime.create() expect(runtime.fileUpload.available).toBe(false) diff --git a/packages/test-support/client-runtime/tsconfig.json b/packages/test-support/client-runtime/tsconfig.json index f9abd79351..bc8fbf0688 100644 --- a/packages/test-support/client-runtime/tsconfig.json +++ b/packages/test-support/client-runtime/tsconfig.json @@ -38,6 +38,9 @@ { "path": "../../client/ui-conversation" }, + { + "path": "../../client/ui-layout" + }, { "path": "../../client/ui-slots" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8858ea63f1..06b92cd460 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9900,6 +9900,9 @@ importers: '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../../client/ui-conversation + '@deepseek-ai/dsh-client-ui-layout': + specifier: workspace:^ + version: link:../../client/ui-layout '@deepseek-ai/dsh-client-ui-renderer': specifier: workspace:^ version: link:../../client/ui-renderer