diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index a4932dd534..3e9641549a 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -49,22 +49,23 @@ "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@types/react": "~18.3.1", - "react": "^18.2.0", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-remote-mock": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", - "clsx": "^2.0.0" + "@types/react": "~18.3.1", + "clsx": "^2.0.0", + "react": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-settings-general/tests/apply.client.spec.ts b/packages/client/ui-settings-general/tests/apply.client.spec.ts index e2e3099489..10380d26d9 100644 --- a/packages/client/ui-settings-general/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.client.spec.ts @@ -1,20 +1,30 @@ -/** Ownerless-copy registrations: the five seats, dictionaries, thunked labels, and HMR recovery. */ -import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it, vi } from 'vitest' +// @vitest-environment jsdom +/** + * Ownerless-copy registrations inside the assembled web client: the five + * seats, the `settings` dictionaries, the locale-following nav label, the + * loopback-only document action over the real settings mirror, and recovery + * across Loader rebuilds of the declaring chain. + */ +import { describe, expect, onTestFinished, vi } from 'vitest' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import { ok, type RemoteMock } from '@deepseek-ai/dsh-remote-mock' +import type { SettingsNamespaceView } from '@deepseek-ai/dsh-settings/types' +import { createClientTest, type TestClient, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' -import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' -import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' -import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' -import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client' +import { LOCALE_SETTINGS_NAMESPACE, LocaleSettingsSchema } from '@deepseek-ai/dsh-client-locale/src/locale-settings.ts' +import { inject } from '../src/client/index.ts' import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx' import { GeneralSection } from '../src/client/GeneralSection.tsx' import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx' import type { SettingsDocumentActionInjected } from '../src/client/SettingsDocumentAction.tsx' -// These specs assert the shipped Chinese copy. The lane has no jsdom `window`, -// so browser-language detection never runs and a fresh LocaleRuntime opens on -// FALLBACK_LOCALE (en); bench stages zh explicitly on the locale instead. +const SELF = '@deepseek-ai/dsh-client-ui-settings-general' +const SIDEBAR = '@deepseek-ai/dsh-client-ui-sidebar' +const it = createClientTest({ roster: webApp }) +/** The whole roster's first boot pays the cold module transform of every plugin package. */ +const COLD_BOOT_TIMEOUT_MS = 60_000 +/** Dictionary namespace this plugin owns; every seat it fills declares it. */ +const NS = 'settings' /** The seats this plugin fills for a loopback browser (slot name → expected component). */ const SEATS = [ @@ -25,56 +35,55 @@ const SEATS = [ ['settings.section', GeneralSection], ] as const -async function bench(isLoopback = true) { - const ctx = new Context() - await ctx.plugin(SlotRegistry).await() - const locale = new LocaleRuntime(ctx) - locale.setLocale('zh') - ctx.provide('locale', locale) - const settingsDescribe = vi.fn(() => Promise.resolve({ - ok: true as const, - value: { - writable: true, - hasDocument: true, - namespaces: [], - }, - })) - const settingsOpenDocument = vi.fn(() => Promise.resolve({ - ok: true as const, value: { opened: true as const }, - })) - const remote = new TestRemote(ctx, { - settings: { describe: settingsDescribe, openSettingsDocument: settingsOpenDocument }, - }) - // The fixed Host facts the shell reads its loopback-only action from. - remote.$host = { home: undefined, isLoopback } - ctx.provide('connection', { - state: { getSnapshot: () => 'connected', subscribe: () => () => {} }, - reconnect: () => {}, - } as never) - await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() - return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, settingsDescribe, settingsOpenDocument } +/** One Host view of the locale preference, including its revision fence. */ +function localeView(preference: string, revision = 0): SettingsNamespaceView { + return { + ns: LOCALE_SETTINGS_NAMESPACE, + // The Remote wire serializes nested Schema values before the client rehydrates them. + schema: JSON.parse(JSON.stringify(LocaleSettingsSchema.toJSON())) as SettingsNamespaceView['schema'], + value: { preference }, + applies: 'live', + secrets: [], + revision, + } } -/** Declare the shell's six child slots the way ui-settings' entry does. */ -function declare(slots: SlotRegistry): () => void { - return slots.register( - { - name: 'root', - children: { - 'settings.trigger': { kind: 'single', scope: 'root' }, - 'settings.header': { kind: 'single', scope: 'root' }, - 'settings.action': { kind: 'list', scope: 'root' }, - 'settings.close': { kind: 'single', scope: 'root' }, - 'settings.section': { kind: 'list', scope: 'root' }, - 'settings.onboarding': { kind: 'list', scope: 'root' }, - }, - } as never, - () => null, - ) +async function client(mock: RemoteMock, start: () => Promise, hasDocument = false) { + const settings = mock.remote.settings + settings.describe.mockResolvedValue(ok({ writable: true, hasDocument, namespaces: [localeView('zh')] })) + const c = await start() + // The locale adopts the Host preference once the describe mirror holds the document. + await c.ctx.settingsScope.describe().ensure() + return { c, settings } } -function generalEntry(slots: SlotRegistry) { - return slots.entries('settings.section').find(e => e.component === GeneralSection) +/** This plugin's rows in a seat: the list seats also carry feature-owned rows (the product's other sections and actions). */ +function ownEntries(c: TestClient, name: (typeof SEATS)[number][0]) { + return c.ctx.slots.entries(name).filter(entry => entry.locale === NS) +} + +function generalEntry(c: TestClient) { + return ownEntries(c, 'settings.section').find(entry => entry.component === GeneralSection)! +} + +function generalLabel(c: TestClient): string | undefined { + return resolveSlotLabel(generalEntry(c).options.label) +} + +function actionInjectedOf(c: TestClient): SettingsDocumentActionInjected { + const entry = ownEntries(c, 'settings.action')[0]! + return (entry.inject as unknown as () => SettingsDocumentActionInjected)() +} + +function expectSeated(c: TestClient): void { + for (const [name, component] of SEATS) { + expect(ownEntries(c, name).map(entry => entry.component)).toEqual([component]) + } +} + +/** The page authority the `connection` plugin classifies at apply, reconfigured through the jsdom instance vitest exposes. */ +function setPageUrl(url: string): void { + (globalThis as unknown as { jsdom: { reconfigure(settings: { url: string }): void } }).jsdom.reconfigure({ url }) } describe('ui-settings-general apply', () => { @@ -82,138 +91,144 @@ describe('ui-settings-general apply', () => { expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.settings', 'settingsScope']) }) - it('fills all five seats for declarations before or after apply', async () => { - const before = await bench() - declare(before.slots) - await before.ctx.plugin({ inject: [...inject], apply }).await() - for (const [name, component] of SEATS) { - expect(before.slots.entries(name)[0]!.component).toBe(component) - } - const entry = generalEntry(before.slots)! + it('fills the five seats of the shell it declares, with the locale-following General label', async ({ mock, start }) => { + const { c } = await client(mock, start) + expect(c.ctx.locale.getSnapshot().active).toBe('zh') + expectSeated(c) + const entry = generalEntry(c) expect(entry.options).toMatchObject({ id: 'general', order: 0 }) // The nav label is a locale-following thunk; owners resolve at read time. - expect(resolveSlotLabel(entry.options.label)).toBe('通用设置') - expect(before.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) - expect(before.slots.entries('settings.general.item')).toEqual([]) - // The onboarding hole stays declared for feature-owned steps; this plugin - // no longer seats one. - expect(before.slots.entries('settings.onboarding')).toEqual([]) - const action = before.slots.entries('settings.action')[0]! - const actionInjected = (action.inject as unknown as () => SettingsDocumentActionInjected)() - expect(actionInjected.controller.store.getSnapshot().status).toBe('idle') - expect(actionInjected.hooks.snapshot).toBe(actionInjected.controller.store) - // Copy rides the standard locale seat: every seat declares the namespace. - for (const [name] of SEATS) { - expect(before.slots.entries(name)[0]!.locale).toBe('settings') - } - const after = await bench() - await after.ctx.plugin({ inject: [...inject], apply }).await() - for (const [name] of SEATS) expect(after.slots.entries(name)).toHaveLength(0) - declare(after.slots) - await Promise.resolve() + expect(generalLabel(c)).toBe('通用设置') + expect(c.ctx.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) + // The General items and the onboarding steps are feature-owned rows; this plugin seats none of its own. + expect(c.ctx.slots.entries('settings.general.item').filter(row => row.locale === NS)).toEqual([]) + expect(c.ctx.slots.entries('settings.onboarding').filter(row => row.locale === NS)).toEqual([]) + const { controller, hooks } = actionInjectedOf(c) + expect(controller.store.getSnapshot().status).toBe('idle') + expect(hooks.snapshot).toBe(controller.store) + // Copy rides the standard locale seat: every row this plugin seats declares the namespace. for (const [name, component] of SEATS) { - expect(after.slots.entries(name)[0]!.component).toBe(component) - // The self-inflicted ledger notifications hit the duplicate guard. - expect(after.slots.entries(name)).toHaveLength(1) + expect(c.ctx.slots.entries(name).find(row => row.component === component)!.locale).toBe(NS) } + }, COLD_BOOT_TIMEOUT_MS) + + it('registers the zh/en settings dictionaries and frees the seats when its row unloads', async ({ mock, start }) => { + const { c, settings } = await client(mock, start) + const english = localeView('en', 1) + settings.mutate.mockResolvedValueOnce(ok(english)) + const t = c.ctx.locale.bind(NS) + expect(t('title')).toBe('设置') + expect(t('connection.error')).toBe('连接异常') + expect(t('connection.connecting')).toBe('自动重连中') + expect(t('connection.connected')).toBe('连接成功') + c.ctx.locale.setLocale('en') + expect(t('close')).toBe('Close') + expect(t('connection.reconnect')).toBe('Disconnected, reconnect now') + expect(t('connection.connecting')).toBe('Reconnecting') await vi.waitFor(() => { - expect(after.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) + expect(settings.mutate.mock.calls).toEqual([ + [LOCALE_SETTINGS_NAMESPACE, [{ op: 'set', path: ['preference'], value: 'en' }], 0], + ]) + expect(c.ctx.settingsScope.describe().getSnapshot().view?.namespaces).toEqual([english]) }) - }) - - it('registers the zh/en settings dictionaries and frees the seats on teardown', async () => { - const b = await bench() - declare(b.slots) - const fiber = b.ctx.plugin({ inject: [...inject], apply }) - await fiber.await() - expect(b.locale.bind('settings')('title')).toBe('设置') - expect(b.locale.bind('settings')('connection.error')).toBe('连接异常') - expect(b.locale.bind('settings')('connection.connecting')).toBe('自动重连中') - expect(b.locale.bind('settings')('connection.connected')).toBe('连接成功') - b.locale.setLocale('en') - expect(b.locale.bind('settings')('close')).toBe('Close') - expect(b.locale.bind('settings')('connection.reconnect')).toBe('Disconnected, reconnect now') - expect(b.locale.bind('settings')('connection.connecting')).toBe('Reconnecting') - b.locale.setLocale('zh') - await fiber.dispose() + await c.unload(SELF) + await c.flush() // The (ns, locale) seats are free again — the dictionary disposer ran. - expect(() => b.locale.register('settings', 'zh', {})).not.toThrow() - expect(() => b.locale.register('settings', 'en', {})).not.toThrow() + expect(() => { c.ctx.locale.register(NS, 'zh', {})() }).not.toThrow() + expect(() => { c.ctx.locale.register(NS, 'en', {})() }).not.toThrow() }) - it('the nav label thunk follows the active locale without re-registration', async () => { - const b = await bench() - declare(b.slots) - await b.ctx.plugin({ inject: [...inject], apply }).await() - const zhVersions = SEATS.map(([name]) => b.slots.getVersion(name)) - b.locale.setLocale('en') + it('the nav label thunk follows the active locale without re-registration', async ({ mock, start }) => { + const { c, settings } = await client(mock, start) + const english = localeView('en', 1) + const chinese = localeView('zh', 2) + settings.mutate.mockResolvedValueOnce(ok(english)).mockResolvedValueOnce(ok(chinese)) + const zhVersions = SEATS.map(([name]) => c.ctx.slots.getVersion(name)) + c.ctx.locale.setLocale('en') // No ledger churn: freshness rides the thunk (and the renderer's locale // subscription), not re-registration. SEATS.forEach(([name], i) => { - expect(b.slots.getVersion(name)).toBe(zhVersions[i]!) - expect(b.slots.entries(name)).toHaveLength(1) + expect(c.ctx.slots.getVersion(name)).toBe(zhVersions[i]!) + expect(ownEntries(c, name)).toHaveLength(1) + }) + expect(generalLabel(c)).toBe('General') + await vi.waitFor(() => { + expect(c.ctx.settingsScope.describe().getSnapshot().view?.namespaces).toEqual([english]) + }) + c.ctx.locale.setLocale('zh') + expect(generalLabel(c)).toBe('通用设置') + await vi.waitFor(() => { + expect(settings.mutate.mock.calls).toEqual([ + [LOCALE_SETTINGS_NAMESPACE, [{ op: 'set', path: ['preference'], value: 'en' }], 0], + [LOCALE_SETTINGS_NAMESPACE, [{ op: 'set', path: ['preference'], value: 'zh' }], 1], + ]) + expect(c.ctx.settingsScope.describe().getSnapshot().view?.namespaces).toEqual([chinese]) }) - expect(resolveSlotLabel(generalEntry(b.slots)!.options.label)).toBe('General') - b.locale.setLocale('zh') - expect(resolveSlotLabel(generalEntry(b.slots)!.options.label)).toBe('通用设置') }) - it('reads availability from the shared mirror and follows its reconnect refresh', async () => { - const b = await bench() - declare(b.slots) - await b.ctx.plugin({ inject: [...inject], apply }).await() - const entry = b.slots.entries('settings.action')[0]! - const { controller } = (entry.inject as unknown as () => SettingsDocumentActionInjected)() - // The mirror read once at its own boot; the action's load adds no read. - await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledOnce() }) + it('reads availability from the shared mirror and follows its reconnect refresh', async ({ mock, start }) => { + const { c } = await client(mock, start, true) + const { controller } = actionInjectedOf(c) + // Boot reads the document twice: the mirror's own `ensure` at apply, then + // the `connection/reset` of the first connection. The action's load adds none. + expect(c.mock.log.calls('settings/describe')).toHaveLength(2) await controller.load() - expect(b.settingsDescribe).toHaveBeenCalledOnce() + expect(c.mock.log.calls('settings/describe')).toHaveLength(2) expect(controller.store.getSnapshot().status).toBe('ready') - b.ctx.emit('connection/reset') - await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(2) }) + c.connection.reconnect() + await c.mock.streams.opened('$events', 2) + await vi.waitFor(() => { expect(c.mock.log.calls('settings/describe')).toHaveLength(3) }) }) - it('withholds the Host document action off-loopback', async () => { - const b = await bench(false) - declare(b.slots) - const fiber = b.ctx.plugin({ inject: [...inject], apply }) - await fiber.await() - expect(b.slots.entries('settings.action')).toEqual([]) - expect(b.settingsDescribe).not.toHaveBeenCalled() - await fiber.dispose() - for (const [name] of SEATS) expect(b.slots.entries(name)).toEqual([]) + it('withholds the Host document action off-loopback', async ({ mock, start }) => { + const loopbackUrl = location.href + setPageUrl('http://198.51.100.7:3000/') + onTestFinished(() => { setPageUrl(loopbackUrl) }) + const { c } = await client(mock, start) + expect(c.connection.isLoopback).toBe(false) + expect(ownEntries(c, 'settings.action')).toEqual([]) + // Off-loopback settings stay process-local: no describe read, so the browser language stands. + expect(c.mock.log.calls('settings/describe')).toEqual([]) + expect(c.ctx.locale.getSnapshot().active).toBe('en') + await c.unload(SELF) + await c.flush() + for (const [name] of SEATS) expect(ownEntries(c, name)).toEqual([]) }) - it('re-registers after an HMR collapse of the declaring chain (stale disposers must not block)', async () => { - const b = await bench() - const redeclare = declare(b.slots) - await b.ctx.plugin({ inject: [...inject], apply }).await() - // Declarer unload: the cascade removes every seat entry and the item - // declaration while our local disposers go stale. - redeclare() - for (const [name] of SEATS) expect(b.slots.entries(name)).toHaveLength(0) - expect(b.slots.spec('settings.general.item')).toBeUndefined() - declare(b.slots) - await Promise.resolve() - for (const [name, component] of SEATS) { - expect(b.slots.entries(name)[0]!.component).toBe(component) - } - expect(b.slots.entries('settings.general.item')).toEqual([]) - expect(b.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) + it('re-registers after a Loader rebuild of the declaring chain (stale disposers must not block)', async ({ mock, start }) => { + const { c, settings } = await client(mock, start) + const before = SEATS.map(([name]) => ownEntries(c, name)[0]) + await c.reload(SIDEBAR) + await c.flush() + expectSeated(c) + SEATS.forEach(([name], index) => { + expect(ownEntries(c, name)[0]).not.toBe(before[index]) + }) + expect(c.ctx.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) + expect(c.ctx.slots.entries('settings.general.item').filter(row => row.locale === NS)).toEqual([]) // The recovered registrations still ride the locale path. - b.locale.setLocale('en') - expect(resolveSlotLabel(generalEntry(b.slots)!.options.label)).toBe('General') - b.locale.setLocale('zh') + const english = localeView('en', 1) + const chinese = localeView('zh', 2) + settings.mutate.mockResolvedValueOnce(ok(english)).mockResolvedValueOnce(ok(chinese)) + c.ctx.locale.setLocale('en') + expect(generalLabel(c)).toBe('General') + c.ctx.locale.setLocale('zh') + expect(generalLabel(c)).toBe('通用设置') + await vi.waitFor(() => { + expect(settings.mutate.mock.calls).toEqual([ + [LOCALE_SETTINGS_NAMESPACE, [{ op: 'set', path: ['preference'], value: 'en' }], 0], + [LOCALE_SETTINGS_NAMESPACE, [{ op: 'set', path: ['preference'], value: 'zh' }], 1], + ]) + expect(c.ctx.settingsScope.describe().getSnapshot().view?.namespaces).toEqual([chinese]) + }) }) - it('removes every seat and the item declaration on teardown', async () => { - const b = await bench() - declare(b.slots) - const fiber = b.ctx.plugin({ inject: [...inject], apply }) - await fiber.await() - expect(b.slots.spec('settings.general.item')).toBeDefined() - await fiber.dispose() - for (const [name] of SEATS) expect(b.slots.entries(name)).toHaveLength(0) - expect(b.slots.spec('settings.general.item')).toBeUndefined() + it('removes every seat and the item declaration when its row unloads', async ({ mock, start }) => { + const { c } = await client(mock, start) + expect(c.ctx.slots.spec('settings.general.item')).toBeDefined() + await c.unload(SELF) + await c.flush() + for (const [name] of SEATS) expect(ownEntries(c, name)).toHaveLength(0) + expect(c.ctx.slots.spec('settings.general.item')).toBeUndefined() }) }) diff --git a/packages/client/ui-settings-general/tests/shell.client.spec.ts b/packages/client/ui-settings-general/tests/shell.client.spec.ts index 8e33a75a10..24ca33b895 100644 --- a/packages/client/ui-settings-general/tests/shell.client.spec.ts +++ b/packages/client/ui-settings-general/tests/shell.client.spec.ts @@ -1,54 +1,25 @@ -/** Settings shell registration: slot declaration injection, the ledger projections, and HMR recovery. */ -import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it, vi } from 'vitest' -import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' -import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' -import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' -import { apply, inject } from '../src/client/index.ts' +// @vitest-environment jsdom +/** + * Settings shell registration inside the assembled web client: the shell + * occupies the `sidebar.settings` hole ui-sidebar declares, its ledger + * projections read the product's real sections, its connection control is the + * roster's Connection, and it survives a Loader rebuild of the declarer. + */ +import { describe, expect, vi } from 'vitest' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import { createClientTest, type TestClient, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts' +import { inject } from '../src/client/index.ts' import type { SettingsRootInjected } from '../src/client/shell-contract.ts' import { SettingsRoot } from '../src/client/SettingsRoot.tsx' -async function bench() { - const ctx = new Context() - await ctx.plugin(SlotRegistry).await() - // Copy machinery the shell only reads a revision from; the real locale - // plugin would drag its own settings-row dependencies into this bench. - ctx.provide('locale', { - register: () => () => {}, - bind: () => (key: string) => key, - getSnapshot: () => ({ active: 'zh', locales: [], revision: 0 }), - subscribe: () => () => {}, - } as never) - // The shell mounts ui-settings, which injects `remote.settings`; without the - // namespace provided its fiber parks and no slot is ever declared. - const settings = { - describe: async () => ({ ok: false, error: new RemoteError('gateway/internal', 'no settings', {}) }), - } - const reconnect = vi.fn() - const connectionState = { - getSnapshot: () => 'connected' as const, - subscribe: () => () => {}, - } - ctx.provide('connection', { state: connectionState, reconnect } as never) - ctx.provide('remote', { - $on: () => () => {}, - $host: { home: undefined, isLoopback: false }, - settings, - } as never) - ctx.provide('remote.settings', settings as never) - await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() - return { ctx, slots: ctx.get('slots') as SlotRegistry, connectionState, reconnect } -} +const SELF = '@deepseek-ai/dsh-client-ui-settings-general' +const SIDEBAR = '@deepseek-ai/dsh-client-ui-sidebar' +const it = createClientTest({ roster: webApp }) +/** The whole roster's first boot pays the cold module transform of every plugin package. */ +const COLD_BOOT_TIMEOUT_MS = 60_000 -function declare(slots: SlotRegistry): () => void { - return slots.register( - { name: 'root', children: { 'sidebar.settings': { kind: 'single', scope: 'root' } } } as never, - () => null, - ) -} - -function injectedOf(slots: SlotRegistry): SettingsRootInjected { - const entry = slots.entries('sidebar.settings')[0]! +function injectedOf(c: TestClient): SettingsRootInjected { + const entry = c.ctx.slots.entries('sidebar.settings')[0]! return (entry.inject as () => SettingsRootInjected)() } @@ -61,82 +32,74 @@ const CHILD_SPECS = { 'settings.section': { kind: 'list', scope: 'root' }, 'settings.onboarding': { kind: 'list', scope: 'root' }, } as const +const CHILD_NAMES = Object.keys(CHILD_SPECS) as Array -describe('ui-settings apply', () => { - it('declares only the slot registry (a pure composition face, no locale)', () => { - expect(inject).toEqual([ - 'slots', 'locale', 'connection', 'remote', 'remote.settings', 'settingsScope', - ]) +/** + * Section ids the web-app roster registers, in nav order: this package, then + * ui-settings-models, ui-settings-plugins, and ui-agent-preset. A plugin adding + * a section changes this list. + */ +const PRODUCT_SECTIONS: readonly string[] = ['general', 'models', 'plugins', 'agent-presets'] +/** Onboarding steps the web-app roster registers, in coordinator order; both come from ui-settings-models. */ +const PRODUCT_ONBOARDING: readonly { id: string; order: number }[] = [ + { id: 'welcome-notice', order: -100 }, + { id: 'deepseek-official', order: 0 }, +] + +describe('ui-settings-general shell', () => { + it('declares its services', () => { + expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.settings', 'settingsScope']) }) - it('registers the shell and declares every child slot, before or after the declaration', async () => { - const before = await bench() - declare(before.slots) - await before.ctx.plugin({ inject: [...inject], apply }).await() - expect(before.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot) - for (const name of Object.keys(CHILD_SPECS) as Array) { - expect(before.slots.spec(name)).toEqual(CHILD_SPECS[name]) - } + it('occupies sidebar.settings, declared by ui-sidebar, and declares every child slot', async ({ start }) => { + const c = await start() + expect(c.ctx.slots.entries('sidebar.settings').map(entry => entry.component)).toEqual([SettingsRoot]) + for (const name of CHILD_NAMES) expect(c.ctx.slots.spec(name)).toEqual(CHILD_SPECS[name]) + }, COLD_BOOT_TIMEOUT_MS) - const after = await bench() - await after.ctx.plugin({ inject: [...inject], apply }).await() - expect(after.slots.entries('sidebar.settings')).toHaveLength(0) - declare(after.slots) - await Promise.resolve() - expect(after.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot) - // The self-inflicted ledger notifications hit the duplicate guard. - expect(after.slots.entries('sidebar.settings')).toHaveLength(1) - }) - - it('projects the section ledger into ordered nav rows with option defaults', async () => { - const b = await bench() - declare(b.slots) - await b.ctx.plugin({ inject: [...inject], apply }).await() - const { sections } = injectedOf(b.slots).hooks - // This package registers the General section itself; every other section - // arrives from a feature registrant. - const GENERAL = { id: 'general', order: 0, label: 'general.nav' } - expect(sections.getSnapshot()).toEqual([GENERAL]) - b.slots.register({ name: 'settings.section', id: 'z', order: 20, label: 'Z' } as never, () => null) - // No order and no label: both projection defaults apply. - b.slots.register({ name: 'settings.section', id: 'a' } as never, () => null) + it('projects the section ledger: product sections in order, defaults for bare rows, stable snapshots', async ({ start }) => { + const c = await start() + const { sections } = injectedOf(c).hooks + const product = sections.getSnapshot() + expect(product.map(row => row.id)).toEqual(PRODUCT_SECTIONS) + expect(product[0]).toEqual({ id: 'general', order: 0, label: expect.any(String) as string }) + c.ctx.slots.register({ name: 'settings.section', id: 'z', order: 1_000, label: 'Z' } as never, () => null) + // No order and no label: both projection defaults apply, and order 0 sorts among the product rows. + c.ctx.slots.register({ name: 'settings.section', id: 'a' } as never, () => null) const rows = sections.getSnapshot() - expect(rows).toEqual([ - GENERAL, - { id: 'a', order: 0, label: '' }, - { id: 'z', order: 20, label: 'Z' }, - ]) + expect(rows.at(-1)).toEqual({ id: 'z', order: 1_000, label: 'Z' }) + expect(rows.find(row => row.id === 'a')).toEqual({ id: 'a', order: 0, label: '' }) + expect(rows.map(row => row.order)).toEqual([...rows.map(row => row.order)].sort((x, y) => x - y)) // Snapshot identity is stable until the ledger moves (uSES contract). expect(sections.getSnapshot()).toBe(rows) const listener = vi.fn() const off = sections.subscribe(listener) - b.slots.register({ name: 'settings.section', id: 'b', order: 1, label: 'B' } as never, () => null) + c.ctx.slots.register({ name: 'settings.section', id: 'b', order: 1, label: 'B' } as never, () => null) await Promise.resolve() expect(listener).toHaveBeenCalled() expect(sections.getSnapshot()).not.toBe(rows) off() }) - it('projects the Gateway connection control without copying its state', async () => { - const b = await bench() - declare(b.slots) - await b.ctx.plugin({ inject: [...inject], apply }).await() - const injected = injectedOf(b.slots) - expect(injected.hooks.connectionState).toBe(b.connectionState) + it('projects the roster Connection control without copying its state; reconnect opens a new $events generation', async ({ start }) => { + const c = await start() + const injected = injectedOf(c) + expect(injected.hooks.connectionState).toBe(c.connection.state) + expect(injected.hooks.connectionState.getSnapshot()).toBe('connected') injected.reconnect() - expect(b.reconnect).toHaveBeenCalledOnce() + await c.mock.streams.opened('$events', 2) + await vi.waitFor(() => { expect(c.connection.state.getSnapshot()).toBe('connected') }) }) - it('projects onboarding entries into stable coordinator order', async () => { - const b = await bench() - declare(b.slots) - await b.ctx.plugin({ inject: [...inject], apply }).await() - const { onboardingSteps } = injectedOf(b.slots).hooks - b.slots.register({ name: 'settings.onboarding', id: 'credential', order: 0 } as never, () => null) - b.slots.register({ name: 'settings.onboarding', id: 'welcome', order: -100 } as never, () => null) - b.slots.register({ name: 'settings.onboarding', id: 'default-order' } as never, () => null) + it('projects onboarding entries into stable coordinator order', async ({ start }) => { + const c = await start() + const { onboardingSteps } = injectedOf(c).hooks + expect(onboardingSteps.getSnapshot()).toEqual(PRODUCT_ONBOARDING) + c.ctx.slots.register({ name: 'settings.onboarding', id: 'credential', order: 0 } as never, () => null) + c.ctx.slots.register({ name: 'settings.onboarding', id: 'welcome', order: -100 } as never, () => null) + c.ctx.slots.register({ name: 'settings.onboarding', id: 'default-order' } as never, () => null) const steps = onboardingSteps.getSnapshot() - expect(steps).toEqual([ + expect(steps.filter(step => !PRODUCT_ONBOARDING.some(known => known.id === step.id))).toEqual([ { id: 'welcome', order: -100 }, { id: 'credential', order: 0 }, { id: 'default-order', order: 0 }, @@ -144,39 +107,28 @@ describe('ui-settings apply', () => { expect(onboardingSteps.getSnapshot()).toBe(steps) const listener = vi.fn() const off = onboardingSteps.subscribe(listener) - b.slots.register({ name: 'settings.onboarding', id: 'later', order: 10 } as never, () => null) + c.ctx.slots.register({ name: 'settings.onboarding', id: 'later', order: 10 } as never, () => null) await Promise.resolve() expect(listener).toHaveBeenCalledOnce() off() }) - it('re-registers after an HMR collapse re-declares the slot (stale disposer must not block)', async () => { - const b = await bench() - const redeclare = declare(b.slots) - await b.ctx.plugin({ inject: [...inject], apply }).await() - expect(b.slots.entries('sidebar.settings')).toHaveLength(1) - // Declarer unload: the cascade removes our entry and every child - // declaration while our local disposer variable goes stale. - redeclare() - expect(b.slots.entries('sidebar.settings')).toHaveLength(0) - expect(b.slots.spec('settings.trigger')).toBeUndefined() - declare(b.slots) - await Promise.resolve() - expect(b.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot) - for (const name of Object.keys(CHILD_SPECS) as Array) { - expect(b.slots.spec(name)).toEqual(CHILD_SPECS[name]) - } + it('re-registers after the declarer reloads: the cascade removes the shell, the rebuilt declaration takes it back', async ({ start }) => { + const c = await start() + const before = c.ctx.slots.entries('sidebar.settings')[0] + expect(before).toBeDefined() + await c.reload(SIDEBAR) + await c.flush() + expect(c.ctx.slots.entries('sidebar.settings').map(entry => entry.component)).toEqual([SettingsRoot]) + expect(c.ctx.slots.entries('sidebar.settings')[0]).not.toBe(before) + for (const name of CHILD_NAMES) expect(c.ctx.slots.spec(name)).toEqual(CHILD_SPECS[name]) }) - it('unregisters the shell and collapses every child slot on teardown', async () => { - const b = await bench() - declare(b.slots) - const fiber = b.ctx.plugin({ inject: [...inject], apply }) - await fiber.await() - await fiber.dispose() - expect(b.slots.entries('sidebar.settings')).toHaveLength(0) - for (const name of Object.keys(CHILD_SPECS) as Array) { - expect(b.slots.spec(name)).toBeUndefined() - } + it('unregisters the shell and collapses every child slot when its row unloads', async ({ start }) => { + const c = await start() + await c.unload(SELF) + await c.flush() + expect(c.ctx.slots.entries('sidebar.settings')).toHaveLength(0) + for (const name of CHILD_NAMES) expect(c.ctx.slots.spec(name)).toBeUndefined() }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a96ac6370..948bb100b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3628,6 +3628,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-remote-mock': + specifier: workspace:^ + version: link:../../test-support/remote-mock '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../settings/settings