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"
]
},
"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}",
+5 -13
View File
@@ -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<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.
* @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))
+8 -4
View File
@@ -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<unknown>
/** Injected business face of the GoalBar dock entry: the mutation verbs (function properties: the strip destructures them freely). */
export interface GoalBarActions {
@@ -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<T>(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) =>
@@ -30,10 +30,10 @@ function makeGoal(over: Partial<GoalSnapshot> = {}): GoalSnapshot {
function makeActions() {
return {
onEdit: vi.fn<GoalBarActions['onEdit']>(() => Promise.resolve({ ok: true })),
onPause: vi.fn<GoalBarActions['onPause']>(() => Promise.resolve({ ok: true })),
onResume: vi.fn<GoalBarActions['onResume']>(() => Promise.resolve({ ok: true })),
onClear: vi.fn<GoalBarActions['onClear']>(() => Promise.resolve({ ok: true })),
onEdit: vi.fn<GoalBarActions['onEdit']>(() => Promise.resolve({ ok: true, value: undefined })),
onPause: vi.fn<GoalBarActions['onPause']>(() => Promise.resolve({ ok: true, value: undefined })),
onResume: vi.fn<GoalBarActions['onResume']>(() => Promise.resolve({ ok: true, value: undefined })),
onClear: vi.fn<GoalBarActions['onClear']>(() => 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(<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 () => {
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} />)
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(<GoalBar goal={makeGoal({ phase: 'paused' })} {...actions} t={t} />)
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(<GoalBar goal={makeGoal()} {...actions} t={t} />)
fireEvent.click(screen.getByRole('button', { name: '清除目标' }))
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
* 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> = {
native: '@deepseek-ai/dsh-client-ui-directory-picker-native',