Merge branch 'master' into worktree/locale-browser-default
This commit is contained in:
29
packages/client/ui-settings-general/tests/host.spec.ts
Normal file
29
packages/client/ui-settings-general/tests/host.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { apply } from '../src/index.ts'
|
||||
import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../src/onboarding-copy.ts'
|
||||
|
||||
class MemorySettings extends Settings {
|
||||
readonly writable = true
|
||||
protected load(): Promise<Record<string, unknown>> { return Promise.resolve({}) }
|
||||
protected persist(_ns: SettingsNamespace, _section: Record<string, unknown>): Promise<void> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
describe('ui-settings-general host', () => {
|
||||
it('registers and disposes the durable onboarding namespace with its fiber', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(MemorySettings).await()
|
||||
const fiber = ctx.plugin({ apply })
|
||||
await fiber.await()
|
||||
expect(ctx.settings.describe().map(row => row.ns)).toContain(
|
||||
settingsNamespace(WELCOME_NOTICE_SETTINGS_NAMESPACE),
|
||||
)
|
||||
await fiber.dispose()
|
||||
expect(ctx.settings.describe().map(row => row.ns)).not.toContain(
|
||||
settingsNamespace(WELCOME_NOTICE_SETTINGS_NAMESPACE),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
// @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 { 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)
|
||||
|
||||
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 mutate = vi.fn(mutateImpl)
|
||||
const api = {
|
||||
settings: {
|
||||
describe: () => Promise.resolve(response({
|
||||
writable: true,
|
||||
namespaces: [{
|
||||
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
schema: {},
|
||||
value: version === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: version },
|
||||
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,
|
||||
useSnapshot: bindSnapshotSelector(controller.store),
|
||||
t: key => key in zh ? zh[key as keyof typeof zh] : key,
|
||||
}
|
||||
return { ...render(<WelcomeNotice {...props} />), complete, controller, mutate }
|
||||
}
|
||||
|
||||
describe('WelcomeNotice', () => {
|
||||
it('renders the owner copy with one primary action and no dismissal control', async () => {
|
||||
const h = mount()
|
||||
const page = await screen.findByRole('region', { name: WELCOME_NOTICE_COPY.zh.title })
|
||||
expect(screen.getByText(WELCOME_NOTICE_COPY.zh.title)).toBeTruthy()
|
||||
for (const text of WELCOME_NOTICE_COPY.zh.paragraphs) expect(page.textContent).toContain(text)
|
||||
expect(page.textContent?.match(/感谢您愿意拨冗试用 DeepSeek Harness/g) ?? []).toHaveLength(1)
|
||||
const buttons = page.querySelectorAll('button')
|
||||
expect(buttons).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 }))
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(h.complete).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('region')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('completes only after the acknowledgement write commits', async () => {
|
||||
const h = mount()
|
||||
await screen.findByRole('region')
|
||||
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('region')).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('region')
|
||||
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('暂时无法保存确认状态,请重试。')
|
||||
expect(h.complete).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
166
packages/client/ui-settings-general/tests/welcome-store.spec.ts
Normal file
166
packages/client/ui-settings-general/tests/welcome-store.spec.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcResponse } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { WelcomeNoticeStore } from '../src/client/welcome-store.ts'
|
||||
import { refreshWelcomeIfLoaded } 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 },
|
||||
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 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, 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.
|
||||
// oxlint-disable-next-line typescript/prefer-promise-reject-errors
|
||||
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, 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,
|
||||
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, namespaces: [namespace()] })))
|
||||
const controller = new WelcomeNoticeStore({ settings: { describe } } as never)
|
||||
const stale = controller.load()
|
||||
await controller.load()
|
||||
first.resolve(ok({ writable: true, 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, 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, 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