feat(web): unify onboarding dialogs

This commit is contained in:
ZiyaZhang
2026-08-13 01:19:33 -07:00
parent fb0f0ba799
commit 9ee5aef98c
54 changed files with 1388 additions and 342 deletions

View File

@@ -8,6 +8,7 @@ import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-t
import { apply, inject, refreshIfLoaded } from '@deepseek-ai/dsh-client-ui-settings-models/client'
import { ModelsSection } from '../src/client/ModelsSection.tsx'
import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx'
import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx'
// The service reads its initial locale from the browser; these specs assert
// the shipped Chinese copy, so they state the browser they assume.
@@ -23,7 +24,7 @@ async function bench() {
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: {} } as never)
ctx.provide('connection', { api: {}, isLoopback: true } as never)
return { ctx, slots: ctx.get('slots') as SlotRegistry, locale }
}
@@ -60,9 +61,20 @@ describe('ui-settings-models apply', () => {
expect(typeof injected.controller.load).toBe('function')
expect(typeof injected.useSnapshot).toBe('function')
expect(injected.api).toBeDefined()
const onboarding = before.slots.entries('settings.onboarding')[0]!
expect(onboarding.component).toBe(DeepSeekOnboardingDialog)
expect(onboarding.options).toMatchObject({ id: 'deepseek-official', order: 0 })
const onboarding = before.slots.entries('settings.onboarding')
expect(onboarding).toHaveLength(2)
expect(onboarding.find(entry => entry.options.id === 'welcome-notice')).toMatchObject({
component: WelcomeNotice,
options: { id: 'welcome-notice', order: -100 },
})
const deepSeek = onboarding.find(entry => entry.options.id === 'deepseek-official')!
expect(deepSeek.component).toBe(DeepSeekOnboardingDialog)
expect(deepSeek.options).toMatchObject({ id: 'deepseek-official', order: 0 })
const deepSeekInjected = (
deepSeek.inject as unknown as () => import('../src/client/DeepSeekOnboardingDialog.tsx').DeepSeekOnboardingInjected
)()
expect(deepSeekInjected.hooks.models).toBe(injected.controller.store)
expect(deepSeekInjected.api).toBeDefined()
const after = await bench()
await after.ctx.plugin({ inject: [...inject], apply }).await()
@@ -71,7 +83,7 @@ describe('ui-settings-models apply', () => {
declare(after.slots)
await Promise.resolve()
expect(after.slots.entries('settings.section')[0]!.component).toBe(ModelsSection)
expect(after.slots.entries('settings.onboarding')[0]!.component).toBe(DeepSeekOnboardingDialog)
expect(after.slots.entries('settings.onboarding')).toHaveLength(2)
// The self-inflicted ledger notifications hit the duplicate guard.
expect(after.slots.entries('settings.section')).toHaveLength(1)
})
@@ -110,7 +122,7 @@ describe('ui-settings-models apply', () => {
declare(b.slots)
await Promise.resolve()
expect(b.slots.entries('settings.section')[0]!.component).toBe(ModelsSection)
expect(b.slots.entries('settings.onboarding')[0]!.component).toBe(DeepSeekOnboardingDialog)
expect(b.slots.entries('settings.onboarding')).toHaveLength(2)
// The locale path also recovers through the same ledger re-check.
b.locale.setLocale('en')
expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('Models')
@@ -164,8 +176,10 @@ describe('pushed invalidations', () => {
const b = await bench()
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const entry = b.slots.entries('settings.onboarding')
.find(candidate => candidate.options.id === 'deepseek-official')!
const injected = (
b.slots.entries('settings.onboarding')[0]!.inject as unknown as
entry.inject as unknown as
() => import('../src/client/DeepSeekOnboardingDialog.tsx').DeepSeekOnboardingInjected
)()
injected.controller.store.update((state) => { state.status = 'ready' })
@@ -173,4 +187,25 @@ describe('pushed invalidations', () => {
b.ctx.remote.$dispatch('credentials/updated', ['DEEPSEEK_API_KEY'])
expect(load).toHaveBeenCalledTimes(1)
})
it('routes only the onboarding namespace invalidation into welcome state', async () => {
const b = await bench()
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const entry = b.slots.entries('settings.onboarding')
.find(candidate => candidate.options.id === 'welcome-notice')!
const injected = (
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)
})
})

View File

@@ -355,6 +355,65 @@ describe('ModelsSection', () => {
expect(screen.queryByRole('status')).toBeNull()
})
it('reuses the provider editor as a required credential-only onboarding form', async () => {
let finishSet: ((response: RpcResponse<Record<string, never>>) => void) | undefined
const set = vi.fn(() => new Promise<RpcResponse<Record<string, never>>>((resolve) => {
finishSet = resolve
}))
const { face, mutate } = scriptedFace({ set })
const onClose = vi.fn()
const { ProviderEditor } = await import('../src/client/ProviderEditor.tsx')
render(<ProviderEditor
provider="deepseek-official"
displayName="DeepSeek"
hideTitle
namespace={wireNamespaces()[0]!}
settingsPath={[]}
api={face as never}
t={t}
readOnly={false}
credentialOnly
credentialRequired
autoFocusCredential
cancelLabel="onboardingLater"
submitLabel="onboardingSave"
submitBusyLabel="onboardingSaving"
onClose={onClose}
/>)
const key = screen.getByLabelText<HTMLInputElement>(en.keyInput)
const save = screen.getByText<HTMLButtonElement>(en.onboardingSave)
expect(document.activeElement).toBe(key)
expect(key.required).toBe(true)
expect(save.disabled).toBe(true)
expect(screen.getByText(en.onboardingLater)).toBeTruthy()
expect(screen.queryByText(en.customized)).toBeNull()
expect(screen.queryByLabelText(en.baseUrl)).toBeNull()
fireEvent.change(key, { target: { value: ' ' } })
expect(screen.getByText(en.keyRequired)).toBeTruthy()
expect(key.getAttribute('aria-invalid')).toBe('true')
expect(save.disabled).toBe(true)
fireEvent.change(key, { target: { value: ' sk-onboarding ' } })
expect(screen.queryByText(en.keyRequired)).toBeNull()
expect(save.disabled).toBe(false)
fireEvent.click(save)
expect(await screen.findByText(en.onboardingSaving)).toBeTruthy()
expect(set).toHaveBeenCalledWith({ ref: 'DEEPSEEK_API_KEY', value: 'sk-onboarding' })
expect(mutate).not.toHaveBeenCalled()
expect(onClose).not.toHaveBeenCalled()
if (finishSet === undefined) throw new Error('credential write did not start')
await act(async () => {
finishSet?.(ok({}))
await Promise.resolve()
})
expect(onClose).toHaveBeenCalledWith(true)
})
it('applies customized deepseek fields as path ops', async () => {
const { mutate } = await mountDeepSeekCard({
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),

View File

@@ -2,14 +2,18 @@
/** First-run DeepSeek prompt behavior over the shared Models join. */
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { RpcResponse } from '@deepseek-ai/dsh-api-remotes/client'
import Schema from '@deepseek-ai/schemastery'
import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx'
import type { DeepSeekOnboardingDialogProps } from '../src/client/DeepSeekOnboardingDialog.tsx'
import { ModelsSettingsStore } from '../src/client/store.ts'
import { en } from '../src/client/locales.ts'
afterEach(cleanup)
afterEach(() => {
cleanup()
document.getElementById('root')?.remove()
})
let nextRpc = 0
function ok<T>(value: T): RpcResponse<T> {
@@ -22,6 +26,33 @@ function fail<T>(message: string): RpcResponse<T> {
}
}
const DeepSeekConfig = Schema.object({
apiKeyEnv: Schema.string().role('credential-ref'),
baseURL: Schema.string().pattern(/^https:\/\//),
reasoningEffort: Schema.union(['off', 'high', 'max']),
defaultContextWindow: Schema.number().step(1).min(1),
models: Schema.array(Schema.object({
id: Schema.string().required(),
name: Schema.string(),
description: Schema.string(),
contextWindow: Schema.number().step(1).min(1),
})),
})
function deepSeekNamespace(apiKeyEnv: string | null): SettingsNamespaceView {
const value = apiKeyEnv === null ? {} : { apiKeyEnv }
return {
ns: 'llm-deepseek',
schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as unknown,
value,
base: value,
user: {},
applies: 'live',
secrets: [],
revision: 0,
}
}
function harness(options: {
provider?: boolean
providerSettingsNs?: string
@@ -33,9 +64,24 @@ function harness(options: {
describeFailure?: string
settingsWritable?: boolean
providersReject?: boolean
setFailure?: string
setReject?: string
} = {}) {
if (document.getElementById('root') === null) {
const appRoot = document.createElement('div')
appRoot.id = 'root'
document.body.append(appRoot)
}
let fileConfigured = false
const configured = options.configured ?? (() => fileConfigured)
const apiKeyEnv = options.apiKeyEnv === undefined ? 'DEEPSEEK_API_KEY' : options.apiKeyEnv
const mutate = vi.fn(() => Promise.resolve(ok(deepSeekNamespace(apiKeyEnv))))
const set = vi.fn((_payload: { ref: string; value: string }) => {
if (options.setReject !== undefined) return Promise.reject(new Error(options.setReject))
if (options.setFailure !== undefined) return Promise.resolve(fail(options.setFailure))
fileConfigured = true
return Promise.resolve(ok({}))
})
const face = {
llm: {
providers: () => {
@@ -56,19 +102,10 @@ function harness(options: {
settings: {
describe: () => Promise.resolve(ok({
writable: options.settingsWritable ?? true,
namespaces: options.settingsNamespace === false
? []
: [{
ns: 'llm-deepseek',
schema: {},
value: options.apiKeyEnv === null
? {}
: { apiKeyEnv: options.apiKeyEnv ?? 'DEEPSEEK_API_KEY' },
applies: 'live' as const,
secrets: [],
revision: 0,
}],
hasDocument: false,
namespaces: options.settingsNamespace === false ? [] : [deepSeekNamespace(apiKeyEnv)],
})),
mutate,
},
credentials: {
describe: () => options.describeFailure === undefined
@@ -84,6 +121,7 @@ function harness(options: {
},
}))
: Promise.resolve(fail(options.describeFailure)),
set,
},
}
const controller = new ModelsSettingsStore(face as never)
@@ -97,40 +135,100 @@ function harness(options: {
useSessions: unusedHook,
useWorkspaces: unusedHook,
controller,
useSnapshot: bindSnapshotSelector(controller.store),
useModels: bindSnapshotSelector(controller.store),
api: face as never,
t: key => en[key],
}
return { controller, complete, openSection, props, configure: () => { fileConfigured = true } }
return {
controller, complete, openSection, props, mutate, set,
configure: () => { fileConfigured = true },
}
}
describe('DeepSeekOnboardingDialog', () => {
it('loads on first entry and presents one accessible route to Models', async () => {
it('loads a credential-only modal, inerts the product, and focuses the key', async () => {
const h = harness()
render(<DeepSeekOnboardingDialog {...h.props} />)
expect(await screen.findByRole('region', { name: en.onboardingTitle })).toBeTruthy()
expect(await screen.findByRole('dialog', { name: en.onboardingTitle })).toBeTruthy()
expect(document.getElementById('root')?.inert).toBe(true)
expect(screen.getByText(en.onboardingDescription)).toBeTruthy()
const action = screen.getByRole('button', { name: en.onboardingGoToSettings })
expect(action).toBeTruthy()
expect(document.activeElement).toBe(screen.getByRole('heading', { name: en.onboardingTitle }))
expect(screen.queryByRole('textbox')).toBeNull()
const key = screen.getByLabelText<HTMLInputElement>(en.keyInput)
await waitFor(() => { expect(document.activeElement).toBe(key) })
expect(screen.queryByText(en.customized)).toBeNull()
})
it('opens the Models section and dismisses the prompt', async () => {
it('cannot be dismissed implicitly and restores the previous inert state', async () => {
const h = harness()
const appRoot = document.getElementById('root')!
appRoot.inert = true
const view = render(<DeepSeekOnboardingDialog {...h.props} />)
await screen.findByRole('dialog')
fireEvent.keyDown(document, { key: 'Escape' })
fireEvent.click(document.querySelector('[class*="mask"]')!)
expect(screen.getByRole('dialog')).toBeTruthy()
expect(h.complete).not.toHaveBeenCalled()
view.unmount()
expect(appRoot.inert).toBe(true)
})
it('requires a non-blank key before Save and continue is available', async () => {
const h = harness()
render(<DeepSeekOnboardingDialog {...h.props} />)
await screen.findByRole('region')
fireEvent.click(screen.getByRole('button', { name: en.onboardingGoToSettings }))
expect(h.complete).toHaveBeenCalledOnce()
expect(h.openSection).toHaveBeenCalledWith('models')
await screen.findByRole('dialog')
const save = screen.getByRole<HTMLButtonElement>('button', { name: en.onboardingSave })
expect(save.disabled).toBe(true)
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: ' ' } })
expect(save.disabled).toBe(true)
expect(screen.getByText(en.keyRequired)).toBeTruthy()
expect(h.set).not.toHaveBeenCalled()
})
it('stores only the official credential, refreshes, and completes without opening Settings', async () => {
const h = harness()
render(<DeepSeekOnboardingDialog {...h.props} />)
await screen.findByRole('dialog')
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: ' sk-live ' } })
fireEvent.click(screen.getByRole('button', { name: en.onboardingSave }))
await waitFor(() => {
expect(h.set).toHaveBeenCalledWith({ ref: 'DEEPSEEK_API_KEY', value: 'sk-live' })
})
expect(h.mutate).not.toHaveBeenCalled()
expect(h.openSection).not.toHaveBeenCalled()
await waitFor(() => { expect(h.complete).toHaveBeenCalledOnce() })
expect(screen.queryByRole('dialog')).toBeNull()
expect(document.getElementById('root')?.inert).toBe(false)
})
it('keeps the modal open and reports rejected and failed credential writes', async () => {
for (const [options, message] of [
[{ setFailure: 'credential was rejected' }, 'credential was rejected'],
[{ setReject: 'connection lost' }, 'connection lost'],
] as const) {
const h = harness(options)
const view = render(<DeepSeekOnboardingDialog {...h.props} />)
await screen.findByRole('dialog')
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-live' } })
fireEvent.click(screen.getByRole('button', { name: en.onboardingSave }))
expect(await screen.findByText(message)).toBeTruthy()
expect(screen.getByRole('dialog')).toBeTruthy()
expect(screen.getByRole<HTMLButtonElement>('button', { name: en.onboardingSave }).disabled).toBe(false)
expect(h.complete).not.toHaveBeenCalled()
expect(h.mutate).not.toHaveBeenCalled()
view.unmount()
}
})
it('allows configure-later dismissal without opening settings', async () => {
const h = harness()
render(<DeepSeekOnboardingDialog {...h.props} />)
await screen.findByRole('region')
await screen.findByRole('dialog')
fireEvent.click(screen.getByRole('button', { name: en.onboardingLater }))
expect(h.complete).toHaveBeenCalledOnce()
expect(h.openSection).not.toHaveBeenCalled()
expect(h.set).not.toHaveBeenCalled()
expect(h.mutate).not.toHaveBeenCalled()
})
it('does not block the product when DeepSeek setup is unavailable', async () => {
@@ -145,7 +243,7 @@ describe('DeepSeekOnboardingDialog', () => {
]) {
const view = render(<DeepSeekOnboardingDialog {...h.props} />)
await act(async () => { await h.controller.load() })
expect(screen.queryByRole('region')).toBeNull()
expect(screen.queryByRole('dialog')).toBeNull()
await waitFor(() => { expect(h.complete).toHaveBeenCalledOnce() })
expect(h.openSection).not.toHaveBeenCalled()
view.unmount()
@@ -160,7 +258,7 @@ describe('DeepSeekOnboardingDialog', () => {
]) {
const view = render(<DeepSeekOnboardingDialog {...h.props} />)
await act(async () => { await h.controller.load() })
expect(screen.queryByRole('region')).toBeNull()
expect(screen.queryByRole('dialog')).toBeNull()
await waitFor(() => { expect(h.complete).toHaveBeenCalledOnce() })
view.unmount()
}
@@ -169,10 +267,10 @@ describe('DeepSeekOnboardingDialog', () => {
it('closes when an external credential invalidation refreshes the shared join', async () => {
const h = harness()
render(<DeepSeekOnboardingDialog {...h.props} />)
await screen.findByRole('region')
await screen.findByRole('dialog')
h.configure()
await act(async () => { await h.controller.load() })
await waitFor(() => { expect(screen.queryByRole('region')).toBeNull() })
await waitFor(() => { expect(screen.queryByRole('dialog')).toBeNull() })
expect(h.complete).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,126 @@
// @vitest-environment jsdom
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 { WelcomeNotice } from '../src/client/WelcomeNotice.tsx'
import type { WelcomeNoticeProps } from '../src/client/WelcomeNotice.tsx'
import { WelcomeNoticeStore } 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,
WELCOME_NOTICE_VERSION,
} from '../src/onboarding-copy.ts'
afterEach(() => {
cleanup()
document.getElementById('root')?.remove()
})
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({}))) {
const appRoot = document.createElement('div')
appRoot.id = 'root'
document.body.append(appRoot)
const mutate = vi.fn(mutateImpl)
const api = {
settings: {
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,
}],
})),
mutate,
},
}
const controller = new WelcomeNoticeStore(api as never)
const complete = vi.fn()
const unusedHook = (() => { throw new Error('unused standard hook') }) as never
const props: WelcomeNoticeProps = {
stepId: 'welcome-notice',
complete,
openSection: vi.fn(),
useSessions: unusedHook,
useWorkspaces: unusedHook,
controller,
useWelcome: bindSnapshotSelector(controller.store),
t: key => zh[key],
}
return { ...render(<WelcomeNotice {...props} />), complete, controller, mutate, appRoot }
}
describe('WelcomeNotice', () => {
it('uses the exact owner copy in both GUI locales', () => {
expect(WELCOME_NOTICE_COPY.en).toEqual(WELCOME_NOTICE_COPY.zh)
expect(en.welcomeBody).toBe(WELCOME_NOTICE_COPY.en.body)
expect(zh.welcomeBody).toBe(WELCOME_NOTICE_COPY.zh.body)
})
it('renders one blocking modal action and focuses the title', async () => {
const h = mount()
const dialog = await screen.findByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.title })
for (const paragraph of WELCOME_NOTICE_COPY.zh.body.split('\n\n')) {
expect(screen.getByText(paragraph, { exact: true })).toBeTruthy()
}
expect(dialog.querySelectorAll('p')).toHaveLength(2)
expect(dialog.querySelectorAll('button')).toHaveLength(1)
expect(screen.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel })).toBeTruthy()
expect(document.activeElement).toBe(screen.getByRole('heading', { name: WELCOME_NOTICE_COPY.zh.title }))
expect(h.appRoot.inert).toBe(true)
fireEvent.keyDown(document, { key: 'Escape' })
fireEvent.click(document.querySelector('[class*="mask"]')!)
expect(h.complete).not.toHaveBeenCalled()
expect(screen.getByRole('dialog')).toBeTruthy()
})
it('completes only after the acknowledgement write commits', async () => {
const h = mount()
await screen.findByRole('dialog')
fireEvent.click(screen.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }))
await act(async () => { await Promise.resolve() })
expect(h.mutate).toHaveBeenCalledOnce()
expect(h.complete).toHaveBeenCalledOnce()
})
it('skips itself when this exact version was already acknowledged', async () => {
const h = mount(WELCOME_NOTICE_VERSION)
await act(async () => { await h.controller.load() })
expect(screen.queryByRole('dialog')).toBeNull()
expect(h.complete).toHaveBeenCalledOnce()
})
it('keeps the sole action disabled while saving and reports a refused write', async () => {
let resolveWrite!: (value: unknown) => void
const write = new Promise<unknown>((resolve) => { resolveWrite = resolve })
const h = mount(undefined, () => write)
await screen.findByRole('dialog')
const action = screen.getByRole<HTMLButtonElement>('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel })
fireEvent.click(action)
expect(action.disabled).toBe(true)
resolveWrite({
rpcId: 'welcome-refused' as never,
result: {
ok: false,
error: {
code: 'settings-rejected',
message: 'read only',
details: { ns: WELCOME_NOTICE_SETTINGS_NAMESPACE },
},
},
})
expect((await screen.findByRole('alert')).textContent).toBe(zh.welcomeError)
expect(h.complete).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,199 @@
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 {
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION,
} from '../src/onboarding-copy.ts'
let rpc = 0
function ok<T>(value: T): RpcResponse<T> {
return { rpcId: `welcome-${rpc++}` as never, result: { ok: true, value } }
}
function namespace(version?: string) {
return {
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
schema: {},
value: version === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: version },
base: {},
user: {},
applies: 'live' as const,
secrets: [],
revision: 0,
}
}
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 }
}
describe('WelcomeNoticeStore', () => {
it('acknowledges in memory without calling loopback-only settings APIs', async () => {
const describe = vi.fn()
const mutate = vi.fn()
const controller = new WelcomeNoticeStore({ settings: { describe, mutate } } as never, 'memory')
await controller.load()
expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: false, error: null })
await expect(controller.acknowledge()).resolves.toBe(true)
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(mutate).not.toHaveBeenCalled()
})
it('acknowledges only the exact current copy version', async () => {
for (const [version, acknowledged] of [
[undefined, false],
['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)
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)
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 }],
})
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true })
})
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('offline string') },
} as never)
await nonError.load()
expect(nonError.store.getSnapshot().error).toBe('offline string')
})
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')
}
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)
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()
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')
})
})