refactor(ui-settings-models): welcome notice reads through the settings scope
This commit is contained in:
1 parent
85616ec627
commit
bb3128f266
5 files changed
+283
-264
No files matched your search
@@ -22,7 +22,7 @@ import { DeepSeekOnboardingDialog } from './DeepSeekOnboardingDialog.tsx'
|
||||
import type { DeepSeekOnboardingInjected } from './DeepSeekOnboardingDialog.tsx'
|
||||
import { WelcomeNotice } from './WelcomeNotice.tsx'
|
||||
import type { WelcomeNoticeInjected } from './WelcomeNotice.tsx'
|
||||
import { refreshWelcomeIfLoaded, WelcomeNoticeStore } from './welcome-store.ts'
|
||||
import { decodeWelcomeSection, WelcomeNoticeStore } from './welcome-store.ts'
|
||||
import { ModelsSettingsStore } from './store.ts'
|
||||
import { en, zh, type ModelsKey } from './locales.ts'
|
||||
import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../onboarding-copy.ts'
|
||||
@@ -56,7 +56,7 @@ export function refreshIfLoaded(controller: ModelsSettingsStore): void {
|
||||
* ui-settings' apply, whose activation order relative to this one is NOT
|
||||
* constrained; registration depends on each slot through `slots.inject()`.
|
||||
*/
|
||||
export const inject = ['slots', 'locale', 'connection', 'remote']
|
||||
export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsScope']
|
||||
|
||||
/**
|
||||
* Register the Models section once the `settings.section` declaration is on
|
||||
@@ -85,10 +85,12 @@ export function apply(ctx: ClientContext): void {
|
||||
api: connection.api,
|
||||
t,
|
||||
})
|
||||
const welcomeController = new WelcomeNoticeStore(
|
||||
connection.api,
|
||||
connection.isLoopback ? 'host' : 'memory',
|
||||
)
|
||||
// The scope's own memory mode is what keeps a remote browser process-local,
|
||||
// so the store needs no isLoopback branch of its own.
|
||||
const welcomeController = new WelcomeNoticeStore(ctx.settingsScope.bind({
|
||||
namespace: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
decode: decodeWelcomeSection,
|
||||
}))
|
||||
const welcomeInjected = (): WelcomeNoticeInjected => ({
|
||||
controller: welcomeController,
|
||||
hooks: { welcome: welcomeController.store },
|
||||
@@ -96,23 +98,21 @@ export function apply(ctx: ClientContext): void {
|
||||
})
|
||||
|
||||
// Pushed invalidations converge every open surface without polling: any
|
||||
// settings/credentials/topology change refetches once the page loaded.
|
||||
// settings/credentials/topology change refetches once the page loaded. The
|
||||
// welcome notice follows its settings scope, so the shared mirror already
|
||||
// keeps it fresh without a subscription here.
|
||||
ctx.effect(() => {
|
||||
const refreshModels = (): void => { refreshIfLoaded(controller) }
|
||||
const refreshAll = (): void => {
|
||||
refreshModels()
|
||||
refreshWelcomeIfLoaded(welcomeController)
|
||||
}
|
||||
const disposers = [
|
||||
ctx.remote.$on('settings/document-updated', (ns) => {
|
||||
refreshModels()
|
||||
if (ns === WELCOME_NOTICE_SETTINGS_NAMESPACE) refreshWelcomeIfLoaded(welcomeController)
|
||||
}),
|
||||
ctx.remote.$on('settings/document-updated', () => { refreshModels() }),
|
||||
ctx.remote.$on('credentials/updated', refreshModels),
|
||||
ctx.remote.$on('llm/adapters-updated', refreshModels),
|
||||
ctx.on('connection/reset', refreshAll),
|
||||
ctx.on('connection/reset', refreshModels),
|
||||
]
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
return () => {
|
||||
welcomeController.dispose()
|
||||
for (const dispose of disposers) dispose()
|
||||
}
|
||||
}, 'ui-settings-models: pushed invalidations')
|
||||
|
||||
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
/** Welcome-notice state, durable when the browser may use Host settings. */
|
||||
/**
|
||||
* Welcome-notice state derived from the welcome settings scope. The scope is
|
||||
* the transport: a loopback browser follows the durable Host section, while a
|
||||
* remote browser's memory-mode scope never answers and the acknowledgement
|
||||
* stays process-local here.
|
||||
*/
|
||||
|
||||
import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SettingsScope, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION,
|
||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_VERSION,
|
||||
} from '../onboarding-copy.ts'
|
||||
|
||||
/** State rendered by the welcome step. */
|
||||
@@ -14,113 +18,111 @@ export interface WelcomeNoticeState {
|
||||
error: string | null
|
||||
}
|
||||
|
||||
function messageOf(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
/** The welcome section as the notice reads it. */
|
||||
export type WelcomeSection = Record<string, unknown>
|
||||
|
||||
function acknowledgementOf(view: SettingsNamespaceView): string | undefined {
|
||||
if (typeof view.value !== 'object' || view.value === null) return undefined
|
||||
const value = (view.value as Record<string, unknown>)[WELCOME_NOTICE_ACK_FIELD]
|
||||
return typeof value === 'string' ? value : undefined
|
||||
/**
|
||||
* Accept any object section verbatim; a malformed durable value reads as an
|
||||
* empty section, so the notice treats it as unacknowledged instead of leaving
|
||||
* the scope stuck on its previous value.
|
||||
* @param section - the wire section value.
|
||||
* @returns the section object, or an empty one for non-object values.
|
||||
*/
|
||||
export function decodeWelcomeSection(section: unknown): WelcomeSection {
|
||||
return typeof section === 'object' && section !== null && !Array.isArray(section)
|
||||
? section as WelcomeSection
|
||||
: {}
|
||||
}
|
||||
|
||||
/** Coordinates durable Host acknowledgement or a process-local remote fallback. */
|
||||
export class WelcomeNoticeStore {
|
||||
/** uSES-safe state source shared by the registered welcome step. */
|
||||
readonly store: SnapshotStore<WelcomeNoticeState> = createSnapshotStore({
|
||||
status: 'idle', acknowledged: false, error: null,
|
||||
status: 'idle' as const, acknowledged: false, error: null,
|
||||
})
|
||||
|
||||
private generation = 0
|
||||
private localAcknowledged = false
|
||||
private saving = false
|
||||
private following: (() => void) | undefined
|
||||
|
||||
/**
|
||||
* @param api - settings wire face used for durable reads and writes.
|
||||
* @param persistence - remote browsers use memory because settings is loopback-only.
|
||||
* @param scope - the welcome settings namespace scope; its memory mode is
|
||||
* what keeps a remote browser process-local.
|
||||
*/
|
||||
constructor(
|
||||
private readonly api: Pick<IApiClient, 'settings'>,
|
||||
private readonly persistence: 'host' | 'memory' = 'host',
|
||||
) {}
|
||||
constructor(private readonly scope: SettingsScope<WelcomeSection>) {}
|
||||
|
||||
/** Load the acknowledgement from Host settings or initialize process-local state. */
|
||||
/** Begin following the bound scope (idempotent) and publish its current answer. */
|
||||
async load(): Promise<void> {
|
||||
const generation = ++this.generation
|
||||
if (this.persistence === 'memory') {
|
||||
this.store.update((state) => { state.status = 'ready'; state.error = null })
|
||||
return
|
||||
}
|
||||
this.store.update((state) => { state.status = 'loading'; state.error = null })
|
||||
try {
|
||||
const response = await this.api.settings.describe({})
|
||||
if (!response.result.ok) throw new Error(response.result.error.message)
|
||||
const view = response.result.value.namespaces.find(
|
||||
candidate => candidate.ns === WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
)
|
||||
if (view === undefined) throw new Error('welcome acknowledgement settings are unavailable')
|
||||
if (generation !== this.generation) return
|
||||
this.store.update((state) => {
|
||||
state.status = 'ready'
|
||||
state.acknowledged = acknowledgementOf(view) === WELCOME_NOTICE_VERSION
|
||||
state.error = null
|
||||
})
|
||||
} catch (error) {
|
||||
if (generation !== this.generation) return
|
||||
this.store.update((state) => {
|
||||
state.status = 'error'
|
||||
state.acknowledged = false
|
||||
state.error = messageOf(error)
|
||||
})
|
||||
}
|
||||
this.following ??= this.scope.subscribe(() => { this.derive() })
|
||||
this.derive()
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist this copy version, or advance only this process for a remote browser.
|
||||
* @returns true when the selected persistence mode accepted the acknowledgement.
|
||||
* Persist this copy version, or advance only this process for a remote
|
||||
* browser. Success is judged against the state the write left behind, so a
|
||||
* refused or failed write reports false after its recovery read settles.
|
||||
* @returns true when the selected persistence mode holds the acknowledgement.
|
||||
*/
|
||||
async acknowledge(): Promise<boolean> {
|
||||
const generation = ++this.generation
|
||||
if (this.persistence === 'memory') {
|
||||
this.store.update((state) => {
|
||||
state.status = 'ready'
|
||||
state.acknowledged = true
|
||||
state.error = null
|
||||
})
|
||||
if (this.scope.getSnapshot().mode === 'memory') {
|
||||
this.localAcknowledged = true
|
||||
this.derive()
|
||||
return true
|
||||
}
|
||||
this.saving = true
|
||||
this.store.update((state) => { state.status = 'saving'; state.error = null })
|
||||
try {
|
||||
const response = await this.api.settings.mutate({
|
||||
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
ops: [{ op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION }],
|
||||
await this.scope.set(WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_VERSION)
|
||||
} finally {
|
||||
this.saving = false
|
||||
}
|
||||
this.derive()
|
||||
const { acknowledged } = this.store.getSnapshot()
|
||||
if (!acknowledged) {
|
||||
this.store.update((state) => {
|
||||
state.status = 'error'
|
||||
state.error = 'the acknowledgement did not persist'
|
||||
})
|
||||
if (!response.result.ok) throw new Error(response.result.error.message)
|
||||
if (generation === this.generation) {
|
||||
this.store.update((state) => {
|
||||
state.status = 'ready'
|
||||
state.acknowledged = true
|
||||
state.error = null
|
||||
})
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
if (generation === this.generation) {
|
||||
}
|
||||
return acknowledged
|
||||
}
|
||||
|
||||
/** Stop following the scope. */
|
||||
dispose(): void {
|
||||
this.following?.()
|
||||
this.following = undefined
|
||||
}
|
||||
|
||||
private derive(): void {
|
||||
if (this.saving) return
|
||||
const scope = this.scope.getSnapshot()
|
||||
if (scope.mode === 'memory') {
|
||||
this.store.update((state) => {
|
||||
state.status = 'ready'
|
||||
state.acknowledged = this.localAcknowledged
|
||||
state.error = null
|
||||
})
|
||||
return
|
||||
}
|
||||
switch (scope.status) {
|
||||
case 'loading':
|
||||
this.store.update((state) => { state.status = 'loading'; state.error = null })
|
||||
return
|
||||
case 'unavailable':
|
||||
this.store.update((state) => {
|
||||
state.status = 'error'
|
||||
state.acknowledged = false
|
||||
state.error = messageOf(error)
|
||||
state.error = 'welcome acknowledgement settings are unavailable'
|
||||
})
|
||||
return
|
||||
case 'ready': {
|
||||
const acknowledged = scope.value?.[WELCOME_NOTICE_ACK_FIELD] === WELCOME_NOTICE_VERSION
|
||||
this.store.update((state) => {
|
||||
state.status = 'ready'
|
||||
state.acknowledged = acknowledged
|
||||
state.error = null
|
||||
})
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh only after welcome state has left idle. A memory-mode load retains
|
||||
* acknowledgement so reconnect does not reopen a process-local notice.
|
||||
* @param controller - welcome state owner whose current status decides whether to load.
|
||||
*/
|
||||
export function refreshWelcomeIfLoaded(controller: WelcomeNoticeStore): void {
|
||||
if (controller.store.getSnapshot().status === 'idle') return
|
||||
void controller.load()
|
||||
}
|
||||
@@ -5,7 +5,11 @@ import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { TestRemote, usePinnedBrowserLanguages } 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, refreshIfLoaded } from '@deepseek-ai/dsh-client-ui-settings-models/client'
|
||||
import {
|
||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION,
|
||||
} from '../src/onboarding-copy.ts'
|
||||
import { ModelsSection } from '../src/client/ModelsSection.tsx'
|
||||
import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx'
|
||||
import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx'
|
||||
@@ -14,7 +18,7 @@ import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx'
|
||||
// the shipped Chinese copy, so they state the browser they assume.
|
||||
usePinnedBrowserLanguages('zh-CN')
|
||||
|
||||
async function bench(isLoopback = true) {
|
||||
async function bench(isLoopback = true, settings?: object) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotRegistry).await()
|
||||
const locale = new LocaleRuntime(ctx)
|
||||
@@ -22,9 +26,10 @@ async function bench(isLoopback = true) {
|
||||
// The plugins inject `remote`; forwarded events reach them through the
|
||||
// same `$dispatch` handoff the connection sink makes.
|
||||
new TestRemote(ctx)
|
||||
// The apply path only captures the wire face; no call leaves this fake
|
||||
// until a section actually loads.
|
||||
ctx.provide('connection', { api: {}, isLoopback } as never)
|
||||
// Without a settings face the mirror's reads fail and stay contained; the
|
||||
// Models join itself never fetches until a section actually loads.
|
||||
ctx.provide('connection', { api: settings === undefined ? {} : { settings }, isLoopback } as never)
|
||||
await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
|
||||
return { ctx, slots: ctx.get('slots') as SlotRegistry, locale }
|
||||
}
|
||||
|
||||
@@ -43,7 +48,7 @@ function declare(slots: SlotRegistry): () => void {
|
||||
|
||||
describe('ui-settings-models apply', () => {
|
||||
it('declares the services it uses', () => {
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote'])
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'settingsScope'])
|
||||
})
|
||||
|
||||
it('registers the models nav entry for declarations before or after apply', async () => {
|
||||
@@ -204,8 +209,31 @@ describe('pushed invalidations', () => {
|
||||
expect(load).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('routes only the onboarding namespace invalidation into welcome state', async () => {
|
||||
const b = await bench()
|
||||
it('welcome state follows the shared mirror across document commits', async () => {
|
||||
// The welcome notice derives from its settings scope: a document commit
|
||||
// reaches it through the mirror's one refresh, with no routing here.
|
||||
const acknowledgement = { current: undefined as string | undefined }
|
||||
const settings = {
|
||||
describe: vi.fn(() => Promise.resolve({
|
||||
rpcId: 'apply-welcome' as never,
|
||||
result: {
|
||||
ok: true as const,
|
||||
value: {
|
||||
writable: true,
|
||||
hasDocument: false,
|
||||
namespaces: [{
|
||||
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
schema: {},
|
||||
value: acknowledgement.current === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: acknowledgement.current },
|
||||
applies: 'live' as const,
|
||||
secrets: [],
|
||||
revision: 0,
|
||||
}],
|
||||
},
|
||||
},
|
||||
})),
|
||||
}
|
||||
const b = await bench(true, settings)
|
||||
declare(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const entry = b.slots.entries('settings.onboarding')
|
||||
@@ -214,14 +242,14 @@ describe('pushed invalidations', () => {
|
||||
entry.inject as unknown as
|
||||
() => import('../src/client/WelcomeNotice.tsx').WelcomeNoticeInjected
|
||||
)()
|
||||
injected.hooks.welcome.update((state) => { state.status = 'ready' })
|
||||
const load = vi.spyOn(injected.controller, 'load').mockResolvedValue()
|
||||
|
||||
b.ctx.remote.$dispatch('settings/document-updated', ['llm-deepseek', 1])
|
||||
expect(load).not.toHaveBeenCalled()
|
||||
b.ctx.remote.$dispatch('settings/document-updated', ['ui-onboarding', 2])
|
||||
expect(load).toHaveBeenCalledOnce()
|
||||
b.ctx.emit('connection/reset')
|
||||
expect(load).toHaveBeenCalledTimes(2)
|
||||
await injected.controller.load()
|
||||
await vi.waitFor(() => {
|
||||
expect(injected.hooks.welcome.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: false })
|
||||
})
|
||||
acknowledgement.current = WELCOME_NOTICE_VERSION
|
||||
b.ctx.remote.$dispatch('settings/document-updated', ['ui-onboarding', 1])
|
||||
await vi.waitFor(() => {
|
||||
expect(injected.hooks.welcome.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,9 +2,13 @@
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import {
|
||||
SettingsDescribeMirror, SettingsScopeController,
|
||||
} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx'
|
||||
import type { WelcomeNoticeProps } from '../src/client/WelcomeNotice.tsx'
|
||||
import { WelcomeNoticeStore } from '../src/client/welcome-store.ts'
|
||||
import { decodeWelcomeSection, WelcomeNoticeStore } from '../src/client/welcome-store.ts'
|
||||
import type { WelcomeSection } from '../src/client/welcome-store.ts'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
import {
|
||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_COPY, WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
@@ -20,7 +24,24 @@ function response<T>(value: T) {
|
||||
return { rpcId: 'welcome-rpc' as never, result: { ok: true as const, value } }
|
||||
}
|
||||
|
||||
function mount(version?: string, mutateImpl: () => Promise<unknown> = () => Promise.resolve(response({}))) {
|
||||
function welcomeView(value: unknown, revision = 0) {
|
||||
return {
|
||||
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
schema: {},
|
||||
value,
|
||||
base: {},
|
||||
user: {},
|
||||
applies: 'live' as const,
|
||||
secrets: [],
|
||||
revision,
|
||||
}
|
||||
}
|
||||
|
||||
function mount(
|
||||
version?: string,
|
||||
mutateImpl: () => Promise<unknown> = () =>
|
||||
Promise.resolve(response(welcomeView({ [WELCOME_NOTICE_ACK_FIELD]: WELCOME_NOTICE_VERSION }, 1))),
|
||||
) {
|
||||
const appRoot = document.createElement('div')
|
||||
appRoot.id = 'root'
|
||||
document.body.append(appRoot)
|
||||
@@ -30,21 +51,19 @@ function mount(version?: string, mutateImpl: () => Promise<unknown> = () => Prom
|
||||
describe: () => Promise.resolve(response({
|
||||
writable: true,
|
||||
hasDocument: false,
|
||||
namespaces: [{
|
||||
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
schema: {},
|
||||
value: version === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: version },
|
||||
base: {},
|
||||
user: {},
|
||||
applies: 'live' as const,
|
||||
secrets: [],
|
||||
revision: 0,
|
||||
}],
|
||||
namespaces: [welcomeView(version === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: version })],
|
||||
})),
|
||||
mutate,
|
||||
},
|
||||
}
|
||||
const controller = new WelcomeNoticeStore(api as never)
|
||||
const mirror = new SettingsDescribeMirror(api as never)
|
||||
const scope = new SettingsScopeController<WelcomeSection>(
|
||||
api as never,
|
||||
{ namespace: WELCOME_NOTICE_SETTINGS_NAMESPACE, decode: decodeWelcomeSection },
|
||||
mirror,
|
||||
)
|
||||
const controller = new WelcomeNoticeStore(scope)
|
||||
void mirror.load()
|
||||
const complete = vi.fn()
|
||||
const unusedHook = (() => { throw new Error('unused standard hook') }) as never
|
||||
const props: WelcomeNoticeProps = {
|
||||
@@ -57,7 +76,7 @@ function mount(version?: string, mutateImpl: () => Promise<unknown> = () => Prom
|
||||
useWelcome: bindSnapshotSelector(controller.store),
|
||||
t: key => zh[key],
|
||||
}
|
||||
return { ...render(<WelcomeNotice {...props} />), complete, controller, mutate, appRoot }
|
||||
return { ...render(<WelcomeNotice {...props} />), complete, controller, mirror, mutate, appRoot }
|
||||
}
|
||||
|
||||
describe('WelcomeNotice', () => {
|
||||
@@ -100,7 +119,10 @@ describe('WelcomeNotice', () => {
|
||||
|
||||
it('skips itself when this exact version was already acknowledged', async () => {
|
||||
const h = mount(WELCOME_NOTICE_VERSION)
|
||||
await act(async () => { await h.controller.load() })
|
||||
await act(async () => {
|
||||
await h.mirror.load()
|
||||
await h.controller.load()
|
||||
})
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(h.complete).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcResponse } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { refreshWelcomeIfLoaded, WelcomeNoticeStore } from '../src/client/welcome-store.ts'
|
||||
import {
|
||||
SettingsDescribeMirror, SettingsScopeController,
|
||||
} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import { decodeWelcomeSection, WelcomeNoticeStore } from '../src/client/welcome-store.ts'
|
||||
import {
|
||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION,
|
||||
} from '../src/onboarding-copy.ts'
|
||||
@@ -10,31 +13,42 @@ function ok<T>(value: T): RpcResponse<T> {
|
||||
return { rpcId: `welcome-${rpc++}` as never, result: { ok: true, value } }
|
||||
}
|
||||
|
||||
function namespace(version?: string) {
|
||||
function namespace(value: unknown = {}, revision = 0) {
|
||||
return {
|
||||
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
schema: {},
|
||||
value: version === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: version },
|
||||
base: {},
|
||||
user: {},
|
||||
value,
|
||||
applies: 'live' as const,
|
||||
secrets: [],
|
||||
revision: 0,
|
||||
revision,
|
||||
}
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
|
||||
return { promise, resolve, reject }
|
||||
function acknowledgedNamespace(version: string, revision = 1) {
|
||||
return namespace({ [WELCOME_NOTICE_ACK_FIELD]: version }, revision)
|
||||
}
|
||||
|
||||
/** The welcome store over a real mirror-derived scope and a fake wire. */
|
||||
function buildWelcome(
|
||||
api: { describe?: ReturnType<typeof vi.fn>; mutate?: ReturnType<typeof vi.fn> },
|
||||
persistence: 'host' | 'memory' = 'host',
|
||||
) {
|
||||
const wire = { settings: api } as never
|
||||
const mirror = new SettingsDescribeMirror(wire, persistence)
|
||||
const scope = new SettingsScopeController(
|
||||
wire,
|
||||
{ namespace: WELCOME_NOTICE_SETTINGS_NAMESPACE, decode: decodeWelcomeSection },
|
||||
mirror,
|
||||
persistence,
|
||||
)
|
||||
return { mirror, controller: new WelcomeNoticeStore(scope) }
|
||||
}
|
||||
|
||||
describe('WelcomeNoticeStore', () => {
|
||||
it('acknowledges in memory without calling loopback-only settings APIs', async () => {
|
||||
const describe = vi.fn()
|
||||
const describeCall = vi.fn()
|
||||
const mutate = vi.fn()
|
||||
const controller = new WelcomeNoticeStore({ settings: { describe, mutate } } as never, 'memory')
|
||||
const { controller } = buildWelcome({ describe: describeCall, mutate }, 'memory')
|
||||
|
||||
await controller.load()
|
||||
expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: false, error: null })
|
||||
@@ -42,7 +56,7 @@ describe('WelcomeNoticeStore', () => {
|
||||
expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: true, error: null })
|
||||
await controller.load()
|
||||
expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: true, error: null })
|
||||
expect(describe).not.toHaveBeenCalled()
|
||||
expect(describeCall).not.toHaveBeenCalled()
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -52,148 +66,101 @@ describe('WelcomeNoticeStore', () => {
|
||||
['older-copy', false],
|
||||
[WELCOME_NOTICE_VERSION, true],
|
||||
] as const) {
|
||||
const api = {
|
||||
settings: {
|
||||
describe: vi.fn(() => Promise.resolve(ok({
|
||||
writable: true, hasDocument: false, namespaces: [namespace(version)],
|
||||
}))),
|
||||
},
|
||||
}
|
||||
const controller = new WelcomeNoticeStore(api as never)
|
||||
const describeCall = vi.fn(() => Promise.resolve(ok({
|
||||
writable: true,
|
||||
hasDocument: false,
|
||||
namespaces: [version === undefined ? namespace() : acknowledgedNamespace(version)],
|
||||
})))
|
||||
const { mirror, controller } = buildWelcome({ describe: describeCall })
|
||||
await mirror.load()
|
||||
await controller.load()
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged })
|
||||
}
|
||||
})
|
||||
|
||||
it('persists the owner version through one idempotent path mutation', async () => {
|
||||
const mutate = vi.fn(() => Promise.resolve(ok(namespace(WELCOME_NOTICE_VERSION))))
|
||||
const controller = new WelcomeNoticeStore({ settings: { mutate } } as never)
|
||||
it('persists the owner version through one revision-fenced mutation', async () => {
|
||||
const describeCall = vi.fn(() => Promise.resolve(ok({
|
||||
writable: true, hasDocument: false, namespaces: [namespace({}, 3)],
|
||||
})))
|
||||
const mutate = vi.fn(() => Promise.resolve(ok(acknowledgedNamespace(WELCOME_NOTICE_VERSION, 4))))
|
||||
const { mirror, controller } = buildWelcome({ describe: describeCall, mutate })
|
||||
await mirror.load()
|
||||
await controller.load()
|
||||
await expect(controller.acknowledge()).resolves.toBe(true)
|
||||
expect(mutate).toHaveBeenCalledWith({
|
||||
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
ops: [{ op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION }],
|
||||
expectedRevision: 3,
|
||||
})
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true })
|
||||
// The write answer folded into the mirror; no re-read followed.
|
||||
expect(describeCall).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps the notice pending when loading or persistence fails', async () => {
|
||||
const load = new WelcomeNoticeStore({
|
||||
settings: { describe: () => Promise.reject(new Error('offline')) },
|
||||
} as never)
|
||||
await load.load()
|
||||
expect(load.store.getSnapshot()).toEqual({ status: 'error', acknowledged: false, error: 'offline' })
|
||||
|
||||
const save = new WelcomeNoticeStore({
|
||||
settings: { mutate: () => Promise.reject(new Error('disk full')) },
|
||||
} as never)
|
||||
await expect(save.acknowledge()).resolves.toBe(false)
|
||||
expect(save.store.getSnapshot()).toEqual({ status: 'error', acknowledged: false, error: 'disk full' })
|
||||
|
||||
const nonError = new WelcomeNoticeStore({
|
||||
// Durable/wire failures are unknown; exercise containment of a non-Error rejection.
|
||||
settings: { describe: () => Promise.reject(new Error('offline string')) },
|
||||
} as never)
|
||||
await nonError.load()
|
||||
expect(nonError.store.getSnapshot().error).toBe('offline string')
|
||||
it('keeps the notice pending while the settings read has not answered', async () => {
|
||||
const describeCall = vi.fn(() => Promise.reject(new Error('offline')))
|
||||
const { mirror, controller } = buildWelcome({ describe: describeCall })
|
||||
await mirror.load()
|
||||
await controller.load()
|
||||
// No answer stands, so the step renders nothing and never acknowledges.
|
||||
expect(controller.store.getSnapshot()).toEqual({ status: 'loading', acknowledged: false, error: null })
|
||||
})
|
||||
|
||||
it('reports business failures, missing namespaces, and malformed durable values', async () => {
|
||||
for (const describe of [
|
||||
() => Promise.resolve({
|
||||
rpcId: 'failed' as never,
|
||||
result: { ok: false as const, error: { code: 'internal' as const, message: 'denied', details: {} } },
|
||||
}),
|
||||
() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] })),
|
||||
]) {
|
||||
const controller = new WelcomeNoticeStore({ settings: { describe } } as never)
|
||||
await controller.load()
|
||||
expect(controller.store.getSnapshot().status).toBe('error')
|
||||
}
|
||||
it('reports a failed or refused persistence attempt after its recovery read', async () => {
|
||||
const describeCall = vi.fn(() => Promise.resolve(ok({
|
||||
writable: true, hasDocument: false, namespaces: [namespace()],
|
||||
})))
|
||||
const mutate = vi.fn(() => Promise.reject(new Error('disk full')))
|
||||
const { mirror, controller } = buildWelcome({ describe: describeCall, mutate })
|
||||
await mirror.load()
|
||||
await controller.load()
|
||||
await expect(controller.acknowledge()).resolves.toBe(false)
|
||||
expect(controller.store.getSnapshot()).toMatchObject({
|
||||
status: 'error',
|
||||
acknowledged: false,
|
||||
error: 'the acknowledgement did not persist',
|
||||
})
|
||||
// The failed latest write triggered one mirror recovery read.
|
||||
expect(describeCall).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('reports a missing namespace as an error instead of a silent skip', async () => {
|
||||
const describeCall = vi.fn(() => Promise.resolve(ok({
|
||||
writable: true, hasDocument: false, namespaces: [],
|
||||
})))
|
||||
const { mirror, controller } = buildWelcome({ describe: describeCall })
|
||||
await mirror.load()
|
||||
await controller.load()
|
||||
expect(controller.store.getSnapshot()).toMatchObject({
|
||||
status: 'error',
|
||||
error: 'welcome acknowledgement settings are unavailable',
|
||||
})
|
||||
})
|
||||
|
||||
it('reads malformed durable values as unacknowledged', async () => {
|
||||
for (const value of [null, 42, { [WELCOME_NOTICE_ACK_FIELD]: 42 }]) {
|
||||
const controller = new WelcomeNoticeStore({
|
||||
settings: { describe: () => Promise.resolve(ok({
|
||||
writable: true,
|
||||
hasDocument: false,
|
||||
namespaces: [{ ...namespace(), value }],
|
||||
})) },
|
||||
} as never)
|
||||
const describeCall = vi.fn(() => Promise.resolve(ok({
|
||||
writable: true, hasDocument: false, namespaces: [namespace(value)],
|
||||
})))
|
||||
const { mirror, controller } = buildWelcome({ describe: describeCall })
|
||||
await mirror.load()
|
||||
await controller.load()
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: false })
|
||||
}
|
||||
|
||||
const save = new WelcomeNoticeStore({
|
||||
settings: { mutate: () => Promise.resolve({
|
||||
rpcId: 'failed-save' as never,
|
||||
result: {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'settings-rejected',
|
||||
message: 'denied',
|
||||
details: { ns: WELCOME_NOTICE_SETTINGS_NAMESPACE },
|
||||
},
|
||||
},
|
||||
}) },
|
||||
} as never)
|
||||
await expect(save.acknowledge()).resolves.toBe(false)
|
||||
expect(save.store.getSnapshot().error).toBe('denied')
|
||||
})
|
||||
|
||||
it('lets the latest load win over stale success and failure', async () => {
|
||||
const first = deferred<ReturnType<typeof ok>>()
|
||||
const describe = vi.fn()
|
||||
.mockImplementationOnce(() => first.promise)
|
||||
.mockImplementationOnce(() => Promise.resolve(ok({
|
||||
writable: true, hasDocument: false, namespaces: [namespace()],
|
||||
})))
|
||||
const controller = new WelcomeNoticeStore({ settings: { describe } } as never)
|
||||
const stale = controller.load()
|
||||
it('follows a later document change without an own read', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(ok({ writable: true, hasDocument: false, namespaces: [namespace()] }))
|
||||
.mockResolvedValueOnce(ok({
|
||||
writable: true, hasDocument: false,
|
||||
namespaces: [acknowledgedNamespace(WELCOME_NOTICE_VERSION)],
|
||||
}))
|
||||
const { mirror, controller } = buildWelcome({ describe: describeCall })
|
||||
await mirror.load()
|
||||
await controller.load()
|
||||
first.resolve(ok({
|
||||
writable: true, hasDocument: false, namespaces: [namespace(WELCOME_NOTICE_VERSION)],
|
||||
}))
|
||||
await stale
|
||||
expect(controller.store.getSnapshot().acknowledged).toBe(false)
|
||||
|
||||
const failed = deferred<ReturnType<typeof ok>>()
|
||||
describe
|
||||
.mockImplementationOnce(() => failed.promise)
|
||||
.mockImplementationOnce(() => Promise.resolve(ok({
|
||||
writable: true, hasDocument: false, namespaces: [namespace(WELCOME_NOTICE_VERSION)],
|
||||
})))
|
||||
const staleFailure = controller.load()
|
||||
await controller.load()
|
||||
failed.reject('stale failure')
|
||||
await staleFailure
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true, error: null })
|
||||
})
|
||||
|
||||
it('contains stale acknowledgement settlements and refreshes only a loaded store', async () => {
|
||||
const write = deferred<ReturnType<typeof ok>>()
|
||||
const describe = vi.fn(() => Promise.resolve(ok({
|
||||
writable: true, hasDocument: false, namespaces: [namespace()],
|
||||
})))
|
||||
const controller = new WelcomeNoticeStore({
|
||||
settings: { mutate: () => write.promise, describe },
|
||||
} as never)
|
||||
refreshWelcomeIfLoaded(controller)
|
||||
expect(describe).not.toHaveBeenCalled()
|
||||
const staleWrite = controller.acknowledge()
|
||||
await controller.load()
|
||||
write.resolve(ok(namespace(WELCOME_NOTICE_VERSION)))
|
||||
await expect(staleWrite).resolves.toBe(true)
|
||||
expect(controller.store.getSnapshot().acknowledged).toBe(false)
|
||||
refreshWelcomeIfLoaded(controller)
|
||||
await vi.waitFor(() => { expect(describe).toHaveBeenCalledTimes(2) })
|
||||
|
||||
const failedWrite = deferred<ReturnType<typeof ok>>()
|
||||
const staleFailure = new WelcomeNoticeStore({
|
||||
settings: { mutate: () => failedWrite.promise, describe },
|
||||
} as never)
|
||||
const pending = staleFailure.acknowledge()
|
||||
await staleFailure.load()
|
||||
failedWrite.reject('late failure')
|
||||
await expect(pending).resolves.toBe(false)
|
||||
expect(staleFailure.store.getSnapshot().status).toBe('ready')
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ acknowledged: false })
|
||||
await mirror.load()
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true })
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user