diff --git a/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.i18n.yaml b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.i18n.yaml index 5b40ff8642..b4898e5114 100644 --- a/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md -2026-09-06-client-assembly-test-line.md: 36f7e2ab11248628dca7aff53cae4a9c9f84e441 -2026-09-06-client-assembly-test-line.zh.md: 0ef64a97e25b062a858ddbd1796f86e59591cd51 +2026-09-06-client-assembly-test-line.md: f3b43ec9a4b08ac7b3a9fc802136ae916da9eb65 +2026-09-06-client-assembly-test-line.zh.md: 95445c584e7484080610da2347a968f5202f14f8 diff --git a/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md index 36f7e2ab11..f3b43ec9a4 100644 --- a/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md +++ b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md @@ -89,4 +89,4 @@ Product facts the tier surfaced and leaves as they are: ## Testing -`packages/test-support/remote-mock/tests/` covers rules, streams, the log, and the carrier face; the `assembly-` specs under `packages/test-support/client-runtime/tests/` cover the roster reader on the real bundles and on a scratch installation, module loading, the proxies including their fold, parallel client startup, instance-local module and Connection reload, and `TestClient` under jsdom and plain Node. Seven converted specs use the tier. In `packages/client/ui-settings-general/tests/`, the shell and apply specs boot the whole `web` roster; the apply spec reads its Chinese copy from the Host settings document the mock answers and reconfigures the jsdom page URL for the off-loopback branch. In `packages/api/session-controller/tests/`, the Session, queue-store, and pending-submission specs drive their objects over the roster's real Connection through the `remote.` proxies, with the Gateway client's own `$stream` retry loop, over the gateway's dependency cone, and the client-apply spec boots the plugin's dependency cone, delivering Remote events as emit frames on `$events`. In `packages/api/workspace-controller/tests/`, the transport spec boots the plugin's cone for apply cases and the gateway cone for hand-built stream and controller cases, since a rostered plugin would share the follow endpoint. Each package keeps a `tests/remote/` module with its default responses and frame builders. The fixture tests include an expected assertion failure and independently observe completed client cleanup; settings reload tests observe replaced registration identities, and write tests assert every mutation argument. Teardown-failure tests execute the real tree disposer before reporting the injected failure and observe the `$events` stream's cancelled state. +`packages/test-support/remote-mock/tests/` covers rules, streams, the log, and the carrier face; the `assembly-` specs under `packages/test-support/client-runtime/tests/` cover the roster reader on the real bundles and on a scratch installation, module loading, the proxies including their fold, parallel client startup, instance-local module and Connection reload, and `TestClient` under jsdom and plain Node. Eight converted specs use the tier. In `packages/client/ui-settings-general/tests/`, the shell and apply specs boot the whole `web` roster; the apply spec reads its Chinese copy from the Host settings document the mock answers and reconfigures the jsdom page URL for the off-loopback branch. In `packages/client/ui-message-feedback/tests/`, object-layer specs pass the `messageFeedback` RemoteMock namespace directly. In `packages/api/session-controller/tests/`, the Session, queue-store, and pending-submission specs drive their objects over the roster's real Connection through the `remote.` proxies, with the Gateway client's own `$stream` retry loop, over the gateway's dependency cone, and the client-apply spec boots the plugin's dependency cone, delivering Remote events as emit frames on `$events`. In `packages/api/workspace-controller/tests/`, the transport spec boots the plugin's cone for apply cases and the gateway cone for hand-built stream and controller cases, since a rostered plugin would share the follow endpoint. Each package keeps a `tests/remote/` module with its default responses and frame builders. The fixture tests include an expected assertion failure and independently observe completed client cleanup; settings reload tests observe replaced registration identities, and write tests assert every mutation argument. Teardown-failure tests execute the real tree disposer before reporting the injected failure and observe the `$events` stream's cancelled state. diff --git a/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.zh.md b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.zh.md index 0ef64a97e2..95445c584e 100644 --- a/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.zh.md +++ b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.zh.md @@ -89,4 +89,4 @@ spec 起的是真插件:整个 `web` roster 冷启动约五秒、热启动远 ## 测试 -`packages/test-support/remote-mock/tests/` 覆盖规则、流、日志与载体面;`packages/test-support/client-runtime/tests/` 下的 `assembly-` 系列 spec 覆盖在真 bundle 与临时安装上的 roster 读取器、模块加载、含折叠的代理、并行客户端启动、实例局部的模块与 Connection 重载,以及 jsdom 与纯 Node 下的 `TestClient`。七条改造后的 spec 使用本档。`packages/client/ui-settings-general/tests/` 下,shell 与 apply 两条起整个 `web` roster;apply 从 mock 应答的 Host settings 文档读它的中文文案,并为 off-loopback 分支重配 jsdom 页面 URL。`packages/api/session-controller/tests/` 下,Session、queue-store、pending-submission 三条在 gateway 依赖锥上经 `remote.` 代理走 roster 的真 Connection 驱动对象(`$stream` 的重试循环仍是 Gateway 客户端自己的),client-apply 起插件的依赖锥,把 Remote 事件作为 `$events` 上的 emit 帧投递。`packages/api/workspace-controller/tests/` 下,transport 的 apply 用例起插件锥、手工构造流与 controller 的用例起 gateway 锥,因为进了 roster 的插件会共用 follow 端点。每个包在 `tests/remote/` 保有自己的默认响应与帧构造。fixture 测试包含预期的断言失败,并独立观察客户端清理完成;settings 重载测试观察注册身份被替换,写入测试断言全部 mutation 参数。teardown 失败测试先执行真实树清理,再报告注入的失败,并观察 `$events` 流的取消状态。 +`packages/test-support/remote-mock/tests/` 覆盖规则、流、日志与载体面;`packages/test-support/client-runtime/tests/` 下的 `assembly-` 系列 spec 覆盖在真 bundle 与临时安装上的 roster 读取器、模块加载、含折叠的代理、并行客户端启动、实例局部的模块与 Connection 重载,以及 jsdom 与纯 Node 下的 `TestClient`。八条改造后的 spec 使用本档。`packages/client/ui-settings-general/tests/` 下,shell 与 apply 两条起整个 `web` roster;apply 从 mock 应答的 Host settings 文档读它的中文文案,并为 off-loopback 分支重配 jsdom 页面 URL。`packages/client/ui-message-feedback/tests/` 下,对象层 spec 直接传入 RemoteMock 的 `messageFeedback` 命名空间。`packages/api/session-controller/tests/` 下,Session、queue-store、pending-submission 三条在 gateway 依赖锥上经 `remote.` 代理走 roster 的真 Connection 驱动对象(`$stream` 的重试循环仍是 Gateway 客户端自己的),client-apply 起插件的依赖锥,把 Remote 事件作为 `$events` 上的 emit 帧投递。`packages/api/workspace-controller/tests/` 下,transport 的 apply 用例起插件锥、手工构造流与 controller 的用例起 gateway 锥,因为进了 roster 的插件会共用 follow 端点。每个包在 `tests/remote/` 保有自己的默认响应与帧构造。fixture 测试包含预期的断言失败,并独立观察客户端清理完成;settings 重载测试观察注册身份被替换,写入测试断言全部 mutation 参数。teardown 失败测试先执行真实树清理,再报告注入的失败,并观察 `$events` 流的取消状态。 diff --git a/packages/client/ui-message-feedback/package.json b/packages/client/ui-message-feedback/package.json index 051353d43f..08b42a5819 100644 --- a/packages/client/ui-message-feedback/package.json +++ b/packages/client/ui-message-feedback/package.json @@ -55,6 +55,7 @@ "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", + "@deepseek-ai/dsh-remote-mock": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/client/ui-message-feedback/src/client/controller.ts b/packages/client/ui-message-feedback/src/client/controller.ts index 9a47b63e73..f50b248d99 100644 --- a/packages/client/ui-message-feedback/src/client/controller.ts +++ b/packages/client/ui-message-feedback/src/client/controller.ts @@ -7,9 +7,8 @@ * @module @deepseek-ai/dsh-client-ui-message-feedback/client/controller */ -import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' -import type { MessageId } from '@deepseek-ai/dsh-api-remotes/client' +import type { ClientRemote, MessageId } from '@deepseek-ai/dsh-api-remotes/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { FeedbackRecord } from '@deepseek-ai/dsh-command-feedback/types' import type { @@ -95,11 +94,11 @@ export class MessageFeedbackController implements HostObservable { - const carried = await this.ctx.remote.messageFeedback.put({ + const carried = await this.remote.put({ sessionId: this.sessionId, messageId, rating, @@ -217,7 +216,7 @@ export class MessageFeedbackController implements HostObservable { - const carried = await this.ctx.remote.messageFeedback.delete({ + const carried = await this.remote.delete({ sessionId: this.sessionId, messageId, ifVersion: observed.version, @@ -240,7 +239,7 @@ export class MessageFeedbackController implements HostObservable { - const carried = await this.ctx.remote.messageFeedback.list({ sessionId: this.sessionId }) + const carried = await this.remote.list({ sessionId: this.sessionId }) if (this.disposed) return OK if (!carried.ok) { this.publish({ status: 'error', items: this.view.items, error: carried.error.message }) diff --git a/packages/client/ui-message-feedback/src/client/surface.ts b/packages/client/ui-message-feedback/src/client/surface.ts index b54d04d659..3006350ce9 100644 --- a/packages/client/ui-message-feedback/src/client/surface.ts +++ b/packages/client/ui-message-feedback/src/client/surface.ts @@ -24,7 +24,7 @@ export class FeedbackSurface { * @param sessionId - Session owning the transcript and the remark. */ constructor(private readonly ctx: ClientContext, private readonly sessionId: SessionId) { - this.feedback = new MessageFeedbackController(ctx, sessionId) + this.feedback = new MessageFeedbackController(ctx.remote.messageFeedback, sessionId) this.dialog = new FeedbackDialogController((target, entry) => target.kind === 'message' ? this.feedback.rate(target.messageId, target.rating, entry) : this.recordSession(entry)) diff --git a/packages/client/ui-message-feedback/tests/controller.client.spec.ts b/packages/client/ui-message-feedback/tests/controller.client.spec.ts index 5fe01b2f58..54ed84bd8e 100644 --- a/packages/client/ui-message-feedback/tests/controller.client.spec.ts +++ b/packages/client/ui-message-feedback/tests/controller.client.spec.ts @@ -5,8 +5,8 @@ * reconciles from the authoritative item carried by the reply, mutations * serialize per Session, and a disposed controller stops publishing. */ -import { describe, expect, it, vi } from 'vitest' -import type { Context as ClientContext } from '@deepseek-ai/cordis' +import { RemoteMock, ok } from '@deepseek-ai/dsh-remote-mock' +import { beforeEach, describe, expect, it, vi } from 'vitest' import type { MessageId, SessionId } from '@deepseek-ai/dsh-api-remotes/client' import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import type { @@ -31,49 +31,20 @@ function item(overrides: Partial = {}): MessageFeedbackItem } } -/** A recording fake Remote whose per-method answers are scripted per call. */ -type Script = { - list?: (request: unknown) => Promise - put?: (request: unknown) => Promise - delete?: (request: unknown) => Promise -} +let mock: RemoteMock -/** - * A recording fake context. Scripts return the *business* result; this wraps it - * in the carrier envelope the generated face uses, so specs stay readable. A - * script may also return an already-enveloped `{ok:false,error:RemoteError}` to - * exercise a carrier failure. - */ -function fakeRemote(script: Script = {}) { - const calls: { method: string; request: unknown }[] = [] - const isCarrier = (v: unknown): boolean => - typeof v === 'object' && v !== null && 'ok' in v && v.ok === false - && 'error' in v && 'details' in ((v as { error: object }).error ?? {}) - const record = (method: 'list' | 'put' | 'delete', real: Script[keyof Script], fallback: unknown) => - (request: never): Promise => { - calls.push({ method, request }) - const business = real === undefined ? Promise.resolve(fallback) : real(request) - return business.then(v => (isCarrier(v) ? v : { ok: true, value: v })) as Promise - } - const ctx = { - remote: { - messageFeedback: { - list: record('list', script.list, { ok: true, value: { items: [] } }), - put: record('put', script.put, { ok: true, value: item() }), - delete: record('delete', script.delete, { ok: true, value: { absent: true } }), - }, - }, - } as unknown as ClientContext - return { ctx, calls } -} +beforeEach(() => { + mock = RemoteMock.create() + mock.remote.messageFeedback.list.mockResolvedValue(ok({ ok: true, value: { items: [] } })) + mock.remote.messageFeedback.put.mockResolvedValue(ok({ ok: true, value: item() })) + mock.remote.messageFeedback.delete.mockResolvedValue(ok({ ok: true, value: { absent: true } })) +}) describe('MessageFeedbackController', () => { it('seeds the view from one list read and keys items by message id', async () => { const seeded = item({ note: 'good' }) - const { ctx, calls } = fakeRemote({ - list: () => Promise.resolve({ ok: true, value: { items: [seeded] } }), - }) - const controller = new MessageFeedbackController(ctx, SESSION) + mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ ok: true, value: { items: [seeded] } })) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) expect(controller.getSnapshot().status).toBe('cold') expect(await controller.ensure()).toEqual({ ok: true }) @@ -81,73 +52,68 @@ describe('MessageFeedbackController', () => { const view = controller.getSnapshot() expect(view.status).toBe('ready') expect(view.items.get(MSG)).toEqual(seeded) - expect(calls).toEqual([{ method: 'list', request: { sessionId: SESSION } }]) + expect(mock.remote.messageFeedback.list).toHaveBeenCalledExactlyOnceWith({ sessionId: SESSION }) }) it('collapses concurrent loads onto one in-flight read', async () => { - const { ctx, calls } = fakeRemote() - const controller = new MessageFeedbackController(ctx, SESSION) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) await Promise.all([controller.ensure(), controller.ensure(), controller.refresh()]) - expect(calls.filter(call => call.method === 'list')).toHaveLength(1) + expect(mock.remote.messageFeedback.list).toHaveBeenCalledTimes(1) }) it('sends ifVersion null for a first rating and the observed version afterwards', async () => { const first = item({ version: version('v1') }) const second = item({ version: version('v2'), rating: 'negative' }) - const { ctx, calls } = fakeRemote({ - put: request => Promise.resolve({ - ok: true, - value: (request as { rating: string }).rating === 'positive' ? first : second, - }), - }) - const controller = new MessageFeedbackController(ctx, SESSION) + mock.remote.messageFeedback.put.mockImplementation(request => Promise.resolve(ok({ + ok: true, + value: request.rating === 'positive' ? first : second, + }))) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) expect(await controller.rate(MSG, 'positive')).toEqual({ ok: true }) expect(await controller.rate(MSG, 'negative')).toEqual({ ok: true }) - const puts = calls.filter(call => call.method === 'put').map(call => call.request) + const puts = mock.remote.messageFeedback.put.mock.calls.map(([request]) => request) expect(puts[0]).toMatchObject({ messageId: MSG, rating: 'positive', ifVersion: null }) expect(puts[1]).toMatchObject({ messageId: MSG, rating: 'negative', ifVersion: version('v1') }) expect(controller.getSnapshot().items.get(MSG)).toEqual(second) }) it('forwards the entry as note and category and omits absent members', async () => { - const { ctx, calls } = fakeRemote() - const controller = new MessageFeedbackController(ctx, SESSION) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) await controller.rate(MSG, 'negative', { text: 'helpful', category: 'task-result' }) await controller.rate(OTHER, 'negative') - const puts = calls.filter(call => call.method === 'put').map(call => call.request as Record) + const puts = mock.remote.messageFeedback.put.mock.calls.map(([request]) => request) expect(puts[0]).toMatchObject({ note: 'helpful', category: 'task-result' }) expect(puts[1]).not.toHaveProperty('note') expect(puts[1]).not.toHaveProperty('category') }) it('retract deletes a matching rating and ignores an opposite judgment', async () => { - const { ctx, calls } = fakeRemote({ - list: () => Promise.resolve({ ok: true, value: { items: [item({ note: 'stored', category: 'other' })] } }), - }) - const controller = new MessageFeedbackController(ctx, SESSION) + mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ + ok: true, value: { items: [item({ note: 'stored', category: 'other' })] }, + })) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) expect(await controller.retract(MSG, 'negative')).toEqual({ ok: true }) expect(await controller.retract(MSG, 'positive')).toEqual({ ok: true }) - expect(calls.filter(call => call.method === 'put')).toHaveLength(0) - expect(calls.filter(call => call.method === 'delete')[0]?.request) - .toEqual({ sessionId: SESSION, messageId: MSG, ifVersion: version('v1') }) + expect(mock.remote.messageFeedback.put).not.toHaveBeenCalled() + expect(mock.remote.messageFeedback.delete).toHaveBeenCalledExactlyOnceWith({ + sessionId: SESSION, messageId: MSG, ifVersion: version('v1'), + }) }) it('never turns a queued stale retraction into a bare rating put', async () => { const existing = item({ rating: 'positive', version: version('v1') }) const replacement = item({ rating: 'negative', version: version('v2') }) - const { ctx, calls } = fakeRemote({ - list: () => Promise.resolve({ ok: true, value: { items: [existing] } }), - put: () => Promise.resolve({ ok: true, value: replacement }), - }) - const controller = new MessageFeedbackController(ctx, SESSION) + mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ ok: true, value: { items: [existing] } })) + mock.remote.messageFeedback.put.mockResolvedValueOnce(ok({ ok: true, value: replacement })) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) await controller.ensure() const replaced = controller.rate(MSG, 'negative') @@ -155,20 +121,18 @@ describe('MessageFeedbackController', () => { await expect(replaced).resolves.toEqual({ ok: true }) await expect(staleRetraction).resolves.toEqual({ ok: true }) - expect(calls.filter(call => call.method === 'put')).toHaveLength(1) - expect(calls.filter(call => call.method === 'delete')).toHaveLength(0) + expect(mock.remote.messageFeedback.put).toHaveBeenCalledTimes(1) + expect(mock.remote.messageFeedback.delete).not.toHaveBeenCalled() expect(controller.getSnapshot().items.get(MSG)).toEqual(replacement) }) it('reconciles a version conflict from the authoritative item without refetching', async () => { const authoritative = item({ version: version('v9'), rating: 'negative', note: 'changed elsewhere' }) - const { ctx, calls } = fakeRemote({ - put: () => Promise.resolve({ - ok: false, - error: { code: 'version-conflict', current: authoritative }, - }), - }) - const controller = new MessageFeedbackController(ctx, SESSION) + mock.remote.messageFeedback.put.mockResolvedValueOnce(ok({ + ok: false, + error: { code: 'version-conflict', current: authoritative }, + })) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) expect(await controller.rate(MSG, 'positive')).toEqual({ ok: false, @@ -176,18 +140,16 @@ describe('MessageFeedbackController', () => { }) expect(controller.getSnapshot().items.get(MSG)).toEqual(authoritative) - expect(calls.filter(call => call.method === 'list')).toHaveLength(1) + expect(mock.remote.messageFeedback.list).toHaveBeenCalledTimes(1) }) it('drops the local item when a conflict reports the feedback is gone', async () => { - const { ctx } = fakeRemote({ - list: () => Promise.resolve({ ok: true, value: { items: [item()] } }), - delete: () => Promise.resolve({ - ok: false, - error: { code: 'version-conflict', current: null }, - }), - }) - const controller = new MessageFeedbackController(ctx, SESSION) + mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ ok: true, value: { items: [item()] } })) + mock.remote.messageFeedback.delete.mockResolvedValueOnce(ok({ + ok: false, + error: { code: 'version-conflict', current: null }, + })) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) await controller.ensure() expect(await controller.retract(MSG, 'positive')).toMatchObject({ ok: false, error: { code: 'version-conflict' } }) @@ -195,16 +157,17 @@ describe('MessageFeedbackController', () => { }) it('deletes with the observed version and removes the item on success', async () => { - const { ctx, calls } = fakeRemote({ - list: () => Promise.resolve({ ok: true, value: { items: [item({ version: version('v7') })] } }), - }) - const controller = new MessageFeedbackController(ctx, SESSION) + mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ + ok: true, value: { items: [item({ version: version('v7') })] }, + })) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) await controller.ensure() expect(await controller.retract(MSG, 'positive')).toEqual({ ok: true }) - expect(calls.filter(call => call.method === 'delete')[0]?.request) - .toEqual({ sessionId: SESSION, messageId: MSG, ifVersion: version('v7') }) + expect(mock.remote.messageFeedback.delete).toHaveBeenCalledExactlyOnceWith({ + sessionId: SESSION, messageId: MSG, ifVersion: version('v7'), + }) expect(controller.getSnapshot().items.has(MSG)).toBe(false) }) @@ -213,32 +176,30 @@ describe('MessageFeedbackController', () => { let overlapped = false const versions = [version('v1'), version('v2')] let index = 0 - const { ctx, calls } = fakeRemote({ - put: async () => { - inFlight += 1 - if (inFlight > 1) overlapped = true - await Promise.resolve() - inFlight -= 1 - const next = versions[index] ?? version('vN') - index += 1 - return { ok: true, value: item({ version: next }) } - }, + mock.remote.messageFeedback.put.mockImplementation(async () => { + inFlight += 1 + if (inFlight > 1) overlapped = true + await Promise.resolve() + inFlight -= 1 + const next = versions[index] ?? version('vN') + index += 1 + return ok({ ok: true, value: item({ version: next }) }) }) - const controller = new MessageFeedbackController(ctx, SESSION) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) await Promise.all([controller.rate(MSG, 'positive'), controller.rate(MSG, 'negative')]) expect(overlapped).toBe(false) - const puts = calls.filter(call => call.method === 'put').map(call => call.request as Record) + const puts = mock.remote.messageFeedback.put.mock.calls.map(([request]) => request) expect(puts[0]?.ifVersion).toBeNull() expect(puts[1]?.ifVersion).toBe(version('v1')) }) it('publishes an error status when the list read is rejected by the Host', async () => { - const { ctx } = fakeRemote({ - list: () => Promise.resolve({ ok: false, error: { code: 'session-not-found', sessionId: SESSION } }), - }) - const controller = new MessageFeedbackController(ctx, SESSION) + mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ + ok: false, error: { code: 'session-not-found', sessionId: SESSION }, + })) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) expect(await controller.ensure()).toMatchObject({ ok: false, error: { code: 'session-not-found' } }) expect(controller.getSnapshot()).toMatchObject({ @@ -248,8 +209,7 @@ describe('MessageFeedbackController', () => { }) it('notifies subscribers on publication and stops after unsubscribe', async () => { - const { ctx } = fakeRemote() - const controller = new MessageFeedbackController(ctx, SESSION) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) const listener = vi.fn() const unsubscribe = controller.subscribe(listener) @@ -263,8 +223,7 @@ describe('MessageFeedbackController', () => { }) it('contains a throwing subscriber at the observable boundary', async () => { - const { ctx } = fakeRemote() - const controller = new MessageFeedbackController(ctx, SESSION) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) controller.subscribe(() => { throw new Error('subscriber exploded') }) const healthy = vi.fn() @@ -278,17 +237,16 @@ describe('MessageFeedbackController', () => { }) it('refuses mutations and stops publishing once disposed', async () => { - const { ctx, calls } = fakeRemote() - const controller = new MessageFeedbackController(ctx, SESSION) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) await controller.ensure() const listener = vi.fn() controller.subscribe(listener) controller.dispose() - const before = calls.length + const puts = mock.remote.messageFeedback.put.mock.calls.length expect(await controller.rate(MSG, 'positive')).toMatchObject({ ok: false, error: { code: 'disposed' } }) - expect(calls).toHaveLength(before) + expect(mock.remote.messageFeedback.put).toHaveBeenCalledTimes(puts) expect(listener).not.toHaveBeenCalled() }) @@ -300,20 +258,20 @@ describe('MessageFeedbackController', () => { ['note-too-large', 'the note is too long'], ] as const for (const [code, message] of codes) { - const { ctx } = fakeRemote({ - list: () => Promise.resolve({ ok: false, error: { code, sessionId: SESSION } } as never), - }) - const controller = new MessageFeedbackController(ctx, SESSION) + mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ + ok: false, error: { code, sessionId: SESSION }, + } as never)) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) expect(await controller.ensure()).toMatchObject({ ok: false, error: { code } }) expect(controller.getSnapshot().error).toBe(message) } }) it('falls back to the raw code for an unrecognized failure', async () => { - const { ctx } = fakeRemote({ - list: () => Promise.resolve({ ok: false, error: { code: 'brand-new-code' } } as never), - }) - const controller = new MessageFeedbackController(ctx, SESSION) + mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ + ok: false, error: { code: 'brand-new-code' }, + } as never)) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) expect(await controller.ensure()).toMatchObject({ ok: false, error: { code: 'brand-new-code' } }) expect(controller.getSnapshot().error).toBe('brand-new-code') @@ -322,13 +280,11 @@ describe('MessageFeedbackController', () => { it('publishes nothing when the list settles after disposal', async () => { let release = (): void => {} const gate = new Promise((resolve) => { release = resolve }) - const { ctx } = fakeRemote({ - list: async () => { - await gate - return { ok: true, value: { items: [item()] } } - }, + mock.remote.messageFeedback.list.mockImplementationOnce(async () => { + await gate + return ok({ ok: true, value: { items: [item()] } }) }) - const controller = new MessageFeedbackController(ctx, SESSION) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) const pending = controller.ensure() const listener = vi.fn() controller.subscribe(listener) @@ -342,30 +298,28 @@ describe('MessageFeedbackController', () => { }) it('propagates a failed load to a queued mutation without calling the wire', async () => { - const { ctx, calls } = fakeRemote({ - list: () => Promise.resolve({ ok: false, error: { code: 'session-not-found', sessionId: SESSION } }), - }) - const controller = new MessageFeedbackController(ctx, SESSION) + mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ + ok: false, error: { code: 'session-not-found', sessionId: SESSION }, + })) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) expect(await controller.rate(MSG, 'positive')).toMatchObject({ ok: false, error: { code: 'session-not-found' }, }) - expect(calls.filter(call => call.method === 'put')).toHaveLength(0) + expect(mock.remote.messageFeedback.put).not.toHaveBeenCalled() }) it('keeps a later mutation running after an earlier one settles as a failure', async () => { let first = true - const { ctx } = fakeRemote({ - put: () => { - if (first) { - first = false - return Promise.resolve({ ok: false, error: new RemoteError('gateway/internal', 'first blew up', {}) }) - } - return Promise.resolve({ ok: true, value: item({ rating: 'negative' }) }) - }, + mock.remote.messageFeedback.put.mockImplementation(() => { + if (first) { + first = false + return Promise.resolve({ ok: false as const, error: new RemoteError('gateway/internal', 'first blew up', {}) }) + } + return Promise.resolve(ok({ ok: true, value: item({ rating: 'negative' }) })) }) - const controller = new MessageFeedbackController(ctx, SESSION) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) const [a, b] = await Promise.all([ controller.rate(MSG, 'positive'), @@ -383,14 +337,17 @@ describe('MessageFeedbackController', () => { // fiber unloads, and its authoritative item must not be published. let release = (): void => {} const gate = new Promise((resolve) => { release = resolve }) - const { ctx } = fakeRemote({ - list: () => Promise.resolve({ ok: true, value: { items: [item({ version: version('v1') })] } }), - put: async () => { - await gate - return { ok: false, error: { code: 'version-conflict', current: item({ version: version('v2'), rating: 'negative' }) } } - }, + mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ + ok: true, value: { items: [item({ version: version('v1') })] }, + })) + mock.remote.messageFeedback.put.mockImplementationOnce(async () => { + await gate + return ok({ + ok: false, + error: { code: 'version-conflict', current: item({ version: version('v2'), rating: 'negative' }) }, + }) }) - const controller = new MessageFeedbackController(ctx, SESSION) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) await controller.ensure() const listener = vi.fn() controller.subscribe(listener) @@ -407,14 +364,12 @@ describe('MessageFeedbackController', () => { it('drops a delete conflict reconciliation once disposed mid-flight', async () => { let release = (): void => {} const gate = new Promise((resolve) => { release = resolve }) - const { ctx } = fakeRemote({ - list: () => Promise.resolve({ ok: true, value: { items: [item()] } }), - delete: async () => { - await gate - return { ok: false, error: { code: 'version-conflict', current: null } } - }, + mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ ok: true, value: { items: [item()] } })) + mock.remote.messageFeedback.delete.mockImplementationOnce(async () => { + await gate + return ok({ ok: false, error: { code: 'version-conflict', current: null } }) }) - const controller = new MessageFeedbackController(ctx, SESSION) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) await controller.ensure() const pending = controller.retract(MSG, 'positive') @@ -430,11 +385,11 @@ describe('MessageFeedbackController', () => { it('leaves the local item untouched when a rating fails for a non-conflict reason', async () => { const existing = item({ version: version('v3'), rating: 'positive' }) - const { ctx } = fakeRemote({ - list: () => Promise.resolve({ ok: true, value: { items: [existing] } }), - put: () => Promise.resolve({ ok: false, error: { code: 'note-too-large', maxBytes: 8, actualBytes: 9 } }), - }) - const controller = new MessageFeedbackController(ctx, SESSION) + mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ ok: true, value: { items: [existing] } })) + mock.remote.messageFeedback.put.mockResolvedValueOnce(ok({ + ok: false, error: { code: 'note-too-large', maxBytes: 8, actualBytes: 9 }, + })) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) await controller.ensure() expect(await controller.rate(MSG, 'negative', { text: 'far too long' })).toMatchObject({ @@ -446,11 +401,11 @@ describe('MessageFeedbackController', () => { it('leaves the local item untouched when a delete fails for a non-conflict reason', async () => { const existing = item({ version: version('v4') }) - const { ctx } = fakeRemote({ - list: () => Promise.resolve({ ok: true, value: { items: [existing] } }), - delete: () => Promise.resolve({ ok: false, error: { code: 'session-not-found', sessionId: SESSION } }), - }) - const controller = new MessageFeedbackController(ctx, SESSION) + mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ ok: true, value: { items: [existing] } })) + mock.remote.messageFeedback.delete.mockResolvedValueOnce(ok({ + ok: false, error: { code: 'session-not-found', sessionId: SESSION }, + })) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) await controller.ensure() expect(await controller.retract(MSG, 'positive')).toMatchObject({ @@ -466,19 +421,17 @@ describe('MessageFeedbackController', () => { const order: string[] = [] let releasePut = (): void => {} const putGate = new Promise((r) => { releasePut = r }) - const { ctx } = fakeRemote({ - list: () => { - order.push('list') - return Promise.resolve({ ok: true, value: { items: [item({ version: version('v1') })] } }) - }, - put: async () => { - order.push('put:start') - await putGate - order.push('put:end') - return { ok: true, value: item({ version: version('v9'), rating: 'negative' }) } - }, + mock.remote.messageFeedback.list.mockImplementation(() => { + order.push('list') + return Promise.resolve(ok({ ok: true, value: { items: [item({ version: version('v1') })] } })) }) - const controller = new MessageFeedbackController(ctx, SESSION) + mock.remote.messageFeedback.put.mockImplementationOnce(async () => { + order.push('put:start') + await putGate + order.push('put:end') + return ok({ ok: true, value: item({ version: version('v9'), rating: 'negative' }) }) + }) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) await controller.ensure() const rating = controller.rate(MSG, 'negative') @@ -498,14 +451,12 @@ describe('MessageFeedbackController', () => { const gate = new Promise((r) => { release = r }) let started = (): void => {} const listStarted = new Promise((r) => { started = r }) - const { ctx, calls } = fakeRemote({ - list: async () => { - started() - await gate - return { ok: true, value: { items: [] } } - }, + mock.remote.messageFeedback.list.mockImplementationOnce(async () => { + started() + await gate + return ok({ ok: true, value: { items: [] } }) }) - const controller = new MessageFeedbackController(ctx, SESSION) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) const pending = controller.rate(MSG, 'positive') await listStarted @@ -513,19 +464,17 @@ describe('MessageFeedbackController', () => { release() expect(await pending).toMatchObject({ ok: false, error: { code: 'disposed' } }) - expect(calls.filter(c => c.method === 'put')).toHaveLength(0) + expect(mock.remote.messageFeedback.put).not.toHaveBeenCalled() }) it('renders a carrier failure from the Remote envelope', async () => { // The generated face folds transport faults into ok:false with a // RemoteFailure, so the controller reads them as values, not rejections. - const { ctx } = fakeRemote({ - list: () => Promise.resolve({ - ok: false, - error: new RemoteError('gateway/internal', 'socket closed', {}), - }), + mock.remote.messageFeedback.list.mockResolvedValueOnce({ + ok: false, + error: new RemoteError('gateway/internal', 'socket closed', {}), }) - const controller = new MessageFeedbackController(ctx, SESSION) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) expect(await controller.ensure()).toEqual({ ok: false, @@ -535,13 +484,11 @@ describe('MessageFeedbackController', () => { }) it('renders a carrier failure on a mutation without touching the view', async () => { - const { ctx } = fakeRemote({ - put: () => Promise.resolve({ - ok: false, - error: new RemoteError('gateway/internal', 'socket closed', {}), - }), + mock.remote.messageFeedback.put.mockResolvedValueOnce({ + ok: false, + error: new RemoteError('gateway/internal', 'socket closed', {}), }) - const controller = new MessageFeedbackController(ctx, SESSION) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) expect(await controller.rate(MSG, 'positive')).toEqual({ ok: false, @@ -551,14 +498,12 @@ describe('MessageFeedbackController', () => { }) it('renders a carrier failure on a delete', async () => { - const { ctx } = fakeRemote({ - list: () => Promise.resolve({ ok: true, value: { items: [item()] } }), - delete: () => Promise.resolve({ - ok: false, - error: new RemoteError('gateway/internal', 'socket closed', {}), - }), + mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ ok: true, value: { items: [item()] } })) + mock.remote.messageFeedback.delete.mockResolvedValueOnce({ + ok: false, + error: new RemoteError('gateway/internal', 'socket closed', {}), }) - const controller = new MessageFeedbackController(ctx, SESSION) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) await controller.ensure() expect(await controller.retract(MSG, 'positive')).toMatchObject({ ok: false, error: { code: 'gateway/internal' } }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 197c8b792e..40a5e7b7eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3127,6 +3127,9 @@ importers: '@deepseek-ai/dsh-message-feedback': specifier: workspace:^ version: link:../../feedback/message-feedback + '@deepseek-ai/dsh-remote-mock': + specifier: workspace:^ + version: link:../../test-support/remote-mock '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session