feat(web): unify onboarding dialogs
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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]))),
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user