Browse Source

test(client): cover settings lifecycle guards

Yichen Jiang 2 months ago
parent
commit
d415b63c19

+ 24 - 0
packages/client/ui-permission-presets/tests/settings-store.client.spec.ts

@@ -187,6 +187,23 @@ describe('permission settings store', () => {
     }).controller
     await thrown.load()
     expect(thrown.store.getSnapshot()).toMatchObject({ status: 'error', error: 'disconnected' })
+
+    const wire = {
+      settings: {
+        describe: () => Promise.resolve(ok({
+          writable: true, hasDocument: false, namespaces: [view('read-only')],
+        })),
+        mutate,
+      },
+    } as never
+    const mirror = new SettingsDescribeMirror(wire)
+    const malformed = new PermissionPresetSettingsController(mirror, wire, {
+      rehydrate: () => { throw 'schema disconnected' },
+    } as never)
+    await malformed.load()
+    expect(malformed.store.getSnapshot()).toMatchObject({
+      status: 'error', error: 'schema disconnected',
+    })
   })
 
   it('hides the row in a remote browser instead of loading forever', async () => {
@@ -216,6 +233,13 @@ describe('permission settings store', () => {
   })
 
   it('disposal stops deriving and suppresses in-flight writes', async () => {
+    const neverRead = vi.fn()
+    const { controller: neverLoaded } = permissionController({ describe: neverRead, mutate: vi.fn() })
+    neverLoaded.dispose()
+    await neverLoaded.load()
+    expect(neverLoaded.store.getSnapshot().status).toBe('idle')
+    expect(neverRead).not.toHaveBeenCalled()
+
     const read = Promise.withResolvers<ReturnType<typeof ok<{
       writable: boolean
       namespaces: SettingsNamespaceView[]

+ 41 - 1
packages/client/ui-settings-plugins/tests/stores.client.spec.ts

@@ -8,7 +8,9 @@ import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-clie
 import { CardForm, numberField, textField } from '../src/client/card-form.ts'
 import { AgentLoopCardController, type AgentLoopSettings } from '../src/client/agent-loop-card-controller.ts'
 import { BashCardController, type BashSettings } from '../src/client/bash-card-controller.ts'
-import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
+import {
+  SettingsDescribeMirror, type SettingsMirrorSnapshot,
+} from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
 import { ConfigurablePluginsTabController } from '../src/client/tab-store.ts'
 import { WebSearchCardController, type WebSearchSettings } from '../src/client/web-search-card-controller.ts'
 
@@ -635,6 +637,44 @@ describe('ConfigurablePluginsTabController', () => {
     expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual([])
   })
 
+  it('ignores a mirror notification already queued when disposal starts', () => {
+    let notify = (): void => {}
+    let snapshot: SettingsMirrorSnapshot = {
+      status: 'ready' as const,
+      view: { writable: true, hasDocument: true, namespaces: [] },
+      error: null,
+    }
+    const describeFace = {
+      getSnapshot: () => snapshot,
+      subscribe: (listener: () => void) => {
+        notify = listener
+        return () => {}
+      },
+      ensure: () => Promise.resolve(),
+      acceptView: vi.fn(),
+    } as never
+    const controller = new ConfigurablePluginsTabController(describeFace, () => ledger('bash'))
+    expect(controller.inject().hooks.configurablePlugins.getSnapshot())
+      .toEqual({ loaded: true, namespaces: [] })
+
+    controller.dispose()
+    snapshot = {
+      status: 'ready',
+      view: {
+        writable: true,
+        hasDocument: true,
+        namespaces: [{
+          ns: 'bash', schema: {}, value: {}, applies: 'live', secrets: [], revision: 1,
+        }],
+      },
+      error: null,
+    }
+    notify()
+
+    expect(controller.inject().hooks.configurablePlugins.getSnapshot())
+      .toEqual({ loaded: true, namespaces: [] })
+  })
+
   it('reports the Host answered even when it serves nothing this tab shows', async () => {
     const settings = settingsApi(['ui-theme'])
     const controller = new ConfigurablePluginsTabController(settings.mirror, () => ledger('bash'))

+ 54 - 0
packages/client/ui-settings/tests/settings-scope.client.spec.ts

@@ -259,6 +259,27 @@ describe('SettingsScopeController', () => {
     expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 2 })
   })
 
+  it('keeps the write queue usable when a write publication listener throws', async () => {
+    const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'system' }, 1))
+    const mutate = vi.fn()
+      .mockResolvedValueOnce(ok(view({ preference: 'dark' }, 2)))
+      .mockResolvedValueOnce(ok(view({ preference: 'light' }, 3)))
+    const { mirror, scope } = derivedScope({ describe: describeCall, mutate })
+    await mirror.load()
+    let shouldThrow = true
+    mirror.subscribe(() => {
+      if (!shouldThrow) return
+      shouldThrow = false
+      throw new Error('write subscriber failed')
+    })
+
+    await expect(scope.set('preference', 'dark')).rejects.toThrow('write subscriber failed')
+    await expect(scope.set('preference', 'light')).resolves.toBeUndefined()
+
+    expect(mutate).toHaveBeenCalledTimes(2)
+    expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 3 })
+  })
+
   it('cancels queued and post-dispose writes while draining the in-flight mutation', async () => {
     const first = deferred<RpcResponse<SettingsNamespaceView>>()
     const mutate = vi.fn().mockReturnValue(first.promise)
@@ -292,6 +313,38 @@ describe('SettingsScopeController', () => {
     expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 1 })
   })
 
+  it('ignores a mirror notification already queued when disposal starts', async () => {
+    let notify = (): void => {}
+    let snapshot = {
+      status: 'ready' as const,
+      view: {
+        writable: true, hasDocument: true,
+        namespaces: [view({ preference: 'dark' }, 1)],
+      },
+      error: null,
+    }
+    const mirror = {
+      getSnapshot: () => snapshot,
+      subscribe: (listener: () => void) => {
+        notify = listener
+        return () => {}
+      },
+    } as never
+    const wire = { settings: {} } as never
+    const scope = new SettingsScopeController<UiTestSettings>(
+      wire, { namespace: 'ui-test' }, mirror, 'host', settingsSchema)
+    expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 1 })
+
+    await scope.dispose()
+    snapshot = {
+      ...snapshot,
+      view: { ...snapshot.view, namespaces: [view({ preference: 'light' }, 2)] },
+    }
+    notify()
+
+    expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 1 })
+  })
+
   it('keeps a remote browser in memory mode without Host calls', async () => {
     const describeCall = vi.fn()
     const mutate = vi.fn()
@@ -380,6 +433,7 @@ describe('SettingsScopeBinder.bind', () => {
     let locale!: SettingsScope<UiTestSettings>
     new TestRemote(ctx)
     await ctx.plugin(SettingsScopeBinder, { mirror, schema: settingsSchema }).await()
+    expect(ctx.settingsScope.describe()).toBe(mirror)
     const fiber = ctx.plugin({
       inject: ['connection', 'remote', 'settingsScope'],
       apply: (plugin: Context) => {