mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-09 04:02:35 +00:00
refactor(ui-settings): derive settings scopes from the describe mirror
This commit is contained in:
@@ -1,35 +1,64 @@
|
||||
/**
|
||||
* Settings domain base plugin, browser half. Provides `ctx.settingsScope`, the
|
||||
* settings-namespace Host transport every preference row binds its durable
|
||||
* section through, and owns the canonical slot-type contract for the settings
|
||||
* surface. It depends on no `ui-*` presentation package, so any feature that
|
||||
* owns a preference can reach it: the settings SHELL — the `sidebar.settings`
|
||||
* occupant, its navigation, and the chrome — lives in ui-settings-general,
|
||||
* because a shell dependency on ui-sidebar would close a reference cycle
|
||||
* through ui-layout and ui-theme. Export discipline: packages/client/AGENTS.md.
|
||||
* settings-namespace scope service every preference row binds its durable
|
||||
* section through, and owns the one `settings.describe` reader in the browser:
|
||||
* the describe mirror, whose invalidation subscriptions
|
||||
* (`settings/document-updated`, `connection/reset`) live here so every derived
|
||||
* surface refreshes from a single wire read. It depends on no `ui-*`
|
||||
* presentation package, so any feature that owns a preference can reach it:
|
||||
* the settings SHELL — the `sidebar.settings` occupant, its navigation, and
|
||||
* the chrome — lives in ui-settings-general, because a shell dependency on
|
||||
* ui-sidebar would close a reference cycle through ui-layout and ui-theme.
|
||||
* Export discipline: packages/client/AGENTS.md.
|
||||
*/
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
// Type-only pair supplying `$on` and its key face without dragging a build
|
||||
// artifact into the Host graph (rationale beside the same pair in
|
||||
// settings-scope.ts).
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/types'
|
||||
import type {} from '@deepseek-ai/dsh-settings/types'
|
||||
import { SettingsScopeBinder } from './settings-scope.ts'
|
||||
import { SettingsDescribeMirror } from './settings-mirror.ts'
|
||||
|
||||
export type {
|
||||
SettingsGeneralItemOwnerProps, SettingsHeaderOwnerProps, SettingsOnboardingOwnerProps,
|
||||
SettingsPluginsTabOwnerProps, SettingsSectionOwnerProps, SettingsTriggerOwnerProps,
|
||||
} from './contract/slots.ts'
|
||||
export { SettingsScopeController, SettingsScopeBinder } from './settings-scope.ts'
|
||||
export { SettingsDescribeMirror } from './settings-mirror.ts'
|
||||
export type { SettingsDescribeView, SettingsMirrorSnapshot } from './settings-mirror.ts'
|
||||
|
||||
/**
|
||||
* Required services: none. The transport is resolved per caller through
|
||||
* `this.ctx` at `bind` time, so this plugin waits for nothing.
|
||||
* Required services: the wire handle for the mirror's reads and the forwarded
|
||||
* settings invalidation the mirror refreshes on.
|
||||
*/
|
||||
export const inject = []
|
||||
export const inject = ['connection', 'remote']
|
||||
|
||||
/**
|
||||
* Provide the settings-namespace scope service.
|
||||
* Provide the settings-namespace scope service over one shared describe
|
||||
* mirror, and keep that mirror fresh on the two signals that can move the
|
||||
* settings document: a document commit and a (re)connect.
|
||||
*
|
||||
* Constructing the service in this plugin's fiber keeps its traced methods
|
||||
* bound to each consuming plugin's context.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
new SettingsScopeBinder(ctx)
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const mirror = new SettingsDescribeMirror(
|
||||
connection.api,
|
||||
connection.isLoopback ? 'host' : 'memory',
|
||||
)
|
||||
ctx.effect(() => {
|
||||
const disposers = [
|
||||
(ctx.get('remote') as ClientContext['remote']).$on('settings/document-updated', () => { void mirror.load() }),
|
||||
ctx.on('connection/reset', () => { void mirror.load() }),
|
||||
]
|
||||
// The first connection also emits connection/reset; the in-flight fold
|
||||
// makes this eager read and that reset converge to one wire call.
|
||||
void mirror.ensure()
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
}, 'ui-settings: describe mirror invalidations')
|
||||
new SettingsScopeBinder(ctx, { mirror })
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
/**
|
||||
* Host transport for the settings-namespace scope contract. The contract types
|
||||
* live in `dsh-client-runtime` (the common dependency of every feature that
|
||||
* owns a preference); this file owns the wire behavior and the invalidation
|
||||
* subscription, both of which are Settings-surface concerns.
|
||||
* owns a preference); this file owns the per-namespace derivation over the
|
||||
* shared {@link SettingsDescribeMirror} and the serialized write path, both of
|
||||
* which are Settings-surface concerns. Reads never touch the wire here: the
|
||||
* mirror is the one `settings.describe` reader, and every scope is a selector
|
||||
* over its snapshot.
|
||||
*/
|
||||
|
||||
import { Service } from '@deepseek-ai/cordis'
|
||||
@@ -22,8 +25,8 @@ import {
|
||||
// Client half declares `ctx.remote` with no generated import, and the
|
||||
// allowlist's `types` subpath is a pure-type source file, so the pair supplies
|
||||
// `$on` and its key face without dragging a build artifact in. The runtime
|
||||
// `remote` injection belongs to whoever calls bindSettingsScope: the
|
||||
// subscription is registered on the caller's own context.
|
||||
// `remote` injection belongs to the providing plugin's apply, which registers
|
||||
// the mirror's invalidation subscriptions.
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/types'
|
||||
// The forwarded event's own declaration: `$on`'s key face is
|
||||
@@ -31,29 +34,39 @@ import type {} from '@deepseek-ai/dsh-api-remotes/types'
|
||||
// never — the owning package's client-safe, type-only subpath supplies the
|
||||
// cordis `Events` entry (and with it the branded `SettingsNamespace`).
|
||||
import type {} from '@deepseek-ai/dsh-settings/types'
|
||||
import { SettingsDescribeMirror } from './settings-mirror.ts'
|
||||
|
||||
type SettingsFace = Pick<IApiClient, 'settings'>
|
||||
|
||||
/**
|
||||
* Serializes one namespace's Host reads and writes behind a snapshot store.
|
||||
* Reads never block plugin activation; writes carry the latest known
|
||||
* namespace revision and teardown waits for the operation already crossing
|
||||
* the wire.
|
||||
* One namespace's derived view over the shared describe mirror, plus that
|
||||
* namespace's serialized Host writes. Writes carry the latest known namespace
|
||||
* revision, fold their answers back into the mirror, and teardown waits for
|
||||
* the operation already crossing the wire.
|
||||
*/
|
||||
export class SettingsScopeController<T> implements SettingsScope<T> {
|
||||
private readonly store: SnapshotStore<SettingsScopeSnapshot<T>>
|
||||
private tail: Promise<void> = Promise.resolve()
|
||||
private readGeneration = 0
|
||||
private writeGeneration = 0
|
||||
private disposed = false
|
||||
private readonly unsubscribe: (() => void) | undefined
|
||||
/**
|
||||
* Revision answered by a superseded write still ahead of the mirror: the
|
||||
* mirror only folds the LATEST settlement in, so a queued successor takes
|
||||
* its fence from here first.
|
||||
*/
|
||||
private pendingRevision: number | undefined
|
||||
|
||||
/**
|
||||
* @param api - settings wire face.
|
||||
* @param api - settings wire face (writes only; reads ride the mirror).
|
||||
* @param spec - namespace identity and optional narrowing decoder.
|
||||
* @param mirror - the shared describe mirror this scope derives from.
|
||||
* @param persistence - remote browsers remain process-local because settings RPCs are loopback-only.
|
||||
*/
|
||||
constructor(
|
||||
private readonly api: SettingsFace,
|
||||
private readonly spec: SettingsScopeSpec<T>,
|
||||
private readonly mirror: SettingsDescribeMirror,
|
||||
private readonly persistence: 'host' | 'memory' = 'host',
|
||||
) {
|
||||
this.store = createSnapshotStore<SettingsScopeSnapshot<T>>({
|
||||
@@ -65,6 +78,10 @@ export class SettingsScopeController<T> implements SettingsScope<T> {
|
||||
writable: false,
|
||||
mode: persistence,
|
||||
})
|
||||
if (persistence === 'host') {
|
||||
this.unsubscribe = mirror.subscribe(() => { this.derive() })
|
||||
this.derive()
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns the current sync snapshot (stable reference until the next change). */
|
||||
@@ -81,15 +98,6 @@ export class SettingsScopeController<T> implements SettingsScope<T> {
|
||||
return this.store.subscribe(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a Host refresh; a newer read or user write suppresses stale publication.
|
||||
* @returns settlement after the queued read completes or is skipped.
|
||||
*/
|
||||
load(): Promise<void> {
|
||||
const generation = ++this.readGeneration
|
||||
return this.enqueue(() => this.read(generation))
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue one field write; see {@link SettingsScope.set} for the ordering,
|
||||
* revision, and recovery contract.
|
||||
@@ -112,10 +120,9 @@ export class SettingsScopeController<T> implements SettingsScope<T> {
|
||||
}
|
||||
|
||||
private write(op: SettingsPathOpView): Promise<void> {
|
||||
this.readGeneration += 1
|
||||
const generation = ++this.writeGeneration
|
||||
return this.enqueue(async () => {
|
||||
const revision = this.getSnapshot().revision
|
||||
const revision = this.pendingRevision ?? this.getSnapshot().revision
|
||||
let response: Awaited<ReturnType<SettingsFace['settings']['mutate']>>
|
||||
try {
|
||||
response = await this.api.settings.mutate({
|
||||
@@ -124,25 +131,39 @@ export class SettingsScopeController<T> implements SettingsScope<T> {
|
||||
...(revision === undefined ? {} : { expectedRevision: revision }),
|
||||
})
|
||||
} catch (_settingsWriteFailure) {
|
||||
if (!this.disposed && generation === this.writeGeneration) await this.read(++this.readGeneration)
|
||||
await this.recover(generation)
|
||||
return
|
||||
}
|
||||
if (!response.result.ok) {
|
||||
if (!this.disposed && generation === this.writeGeneration) await this.read(++this.readGeneration)
|
||||
await this.recover(generation)
|
||||
return
|
||||
}
|
||||
this.accept(response.result.value, generation === this.writeGeneration)
|
||||
if (this.disposed) return
|
||||
if (generation === this.writeGeneration) {
|
||||
this.pendingRevision = undefined
|
||||
this.mirror.acceptView(response.result.value)
|
||||
} else {
|
||||
this.pendingRevision = response.result.value.revision
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Reload Host state for the latest failed write; superseded failures leave recovery to it. */
|
||||
private async recover(generation: number): Promise<void> {
|
||||
if (this.disposed || generation !== this.writeGeneration) return
|
||||
this.pendingRevision = undefined
|
||||
await this.mirror.load()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop queued operations and wait for the current wire call to settle.
|
||||
* Stop queued operations, stop deriving, and wait for the current wire call
|
||||
* to settle.
|
||||
* @returns settlement after the controller reaches quiescence.
|
||||
*/
|
||||
async dispose(): Promise<void> {
|
||||
this.disposed = true
|
||||
this.readGeneration += 1
|
||||
this.writeGeneration += 1
|
||||
this.unsubscribe?.()
|
||||
await this.tail
|
||||
}
|
||||
|
||||
@@ -158,36 +179,25 @@ export class SettingsScopeController<T> implements SettingsScope<T> {
|
||||
return task
|
||||
}
|
||||
|
||||
private async read(generation: number): Promise<void> {
|
||||
let response: Awaited<ReturnType<SettingsFace['settings']['describe']>>
|
||||
try {
|
||||
response = await this.api.settings.describe({})
|
||||
} catch (_settingsReadFailure) {
|
||||
return
|
||||
}
|
||||
if (!response.result.ok || this.disposed) return
|
||||
const { namespaces, writable } = response.result.value
|
||||
const view = namespaces.find(candidate => candidate.ns === this.spec.namespace)
|
||||
const publish = generation === this.readGeneration
|
||||
private derive(): void {
|
||||
if (this.disposed) return
|
||||
const mirrored = this.mirror.getSnapshot()
|
||||
if (mirrored.view === undefined) return
|
||||
const { writable } = mirrored.view
|
||||
const view = mirrored.view.namespaces.find(candidate => candidate.ns === this.spec.namespace)
|
||||
if (view === undefined) {
|
||||
if (publish) {
|
||||
this.store.update((draft) => {
|
||||
draft.status = 'unavailable'
|
||||
draft.writable = writable
|
||||
})
|
||||
}
|
||||
this.store.update((draft) => {
|
||||
draft.status = 'unavailable'
|
||||
draft.writable = writable
|
||||
})
|
||||
return
|
||||
}
|
||||
this.accept(view, publish, writable)
|
||||
}
|
||||
|
||||
private accept(view: SettingsNamespaceView, publish: boolean, writable?: boolean): void {
|
||||
const decoded = publish ? this.decode(view) : undefined
|
||||
const decoded = this.decode(view)
|
||||
this.store.update((draft) => {
|
||||
draft.revision = view.revision
|
||||
draft.base = view.base
|
||||
draft.user = view.user
|
||||
if (writable !== undefined) draft.writable = writable
|
||||
draft.writable = writable
|
||||
if (decoded === undefined) return
|
||||
draft.status = 'ready'
|
||||
draft.value = decoded
|
||||
@@ -225,20 +235,24 @@ declare module '@deepseek-ai/cordis' {
|
||||
* (`packages/client/tsdown.client.ts`).
|
||||
*/
|
||||
export class SettingsScopeBinder extends Service {
|
||||
private readonly mirror: SettingsDescribeMirror
|
||||
|
||||
/**
|
||||
* @param ctx - the providing plugin's context.
|
||||
* @param config - the shared describe mirror every bound scope derives from.
|
||||
*/
|
||||
constructor(ctx: Context) {
|
||||
constructor(ctx: Context, config: { mirror: SettingsDescribeMirror }) {
|
||||
super(ctx, 'settingsScope')
|
||||
this.mirror = config.mirror
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind one namespace scope to settings and connection invalidations on the
|
||||
* CALLER's plugin lifecycle — the service proxy binds `this.ctx` to the
|
||||
* caller at call time, so the scope's disposer belongs to the calling fiber.
|
||||
* Listeners exist before the initial background read starts, so activation
|
||||
* never blocks on the settings transport. The caller injects `connection`
|
||||
* for the transport and `remote` for the forwarded settings invalidation.
|
||||
* Bind one namespace scope on the CALLER's plugin lifecycle — the service
|
||||
* proxy binds `this.ctx` to the caller at call time, so the scope's disposer
|
||||
* belongs to the calling fiber. The scope derives from the shared mirror
|
||||
* (whose invalidation subscriptions live with the providing plugin), so
|
||||
* binding adds no wire read of its own and activation never blocks on the
|
||||
* settings transport.
|
||||
* @param spec - domain-owned namespace contract.
|
||||
* @returns the bound scope consumed by the domain's services and rows.
|
||||
*/
|
||||
@@ -248,20 +262,12 @@ export class SettingsScopeBinder extends Service {
|
||||
const controller = new SettingsScopeController<T>(
|
||||
connection.api,
|
||||
spec,
|
||||
this.mirror,
|
||||
connection.isLoopback ? 'host' : 'memory',
|
||||
)
|
||||
ctx.effect(() => {
|
||||
const refresh = (namespace?: string): void => {
|
||||
if (namespace !== undefined && namespace !== spec.namespace) return
|
||||
void controller.load()
|
||||
}
|
||||
const disposers = [
|
||||
(ctx.get('remote') as Context['remote']).$on('settings/document-updated', refresh),
|
||||
ctx.on('connection/reset', () => { refresh() }),
|
||||
]
|
||||
void controller.load()
|
||||
void this.mirror.ensure()
|
||||
return async () => {
|
||||
for (const dispose of disposers) dispose()
|
||||
await controller.dispose()
|
||||
}
|
||||
}, `ui-settings: ${spec.namespace} settings scope`)
|
||||
|
||||
@@ -1,29 +1,56 @@
|
||||
/**
|
||||
* The settings domain base plugin's own mounting behavior: it stands up
|
||||
* `ctx.settingsScope` for every feature that owns a preference row, and the
|
||||
* service retires with its fiber.
|
||||
* `ctx.settingsScope` over one shared describe mirror, keeps that mirror
|
||||
* fresh on settings-document and connection-reset invalidations, and retires
|
||||
* both the service and the subscriptions with its fiber.
|
||||
*/
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply, inject, SettingsScopeBinder } from '../src/client/index.ts'
|
||||
|
||||
/** Boot the browser half over a bare root context; it injects nothing. */
|
||||
/** Boot the browser half over a fake loopback connection and test remote. */
|
||||
function bench() {
|
||||
const describeCall = vi.fn().mockResolvedValue({
|
||||
rpcId: 'plugin-bench' as never,
|
||||
result: { ok: true, value: { writable: true, hasDocument: true, namespaces: [] } },
|
||||
})
|
||||
const ctx = new Context()
|
||||
return { ctx, fiber: ctx.plugin({ inject: [...inject], apply }) }
|
||||
ctx.provide('connection', {
|
||||
api: { settings: { describe: describeCall } },
|
||||
isLoopback: true,
|
||||
} as never)
|
||||
new TestRemote(ctx)
|
||||
return { ctx, describeCall, fiber: ctx.plugin({ inject: [...inject], apply }) }
|
||||
}
|
||||
|
||||
describe('settings domain base plugin', () => {
|
||||
it('mounts the scope service under settingsScope', async () => {
|
||||
const { ctx, fiber } = bench()
|
||||
it('mounts the scope service under settingsScope and reads once eagerly', async () => {
|
||||
const { ctx, describeCall, fiber } = bench()
|
||||
await fiber.await()
|
||||
expect(ctx.get('settingsScope')).toBeInstanceOf(SettingsScopeBinder)
|
||||
await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(1) })
|
||||
})
|
||||
|
||||
it('fiber disposal retires the service', async () => {
|
||||
const { ctx, fiber } = bench()
|
||||
it('refreshes the mirror on document commits and connection resets, once each', async () => {
|
||||
const { ctx, describeCall, fiber } = bench()
|
||||
await fiber.await()
|
||||
await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(1) })
|
||||
ctx.remote.$dispatch('settings/document-updated', ['ui-test', 0])
|
||||
await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(2) })
|
||||
ctx.emit('connection/reset')
|
||||
await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(3) })
|
||||
})
|
||||
|
||||
it('fiber disposal retires the service and its invalidation subscriptions', async () => {
|
||||
const { ctx, describeCall, fiber } = bench()
|
||||
await fiber.await()
|
||||
await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(1) })
|
||||
await fiber.dispose()
|
||||
expect(ctx.get('settingsScope')).toBeUndefined()
|
||||
ctx.remote.$dispatch('settings/document-updated', ['ui-test', 0])
|
||||
ctx.emit('connection/reset')
|
||||
await Promise.resolve()
|
||||
expect(describeCall).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-re
|
||||
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { SettingsScope } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SettingsScopeController, SettingsScopeBinder } from '../src/client/settings-scope.ts'
|
||||
import { SettingsDescribeMirror } from '../src/client/settings-mirror.ts'
|
||||
|
||||
interface UiTestSettings {
|
||||
preference: 'light' | 'dark' | 'system'
|
||||
@@ -52,6 +53,17 @@ function deferred<T>() {
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
/** A host-mode mirror plus a controller derived from it, over one fake wire. */
|
||||
function derivedScope(
|
||||
api: { describe?: ReturnType<typeof vi.fn>; mutate?: ReturnType<typeof vi.fn> },
|
||||
spec: { namespace: string; decode?: (section: unknown) => UiTestSettings | undefined } = { namespace: 'ui-test' },
|
||||
) {
|
||||
const wire = { settings: api } as never
|
||||
const mirror = new SettingsDescribeMirror(wire)
|
||||
const scope = new SettingsScopeController<UiTestSettings>(wire, spec, mirror)
|
||||
return { mirror, scope }
|
||||
}
|
||||
|
||||
/** Record each distinct published section, starting from the current one. */
|
||||
function trackValues(scope: SettingsScope<UiTestSettings>): Array<UiTestSettings | undefined> {
|
||||
const seen: Array<UiTestSettings | undefined> = [scope.getSnapshot().value]
|
||||
@@ -63,16 +75,13 @@ function trackValues(scope: SettingsScope<UiTestSettings>): Array<UiTestSettings
|
||||
}
|
||||
|
||||
describe('SettingsScopeController', () => {
|
||||
it('starts loading and publishes a schema-valid section with revision and writability', async () => {
|
||||
it('starts loading and derives a schema-valid section with revision and writability', async () => {
|
||||
const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'dark' }, 3))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const { mirror, scope } = derivedScope({ describe: describeCall })
|
||||
expect(scope.getSnapshot()).toEqual({
|
||||
status: 'loading', value: undefined, revision: undefined, writable: false, mode: 'host',
|
||||
})
|
||||
await scope.load()
|
||||
await mirror.load()
|
||||
expect(scope.getSnapshot()).toEqual({
|
||||
status: 'ready', value: { preference: 'dark' }, revision: 3, writable: true, mode: 'host',
|
||||
})
|
||||
@@ -87,12 +96,9 @@ describe('SettingsScopeController', () => {
|
||||
.mockResolvedValueOnce(described(['queue'], 7))
|
||||
.mockResolvedValueOnce(rejected())
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const { mirror, scope } = derivedScope({ describe: describeCall })
|
||||
const good = trackValues(scope)
|
||||
for (let i = 0; i < 7; i++) await scope.load()
|
||||
for (let i = 0; i < 7; i++) await mirror.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({
|
||||
status: 'ready', value: { preference: 'dark' }, revision: 7,
|
||||
})
|
||||
@@ -103,45 +109,22 @@ describe('SettingsScopeController', () => {
|
||||
const broken = { ...view({ preference: 'dark' }, 2), schema: null }
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [broken] }))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
await scope.load()
|
||||
const { mirror, scope } = derivedScope({ describe: describeCall })
|
||||
await mirror.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'loading', value: undefined, revision: 2 })
|
||||
})
|
||||
|
||||
it('suppresses a superseded read of an unexposed namespace', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] }))
|
||||
.mockResolvedValueOnce(described({ preference: 'dark' }, 1))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const statuses: string[] = []
|
||||
scope.subscribe(() => { statuses.push(scope.getSnapshot().status) })
|
||||
const stale = scope.load()
|
||||
const fresh = scope.load()
|
||||
await Promise.all([stale, fresh])
|
||||
expect(statuses).not.toContain('unavailable')
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' } })
|
||||
})
|
||||
|
||||
it('reports an unexposed namespace as unavailable and recovers when it reappears', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(described({ preference: 'light' }, 1))
|
||||
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] }))
|
||||
.mockResolvedValueOnce(described({ preference: 'system' }, 2))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
await scope.load()
|
||||
const { mirror, scope } = derivedScope({ describe: describeCall })
|
||||
await mirror.load()
|
||||
expect(scope.getSnapshot().status).toBe('ready')
|
||||
await scope.load()
|
||||
await mirror.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'unavailable', value: { preference: 'light' } })
|
||||
await scope.load()
|
||||
await mirror.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'system' }, revision: 2 })
|
||||
})
|
||||
|
||||
@@ -149,18 +132,15 @@ describe('SettingsScopeController', () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(described({ preference: 'light' }, 1))
|
||||
.mockResolvedValueOnce(described({ preference: 'dark' }, 2))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{
|
||||
namespace: 'ui-test',
|
||||
decode: section => (section as UiTestSettings).preference === 'dark'
|
||||
? section as UiTestSettings
|
||||
: undefined,
|
||||
},
|
||||
)
|
||||
await scope.load()
|
||||
const { mirror, scope } = derivedScope({ describe: describeCall }, {
|
||||
namespace: 'ui-test',
|
||||
decode: section => (section as UiTestSettings).preference === 'dark'
|
||||
? section as UiTestSettings
|
||||
: undefined,
|
||||
})
|
||||
await mirror.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'loading', value: undefined, revision: 1 })
|
||||
await scope.load()
|
||||
await mirror.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' }, revision: 2 })
|
||||
})
|
||||
|
||||
@@ -170,12 +150,9 @@ describe('SettingsScopeController', () => {
|
||||
const mutate = vi.fn()
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockResolvedValueOnce(ok(view({ preference: 'light' }, 6)))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall, mutate } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const { mirror, scope } = derivedScope({ describe: describeCall, mutate })
|
||||
const published = trackValues(scope)
|
||||
await scope.load()
|
||||
await mirror.load()
|
||||
const dark = scope.set('preference', 'dark')
|
||||
const light = scope.set('preference', 'light')
|
||||
await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
|
||||
@@ -195,6 +172,19 @@ describe('SettingsScopeController', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('folds the latest write answer into the mirror so a sibling scope sees it', async () => {
|
||||
const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'system' }, 4))
|
||||
const mutate = vi.fn().mockResolvedValueOnce(ok(view({ preference: 'dark' }, 5)))
|
||||
const wire = { settings: { describe: describeCall, mutate } } as never
|
||||
const mirror = new SettingsDescribeMirror(wire)
|
||||
const writer = new SettingsScopeController<UiTestSettings>(wire, { namespace: 'ui-test' }, mirror)
|
||||
const sibling = new SettingsScopeController<UiTestSettings>(wire, { namespace: 'ui-test' }, mirror)
|
||||
await mirror.load()
|
||||
await writer.set('preference', 'dark')
|
||||
expect(describeCall).toHaveBeenCalledTimes(1)
|
||||
expect(sibling.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 5 })
|
||||
})
|
||||
|
||||
it('recovers the latest rejected or thrown write from Host state', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(described({ preference: 'system' }, 2))
|
||||
@@ -202,52 +192,45 @@ describe('SettingsScopeController', () => {
|
||||
const mutate = vi.fn()
|
||||
.mockResolvedValueOnce(rejected())
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall, mutate } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const { mirror, scope } = derivedScope({ describe: describeCall, mutate })
|
||||
const published = trackValues(scope)
|
||||
await mirror.load()
|
||||
await scope.set('preference', 'dark')
|
||||
await scope.set('preference', 'system')
|
||||
expect(published.map(section => section?.preference)).toEqual([undefined, 'system', 'light'])
|
||||
})
|
||||
|
||||
it('does not recover superseded rejected or thrown writes', async () => {
|
||||
const describeCall = vi.fn()
|
||||
const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'system' }, 2))
|
||||
const mutate = vi.fn()
|
||||
.mockResolvedValueOnce(rejected())
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
.mockResolvedValueOnce(ok(view({ preference: 'light' }, 3)))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall, mutate } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const { mirror, scope } = derivedScope({ describe: describeCall, mutate })
|
||||
const published = trackValues(scope)
|
||||
await mirror.load()
|
||||
await Promise.all([
|
||||
scope.set('preference', 'dark'),
|
||||
scope.set('preference', 'system'),
|
||||
scope.set('preference', 'light'),
|
||||
])
|
||||
expect(describeCall).not.toHaveBeenCalled()
|
||||
expect(published.map(section => section?.preference)).toEqual([undefined, 'light'])
|
||||
expect(describeCall).toHaveBeenCalledTimes(1)
|
||||
expect(published.map(section => section?.preference)).toEqual([undefined, 'system', 'light'])
|
||||
})
|
||||
|
||||
it('keeps the write queue usable when a subscriber throws', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(described({ preference: 'dark' }, 1))
|
||||
.mockResolvedValueOnce(described({ preference: 'light' }, 2))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const { mirror, scope } = derivedScope({ describe: describeCall })
|
||||
let thrown = false
|
||||
scope.subscribe(() => {
|
||||
if (thrown) return
|
||||
thrown = true
|
||||
throw new Error('subscriber failed')
|
||||
})
|
||||
await expect(scope.load()).rejects.toThrow('subscriber failed')
|
||||
await expect(scope.load()).resolves.toBeUndefined()
|
||||
await expect(mirror.load()).rejects.toThrow('subscriber failed')
|
||||
await expect(mirror.load()).resolves.toBeUndefined()
|
||||
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 2 })
|
||||
})
|
||||
|
||||
@@ -255,10 +238,7 @@ describe('SettingsScopeController', () => {
|
||||
const first = deferred<RpcResponse<SettingsNamespaceView>>()
|
||||
const mutate = vi.fn().mockReturnValue(first.promise)
|
||||
const describeCall = vi.fn()
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall, mutate } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const { scope } = derivedScope({ describe: describeCall, mutate })
|
||||
const published = trackValues(scope)
|
||||
const dark = scope.set('preference', 'dark')
|
||||
await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
|
||||
@@ -270,24 +250,34 @@ describe('SettingsScopeController', () => {
|
||||
first.resolve(ok(view({ preference: 'dark' }, 1)))
|
||||
await Promise.all([dark, light, stop])
|
||||
await scope.set('preference', 'system')
|
||||
await scope.load()
|
||||
expect(mutate).toHaveBeenCalledOnce()
|
||||
expect(describeCall).not.toHaveBeenCalled()
|
||||
expect(published).toEqual([undefined])
|
||||
})
|
||||
|
||||
it('stops deriving from the mirror after dispose', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(described({ preference: 'dark' }, 1))
|
||||
.mockResolvedValueOnce(described({ preference: 'light' }, 2))
|
||||
const { mirror, scope } = derivedScope({ describe: describeCall })
|
||||
await mirror.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' } })
|
||||
await scope.dispose()
|
||||
await mirror.load()
|
||||
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()
|
||||
const wire = { settings: { describe: describeCall, mutate } } as never
|
||||
const mirror = new SettingsDescribeMirror(wire, 'memory')
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall, mutate } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
'memory',
|
||||
)
|
||||
wire, { namespace: 'ui-test' }, mirror, 'memory')
|
||||
expect(scope.getSnapshot()).toEqual({
|
||||
status: 'unavailable', value: undefined, revision: undefined, writable: false, mode: 'memory',
|
||||
})
|
||||
await scope.load()
|
||||
await mirror.load()
|
||||
await scope.set('preference', 'dark')
|
||||
await scope.dispose()
|
||||
expect(describeCall).not.toHaveBeenCalled()
|
||||
@@ -302,12 +292,9 @@ describe('SettingsScopeController', () => {
|
||||
}
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [layered] }))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const { mirror, scope } = derivedScope({ describe: describeCall })
|
||||
|
||||
await scope.load()
|
||||
await mirror.load()
|
||||
|
||||
expect(scope.getSnapshot()).toMatchObject({
|
||||
value: { preference: 'dark' },
|
||||
@@ -320,12 +307,9 @@ describe('SettingsScopeController', () => {
|
||||
const inherited: SettingsNamespaceView = { ...view({ preference: 'system' }, 1), base: { preference: 'system' } }
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [inherited] }))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const { mirror, scope } = derivedScope({ describe: describeCall })
|
||||
|
||||
await scope.load()
|
||||
await mirror.load()
|
||||
|
||||
expect(scope.getSnapshot().user).toBeUndefined()
|
||||
})
|
||||
@@ -333,11 +317,8 @@ describe('SettingsScopeController', () => {
|
||||
it('clears one field through an unset op fenced by the held revision', async () => {
|
||||
const mutate = vi.fn().mockResolvedValueOnce(ok(view({ preference: 'system' }, 4)))
|
||||
const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'dark' }, 3))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall, mutate } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
await scope.load()
|
||||
const { mirror, scope } = derivedScope({ describe: describeCall, mutate })
|
||||
await mirror.load()
|
||||
|
||||
await scope.unset('preference')
|
||||
|
||||
@@ -354,64 +335,53 @@ describe('SettingsScopeController', () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(described({ preference: 'dark' }, 3))
|
||||
.mockResolvedValueOnce(described({ preference: 'light' }, 5))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall, mutate } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
await scope.load()
|
||||
const { mirror, scope } = derivedScope({ describe: describeCall, mutate })
|
||||
await mirror.load()
|
||||
|
||||
await scope.unset('preference')
|
||||
|
||||
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 5 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('SettingsScopeBinder.bind', () => {
|
||||
it('subscribes before the initial read and converges to the latest queued invalidation', async () => {
|
||||
const initial = deferred<ReturnType<typeof described>>()
|
||||
const describeCall = vi.fn()
|
||||
.mockReturnValueOnce(initial.promise)
|
||||
.mockResolvedValueOnce(described({ preference: 'light' }, 2))
|
||||
.mockResolvedValueOnce(described({ preference: 'system' }, 3))
|
||||
it('shares one mirror read across bound scopes and disposes each with its fiber', async () => {
|
||||
const describeCall = vi.fn().mockResolvedValue(described({ preference: 'dark' }, 1))
|
||||
const wire = { settings: { describe: describeCall } }
|
||||
const mirror = new SettingsDescribeMirror(wire as never)
|
||||
const ctx = new Context()
|
||||
ctx.provide('connection', {
|
||||
api: { settings: { describe: describeCall } },
|
||||
isLoopback: true,
|
||||
} as never)
|
||||
let scope!: SettingsScope<UiTestSettings>
|
||||
ctx.provide('connection', { api: wire, isLoopback: true } as never)
|
||||
let theme!: SettingsScope<UiTestSettings>
|
||||
let locale!: SettingsScope<UiTestSettings>
|
||||
new TestRemote(ctx)
|
||||
await ctx.plugin(SettingsScopeBinder).await()
|
||||
await ctx.plugin(SettingsScopeBinder, { mirror }).await()
|
||||
const fiber = ctx.plugin({
|
||||
inject: ['connection', 'remote', 'settingsScope'],
|
||||
apply: (plugin: Context) => {
|
||||
scope = plugin.settingsScope.bind<UiTestSettings>({ namespace: 'ui-test' })
|
||||
theme = plugin.settingsScope.bind<UiTestSettings>({ namespace: 'ui-test' })
|
||||
locale = plugin.settingsScope.bind<UiTestSettings>({ namespace: 'ui-test' })
|
||||
},
|
||||
})
|
||||
await fiber.await()
|
||||
await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledOnce() })
|
||||
ctx.remote.$dispatch('settings/document-updated', ['unrelated', 0])
|
||||
ctx.remote.$dispatch('settings/document-updated', ['ui-test', 0])
|
||||
ctx.emit('connection/reset')
|
||||
initial.resolve(described({ preference: 'dark' }, 1))
|
||||
await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(3) })
|
||||
await vi.waitFor(() => {
|
||||
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'system' }, revision: 3 })
|
||||
expect(theme.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' } })
|
||||
expect(locale.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' } })
|
||||
})
|
||||
expect(describeCall).toHaveBeenCalledTimes(1)
|
||||
await fiber.dispose()
|
||||
ctx.remote.$dispatch('settings/document-updated', ['ui-test', 0])
|
||||
await Promise.resolve()
|
||||
expect(describeCall).toHaveBeenCalledTimes(3)
|
||||
await mirror.load()
|
||||
expect(theme.getSnapshot()).toMatchObject({ revision: 1 })
|
||||
})
|
||||
|
||||
it('binds a remote browser in memory mode without starting a settings read', async () => {
|
||||
const describeCall = vi.fn()
|
||||
const wire = { settings: { describe: describeCall } }
|
||||
const mirror = new SettingsDescribeMirror(wire as never, 'memory')
|
||||
const ctx = new Context()
|
||||
ctx.provide('connection', {
|
||||
api: { settings: { describe: describeCall } },
|
||||
isLoopback: false,
|
||||
} as never)
|
||||
ctx.provide('connection', { api: wire, isLoopback: false } as never)
|
||||
let scope!: SettingsScope<UiTestSettings>
|
||||
new TestRemote(ctx)
|
||||
await ctx.plugin(SettingsScopeBinder).await()
|
||||
await ctx.plugin(SettingsScopeBinder, { mirror }).await()
|
||||
const fiber = ctx.plugin({
|
||||
inject: ['connection', 'remote', 'settingsScope'],
|
||||
apply: (plugin: Context) => {
|
||||
|
||||
Reference in New Issue
Block a user