fix: test

This commit is contained in:
imccyu
2026-08-11 23:33:18 +08:00
parent 6332bd3513
commit 378141bf5d
6 changed files with 59 additions and 65 deletions
+6
View File
@@ -91,6 +91,12 @@
"tests/**/*.ts" "tests/**/*.ts"
] ]
}, },
"packages/host/directory-picker-auto": {
"ignoreDependencies": [
"@deepseek-ai/dsh-client-ui-directory-picker",
"@deepseek-ai/dsh-client-ui-directory-picker-native"
]
},
"packages/host/directory-picker-native": { "packages/host/directory-picker-native": {
"entry": [ "entry": [
"tests/**/*.spec.{ts,tsx}", "tests/**/*.spec.{ts,tsx}",
+5 -13
View File
@@ -8,7 +8,6 @@
* their CAS ref reads the session's current projected value at call time. * their CAS ref reads the session's current projected value at call time.
* Goal creation stays on the /goal host command. * Goal creation stays on the /goal host command.
*/ */
import type { RemoteResult } from '@deepseek-ai/dsh-type-meta'
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: pulls the generated Remote API and ctx.remote merge through the Client assembly boundary. // Type-only: pulls the generated Remote API and ctx.remote merge through the Client assembly boundary.
import type {} from '@deepseek-ai/dsh-api-remotes/client' import type {} from '@deepseek-ai/dsh-api-remotes/client'
@@ -41,13 +40,6 @@ const NS = 'goal'
/** Required services for the Goal dock, command-input projection, Remote mutations, and copy. */ /** Required services for the Goal dock, command-input projection, Remote mutations, and copy. */
export const inject = ['slots', 'sessions', 'remote', 'remote.goals', 'locale', 'conversationEvents'] export const inject = ['slots', 'sessions', 'remote', 'remote.goals', 'locale', 'conversationEvents']
/** Narrow one Remote mutation's result to the fields the goal strip renders. */
function settle(result: RemoteResult<unknown>): GoalActionResult {
return result.ok
? { ok: true }
: { ok: false, error: { code: result.error.code, message: result.error.message } }
}
/** /**
* Client plugin body: the GoalBar dock entry with its mutation verbs. * Client plugin body: the GoalBar dock entry with its mutation verbs.
* @param ctx - client root context. * @param ctx - client root context.
@@ -74,7 +66,7 @@ export function apply(ctx: ClientContext): void {
const noCurrentGoal: GoalActionResult = { const noCurrentGoal: GoalActionResult = {
ok: false, ok: false,
error: { code: 'no-current-goal', message: 'no current goal to mutate' }, error: { code: 'no-current-goal', message: 'no current goal to mutate', details: {} },
} }
ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({ ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
@@ -86,22 +78,22 @@ export function apply(ctx: ClientContext): void {
onEdit: async (objective) => { onEdit: async (objective) => {
const ref = refOf(sessionId) const ref = refOf(sessionId)
if (ref === undefined) return noCurrentGoal if (ref === undefined) return noCurrentGoal
return settle(await ctx.remote.goals.edit(sessionId, ref, { objective })) return await ctx.remote.goals.edit(sessionId, ref, { objective })
}, },
onPause: async () => { onPause: async () => {
const ref = refOf(sessionId) const ref = refOf(sessionId)
if (ref === undefined) return noCurrentGoal if (ref === undefined) return noCurrentGoal
return settle(await ctx.remote.goals.pause(sessionId, ref)) return await ctx.remote.goals.pause(sessionId, ref)
}, },
onResume: async () => { onResume: async () => {
const ref = refOf(sessionId) const ref = refOf(sessionId)
if (ref === undefined) return noCurrentGoal if (ref === undefined) return noCurrentGoal
return settle(await ctx.remote.goals.resume(sessionId, ref)) return await ctx.remote.goals.resume(sessionId, ref)
}, },
onClear: async () => { onClear: async () => {
const ref = refOf(sessionId) const ref = refOf(sessionId)
if (ref === undefined) return noCurrentGoal if (ref === undefined) return noCurrentGoal
return settle(await ctx.remote.goals.clear(sessionId, ref)) return await ctx.remote.goals.clear(sessionId, ref)
}, },
}), }),
}, GoalDock)) }, GoalDock))
+8 -4
View File
@@ -7,10 +7,14 @@
* (callbacks from inject, live state from useProjection). * (callbacks from inject, live state from useProjection).
*/ */
/** Settled outcome of one goal mutation, rendered inline by the strip. */ import type { RemoteResult } from '@deepseek-ai/dsh-type-meta'
export type GoalActionResult =
| { ok: true } /**
| { ok: false; error: { code: string; message: string } } * Settled outcome of one goal mutation, rendered inline by the strip. The
* strip renders the failure only — the mutated goal arrives through the
* projection — so the success value stays unread here.
*/
export type GoalActionResult = RemoteResult<unknown>
/** Injected business face of the GoalBar dock entry: the mutation verbs (function properties: the strip destructures them freely). */ /** Injected business face of the GoalBar dock entry: the mutation verbs (function properties: the strip destructures them freely). */
export interface GoalBarActions { export interface GoalBarActions {
@@ -5,8 +5,8 @@
* conversation.input.dock, the inject face's four verbs read the CAS ref * conversation.input.dock, the inject face's four verbs read the CAS ref
* from the session's CURRENT projected value at call time (no fence — the * from the session's CURRENT projected value at call time (no fence — the
* Remote method's compare-and-set is the guard), a missing projection short-circuits * Remote method's compare-and-set is the guard), a missing projection short-circuits
* to the no-current-goal error without touching the wire, and Remote errors * to the no-current-goal error without touching the wire, and a Remote failure
* map onto the inline-render result shape. Registration disposal rides the * reaches the strip verbatim. Registration disposal rides the
* plugin fiber (HMR safety). The node half and the invariant companion are * plugin fiber (HMR safety). The node half and the invariant companion are
* exercised over the same Context. * exercised over the same Context.
*/ */
@@ -48,8 +48,7 @@ function makeProjection(revision = 3): GoalProjection {
/** Boot the plugin over fake faces; Goal Remote methods record arguments and answer per the script. */ /** Boot the plugin over fake faces; Goal Remote methods record arguments and answer per the script. */
async function bench(options: { async function bench(options: {
projection?: GoalProjection | null | undefined projection?: GoalProjection | null | undefined
failWith?: { code: string; message: string } failWith?: { code: string; message: string; details: object }
rejectWith?: unknown
} = {}) { } = {}) {
const ctx = new Context() const ctx = new Context()
const calls: { method: string; args: unknown[] }[] = [] const calls: { method: string; args: unknown[] }[] = []
@@ -57,12 +56,8 @@ async function bench(options: {
function answer<T>(method: string, value: T) { function answer<T>(method: string, value: T) {
return (...args: unknown[]) => { return (...args: unknown[]) => {
calls.push({ method, args }) calls.push({ method, args })
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the defensive scenario under test. if (options.failWith !== undefined) return Promise.resolve({ ok: false, error: options.failWith })
if ('rejectWith' in options) return Promise.reject(options.rejectWith) return Promise.resolve({ ok: true, value })
if (options.failWith !== undefined) {
return Promise.reject(new Error(`Remote ${method} failed`, { cause: options.failWith }))
}
return Promise.resolve(value)
} }
} }
const ref = { id: 'g-1', revision: 3 } const ref = { id: 'g-1', revision: 3 }
@@ -139,10 +134,13 @@ describe('ui-goal browser plugin', () => {
const b = await bench({ projection: makeProjection(5) }) const b = await bench({ projection: makeProjection(5) })
await b.fiber.await() await b.fiber.await()
const verbs = b.entry()!.inject!(sid('s1')) const verbs = b.entry()!.inject!(sid('s1'))
expect(await verbs.onEdit('New objective')).toEqual({ ok: true }) // The strip forwards the Remote value verbatim; `answered` is the fake's
expect(await verbs.onPause()).toEqual({ ok: true }) // reply, unrelated to the CAS ref the call carries.
expect(await verbs.onResume()).toEqual({ ok: true }) const answered = { id: 'g-1', revision: 3 }
expect(await verbs.onClear()).toEqual({ ok: true }) expect(await verbs.onEdit('New objective')).toEqual({ ok: true, value: { ref: answered } })
expect(await verbs.onPause()).toEqual({ ok: true, value: { ref: answered } })
expect(await verbs.onResume()).toEqual({ ok: true, value: { ref: answered } })
expect(await verbs.onClear()).toEqual({ ok: true, value: answered })
expect(b.calls.map(c => c.method)).toEqual(['goals/edit', 'goals/pause', 'goals/resume', 'goals/clear']) expect(b.calls.map(c => c.method)).toEqual(['goals/edit', 'goals/pause', 'goals/resume', 'goals/clear'])
const ref = { id: 'g-1', revision: 5 } const ref = { id: 'g-1', revision: 5 }
expect(b.calls[0]?.args).toEqual(['s1', ref, { objective: 'New objective' }]) expect(b.calls[0]?.args).toEqual(['s1', ref, { objective: 'New objective' }])
@@ -157,18 +155,22 @@ describe('ui-goal browser plugin', () => {
const verbs = b.entry()!.inject!(sid('s1')) const verbs = b.entry()!.inject!(sid('s1'))
b.remountGoals() b.remountGoals()
expect(await verbs.onPause()).toEqual({ ok: true }) expect(await verbs.onPause()).toEqual({ ok: true, value: { ref: { id: 'g-1', revision: 3 } } })
expect(b.calls).toMatchObject([{ method: 'remounted-goals/pause' }]) expect(b.calls).toMatchObject([{ method: 'remounted-goals/pause' }])
}) })
it('settles every verb when the Remote namespace is temporarily absent', async () => { it('rejects every verb once the Remote namespace is gone', async () => {
const b = await bench({ projection: makeProjection() }) const b = await bench({ projection: makeProjection() })
await b.fiber.await() await b.fiber.await()
const verbs = b.entry()!.inject!(sid('s1')) const verbs = b.entry()!.inject!(sid('s1'))
b.unmountGoals() b.unmountGoals()
for (const result of [await verbs.onEdit('x'), await verbs.onPause(), await verbs.onResume(), await verbs.onClear()]) { // A missing namespace is an assembly fault, not a call outcome: this plugin
expect(result).toMatchObject({ ok: false, error: { code: 'internal' } }) // declares remote.goals in `inject`, so cordis disposes the dock entry along
// with the namespace. Only a React closure that outlived that disposal can
// reach these verbs, so no consumer-side guard renders it as an error.
for (const verb of [() => verbs.onEdit('x'), () => verbs.onPause(), () => verbs.onResume(), () => verbs.onClear()]) {
await expect(verb()).rejects.toThrow(TypeError)
} }
expect(b.calls).toHaveLength(0) expect(b.calls).toHaveLength(0)
}) })
@@ -179,30 +181,17 @@ describe('ui-goal browser plugin', () => {
await b.fiber.await() await b.fiber.await()
const verbs = b.entry()!.inject!(sid('s1')) const verbs = b.entry()!.inject!(sid('s1'))
for (const result of [await verbs.onEdit('x'), await verbs.onPause(), await verbs.onResume(), await verbs.onClear()]) { for (const result of [await verbs.onEdit('x'), await verbs.onPause(), await verbs.onResume(), await verbs.onClear()]) {
expect(result).toEqual({ ok: false, error: { code: 'no-current-goal', message: 'no current goal to mutate' } }) expect(result).toEqual({ ok: false, error: { code: 'no-current-goal', message: 'no current goal to mutate', details: {} } })
} }
expect(b.calls).toHaveLength(0) expect(b.calls).toHaveLength(0)
} }
}) })
it('maps a Remote error onto the inline-render shape', async () => { it('forwards a Remote failure to the strip verbatim', async () => {
const b = await bench({ projection: makeProjection(), failWith: { code: 'internal', message: 'stale revision' } }) const b = await bench({ projection: makeProjection(), failWith: { code: 'internal', message: 'stale revision', details: {} } })
await b.fiber.await() await b.fiber.await()
const verbs = b.entry()!.inject!(sid('s1')) const verbs = b.entry()!.inject!(sid('s1'))
expect(await verbs.onEdit('x')).toEqual({ ok: false, error: { code: 'internal', message: 'stale revision' } }) expect(await verbs.onEdit('x')).toEqual({ ok: false, error: { code: 'internal', message: 'stale revision', details: {} } })
})
it.each([
[new Error('connection closed'), 'connection closed'],
['connection closed', 'goal mutation failed'],
[new Error('invalid Remote failure', { cause: null }), 'invalid Remote failure'],
[new Error('invalid Remote failure', { cause: { code: 1, message: 'stale revision' } }), 'invalid Remote failure'],
[new Error('invalid Remote failure', { cause: { code: 'internal', message: 1 } }), 'invalid Remote failure'],
])('maps an unstructured rejection onto an internal error', async (rejection, message) => {
const b = await bench({ projection: makeProjection(), rejectWith: rejection })
await b.fiber.await()
const verbs = b.entry()!.inject!(sid('s1'))
expect(await verbs.onEdit('x')).toEqual({ ok: false, error: { code: 'internal', message } })
}) })
it('drops the dock entry when the plugin fiber unloads (HMR safety)', async () => { it('drops the dock entry when the plugin fiber unloads (HMR safety)', async () => {
@@ -223,10 +212,10 @@ describe('GoalDock adapter', () => {
const projection = makeProjection() const projection = makeProjection()
const useProjection = vi.fn(() => projection) const useProjection = vi.fn(() => projection)
const actions: GoalBarActions = { const actions: GoalBarActions = {
onEdit: () => Promise.resolve({ ok: true }), onEdit: () => Promise.resolve({ ok: true, value: undefined }),
onPause: () => Promise.resolve({ ok: true }), onPause: () => Promise.resolve({ ok: true, value: undefined }),
onResume: () => Promise.resolve({ ok: true }), onResume: () => Promise.resolve({ ok: true, value: undefined }),
onClear: () => Promise.resolve({ ok: true }), onClear: () => Promise.resolve({ ok: true, value: undefined }),
} }
const t = makeTranslate(zh, commonZh) const t = makeTranslate(zh, commonZh)
const dockProps = (up: () => GoalProjection | null | undefined) => const dockProps = (up: () => GoalProjection | null | undefined) =>
@@ -30,10 +30,10 @@ function makeGoal(over: Partial<GoalSnapshot> = {}): GoalSnapshot {
function makeActions() { function makeActions() {
return { return {
onEdit: vi.fn<GoalBarActions['onEdit']>(() => Promise.resolve({ ok: true })), onEdit: vi.fn<GoalBarActions['onEdit']>(() => Promise.resolve({ ok: true, value: undefined })),
onPause: vi.fn<GoalBarActions['onPause']>(() => Promise.resolve({ ok: true })), onPause: vi.fn<GoalBarActions['onPause']>(() => Promise.resolve({ ok: true, value: undefined })),
onResume: vi.fn<GoalBarActions['onResume']>(() => Promise.resolve({ ok: true })), onResume: vi.fn<GoalBarActions['onResume']>(() => Promise.resolve({ ok: true, value: undefined })),
onClear: vi.fn<GoalBarActions['onClear']>(() => Promise.resolve({ ok: true })), onClear: vi.fn<GoalBarActions['onClear']>(() => Promise.resolve({ ok: true, value: undefined })),
} satisfies GoalBarActions } satisfies GoalBarActions
} }
@@ -75,7 +75,7 @@ describe('GoalBar', () => {
expect(actions.onClear).toHaveBeenCalledTimes(1) expect(actions.onClear).toHaveBeenCalledTimes(1)
expect(clear.disabled).toBe(true) expect(clear.disabled).toBe(true)
await act(async () => { resolveClear({ ok: true }) }) await act(async () => { resolveClear({ ok: true, value: undefined }) })
expect(container.firstChild).toBeNull() expect(container.firstChild).toBeNull()
rerender(<GoalBar goal={makeGoal({ id: 'g2' as GoalSnapshot['id'], objective: 'Next goal' })} {...actions} t={t} />) rerender(<GoalBar goal={makeGoal({ id: 'g2' as GoalSnapshot['id'], objective: 'Next goal' })} {...actions} t={t} />)
@@ -178,7 +178,7 @@ describe('GoalBar', () => {
it('keeps the edit draft open and reports a failed save', async () => { it('keeps the edit draft open and reports a failed save', async () => {
const actions = makeActions() const actions = makeActions()
actions.onEdit.mockResolvedValue({ ok: false, error: { code: 'agent-busy', message: 'stale revision' } }) actions.onEdit.mockResolvedValue({ ok: false, error: { code: 'agent-busy', message: 'stale revision', details: {} } })
render(<GoalBar goal={makeGoal()} {...actions} t={t} />) render(<GoalBar goal={makeGoal()} {...actions} t={t} />)
fireEvent.click(screen.getByRole('button', { name: '编辑目标' })) fireEvent.click(screen.getByRole('button', { name: '编辑目标' }))
const box = screen.getByRole('textbox', { name: '目标内容' }) const box = screen.getByRole('textbox', { name: '目标内容' })
@@ -191,12 +191,12 @@ describe('GoalBar', () => {
it('reports resume and clear failures without hiding the goal', async () => { it('reports resume and clear failures without hiding the goal', async () => {
const actions = makeActions() const actions = makeActions()
actions.onResume.mockResolvedValue({ ok: false, error: { code: 'internal', message: 'resume failed' } }) actions.onResume.mockResolvedValue({ ok: false, error: { code: 'internal', message: 'resume failed', details: {} } })
const { rerender } = render(<GoalBar goal={makeGoal({ phase: 'paused' })} {...actions} t={t} />) const { rerender } = render(<GoalBar goal={makeGoal({ phase: 'paused' })} {...actions} t={t} />)
fireEvent.click(screen.getByRole('button', { name: '恢复目标' })) fireEvent.click(screen.getByRole('button', { name: '恢复目标' }))
expect((await screen.findByRole('alert')).textContent).toBe('resume failed (internal)') expect((await screen.findByRole('alert')).textContent).toBe('resume failed (internal)')
actions.onClear.mockResolvedValue({ ok: false, error: { code: 'agent-busy', message: 'clear failed' } }) actions.onClear.mockResolvedValue({ ok: false, error: { code: 'agent-busy', message: 'clear failed', details: {} } })
rerender(<GoalBar goal={makeGoal()} {...actions} t={t} />) rerender(<GoalBar goal={makeGoal()} {...actions} t={t} />)
fireEvent.click(screen.getByRole('button', { name: '清除目标' })) fireEvent.click(screen.getByRole('button', { name: '清除目标' }))
expect((await screen.findByRole('alert')).textContent).toBe('clear failed (agent-busy)') expect((await screen.findByRole('alert')).textContent).toBe('clear failed (agent-busy)')
@@ -42,7 +42,10 @@ export const BACKEND_PACKAGES: Record<DirectoryPickerBackendKind, string> = {
/** /**
* Client surface package per resolved kind, mounted with its backend so one * Client surface package per resolved kind, mounted with its backend so one
* resolved interaction still composes both faces. Declared as dependencies by * resolved interaction still composes both faces. Declared as dependencies by
* every composing app for the same reason as {@link BACKEND_PACKAGES}. * every composing app for the same reason as {@link BACKEND_PACKAGES}. Only the
* specifier is referenced here — the packages belong to the Client program, so
* no import of them exists on this side and knip needs them ignored for this
* workspace.
*/ */
export const SURFACE_PACKAGES: Record<DirectoryPickerBackendKind, string> = { export const SURFACE_PACKAGES: Record<DirectoryPickerBackendKind, string> = {
native: '@deepseek-ai/dsh-client-ui-directory-picker-native', native: '@deepseek-ai/dsh-client-ui-directory-picker-native',