From 378141bf5da3de3a8bd217e44e5f3cfc0abc3abb Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:20:45 +0800 Subject: [PATCH] fix: test --- knip.json | 6 ++ packages/client/ui-goal/src/client/index.ts | 18 ++--- packages/client/ui-goal/src/client/slots.ts | 12 ++-- .../ui-goal/tests/browser-plugin.spec.tsx | 67 ++++++++----------- .../client/ui-goal/tests/goalbar.spec.tsx | 16 ++--- .../host/directory-picker-auto/src/index.ts | 5 +- 6 files changed, 59 insertions(+), 65 deletions(-) diff --git a/knip.json b/knip.json index 476641830f..34ef90779a 100644 --- a/knip.json +++ b/knip.json @@ -91,6 +91,12 @@ "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": { "entry": [ "tests/**/*.spec.{ts,tsx}", diff --git a/packages/client/ui-goal/src/client/index.ts b/packages/client/ui-goal/src/client/index.ts index f2f545d3c3..bfa1a283b5 100644 --- a/packages/client/ui-goal/src/client/index.ts +++ b/packages/client/ui-goal/src/client/index.ts @@ -8,7 +8,6 @@ * their CAS ref reads the session's current projected value at call time. * 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' // 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' @@ -41,13 +40,6 @@ const NS = 'goal' /** Required services for the Goal dock, command-input projection, Remote mutations, and copy. */ 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): 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. * @param ctx - client root context. @@ -74,7 +66,7 @@ export function apply(ctx: ClientContext): void { const noCurrentGoal: GoalActionResult = { 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({ @@ -86,22 +78,22 @@ export function apply(ctx: ClientContext): void { onEdit: async (objective) => { const ref = refOf(sessionId) 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 () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(await ctx.remote.goals.pause(sessionId, ref)) + return await ctx.remote.goals.pause(sessionId, ref) }, onResume: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(await ctx.remote.goals.resume(sessionId, ref)) + return await ctx.remote.goals.resume(sessionId, ref) }, onClear: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(await ctx.remote.goals.clear(sessionId, ref)) + return await ctx.remote.goals.clear(sessionId, ref) }, }), }, GoalDock)) diff --git a/packages/client/ui-goal/src/client/slots.ts b/packages/client/ui-goal/src/client/slots.ts index 7a1e869afc..a0dc2a5063 100644 --- a/packages/client/ui-goal/src/client/slots.ts +++ b/packages/client/ui-goal/src/client/slots.ts @@ -7,10 +7,14 @@ * (callbacks from inject, live state from useProjection). */ -/** Settled outcome of one goal mutation, rendered inline by the strip. */ -export type GoalActionResult = - | { ok: true } - | { ok: false; error: { code: string; message: string } } +import type { RemoteResult } from '@deepseek-ai/dsh-type-meta' + +/** + * 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 /** Injected business face of the GoalBar dock entry: the mutation verbs (function properties: the strip destructures them freely). */ export interface GoalBarActions { diff --git a/packages/client/ui-goal/tests/browser-plugin.spec.tsx b/packages/client/ui-goal/tests/browser-plugin.spec.tsx index 793a6e3681..cac81a5d50 100644 --- a/packages/client/ui-goal/tests/browser-plugin.spec.tsx +++ b/packages/client/ui-goal/tests/browser-plugin.spec.tsx @@ -5,8 +5,8 @@ * 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 * 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 - * map onto the inline-render result shape. Registration disposal rides the + * to the no-current-goal error without touching the wire, and a Remote failure + * reaches the strip verbatim. Registration disposal rides the * plugin fiber (HMR safety). The node half and the invariant companion are * 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. */ async function bench(options: { projection?: GoalProjection | null | undefined - failWith?: { code: string; message: string } - rejectWith?: unknown + failWith?: { code: string; message: string; details: object } } = {}) { const ctx = new Context() const calls: { method: string; args: unknown[] }[] = [] @@ -57,12 +56,8 @@ async function bench(options: { function answer(method: string, value: T) { return (...args: unknown[]) => { calls.push({ method, args }) - // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the defensive scenario under test. - if ('rejectWith' in options) return Promise.reject(options.rejectWith) - if (options.failWith !== undefined) { - return Promise.reject(new Error(`Remote ${method} failed`, { cause: options.failWith })) - } - return Promise.resolve(value) + if (options.failWith !== undefined) return Promise.resolve({ ok: false, error: options.failWith }) + return Promise.resolve({ ok: true, value }) } } const ref = { id: 'g-1', revision: 3 } @@ -139,10 +134,13 @@ describe('ui-goal browser plugin', () => { const b = await bench({ projection: makeProjection(5) }) await b.fiber.await() const verbs = b.entry()!.inject!(sid('s1')) - expect(await verbs.onEdit('New objective')).toEqual({ ok: true }) - expect(await verbs.onPause()).toEqual({ ok: true }) - expect(await verbs.onResume()).toEqual({ ok: true }) - expect(await verbs.onClear()).toEqual({ ok: true }) + // The strip forwards the Remote value verbatim; `answered` is the fake's + // reply, unrelated to the CAS ref the call carries. + const answered = { id: 'g-1', revision: 3 } + 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']) const ref = { id: 'g-1', revision: 5 } 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')) 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' }]) }) - 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() }) await b.fiber.await() const verbs = b.entry()!.inject!(sid('s1')) b.unmountGoals() - for (const result of [await verbs.onEdit('x'), await verbs.onPause(), await verbs.onResume(), await verbs.onClear()]) { - expect(result).toMatchObject({ ok: false, error: { code: 'internal' } }) + // A missing namespace is an assembly fault, not a call outcome: this plugin + // 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) }) @@ -179,30 +181,17 @@ describe('ui-goal browser plugin', () => { await b.fiber.await() const verbs = b.entry()!.inject!(sid('s1')) 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) } }) - it('maps a Remote error onto the inline-render shape', async () => { - const b = await bench({ projection: makeProjection(), failWith: { code: 'internal', message: 'stale revision' } }) + it('forwards a Remote failure to the strip verbatim', async () => { + const b = await bench({ projection: makeProjection(), failWith: { code: 'internal', message: 'stale revision', details: {} } }) await b.fiber.await() const verbs = b.entry()!.inject!(sid('s1')) - expect(await verbs.onEdit('x')).toEqual({ ok: false, error: { code: 'internal', message: 'stale revision' } }) - }) - - 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 } }) + expect(await verbs.onEdit('x')).toEqual({ ok: false, error: { code: 'internal', message: 'stale revision', details: {} } }) }) it('drops the dock entry when the plugin fiber unloads (HMR safety)', async () => { @@ -223,10 +212,10 @@ describe('GoalDock adapter', () => { const projection = makeProjection() const useProjection = vi.fn(() => projection) const actions: GoalBarActions = { - onEdit: () => Promise.resolve({ ok: true }), - onPause: () => Promise.resolve({ ok: true }), - onResume: () => Promise.resolve({ ok: true }), - onClear: () => Promise.resolve({ ok: true }), + onEdit: () => Promise.resolve({ ok: true, value: undefined }), + onPause: () => Promise.resolve({ ok: true, value: undefined }), + onResume: () => Promise.resolve({ ok: true, value: undefined }), + onClear: () => Promise.resolve({ ok: true, value: undefined }), } const t = makeTranslate(zh, commonZh) const dockProps = (up: () => GoalProjection | null | undefined) => diff --git a/packages/client/ui-goal/tests/goalbar.spec.tsx b/packages/client/ui-goal/tests/goalbar.spec.tsx index 931c591000..a45387f048 100644 --- a/packages/client/ui-goal/tests/goalbar.spec.tsx +++ b/packages/client/ui-goal/tests/goalbar.spec.tsx @@ -30,10 +30,10 @@ function makeGoal(over: Partial = {}): GoalSnapshot { function makeActions() { return { - onEdit: vi.fn(() => Promise.resolve({ ok: true })), - onPause: vi.fn(() => Promise.resolve({ ok: true })), - onResume: vi.fn(() => Promise.resolve({ ok: true })), - onClear: vi.fn(() => Promise.resolve({ ok: true })), + onEdit: vi.fn(() => Promise.resolve({ ok: true, value: undefined })), + onPause: vi.fn(() => Promise.resolve({ ok: true, value: undefined })), + onResume: vi.fn(() => Promise.resolve({ ok: true, value: undefined })), + onClear: vi.fn(() => Promise.resolve({ ok: true, value: undefined })), } satisfies GoalBarActions } @@ -75,7 +75,7 @@ describe('GoalBar', () => { expect(actions.onClear).toHaveBeenCalledTimes(1) expect(clear.disabled).toBe(true) - await act(async () => { resolveClear({ ok: true }) }) + await act(async () => { resolveClear({ ok: true, value: undefined }) }) expect(container.firstChild).toBeNull() rerender() @@ -178,7 +178,7 @@ describe('GoalBar', () => { it('keeps the edit draft open and reports a failed save', async () => { 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() fireEvent.click(screen.getByRole('button', { name: '编辑目标' })) const box = screen.getByRole('textbox', { name: '目标内容' }) @@ -191,12 +191,12 @@ describe('GoalBar', () => { it('reports resume and clear failures without hiding the goal', async () => { 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() fireEvent.click(screen.getByRole('button', { name: '恢复目标' })) 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() fireEvent.click(screen.getByRole('button', { name: '清除目标' })) expect((await screen.findByRole('alert')).textContent).toBe('clear failed (agent-busy)') diff --git a/packages/host/directory-picker-auto/src/index.ts b/packages/host/directory-picker-auto/src/index.ts index 0d836419a0..764af27cdc 100644 --- a/packages/host/directory-picker-auto/src/index.ts +++ b/packages/host/directory-picker-auto/src/index.ts @@ -42,7 +42,10 @@ export const BACKEND_PACKAGES: Record = { /** * Client surface package per resolved kind, mounted with its backend so one * 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 = { native: '@deepseek-ai/dsh-client-ui-directory-picker-native',