assembly-remote-proxies.client.spec.ts 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
  1. /** Remote proxies: namespace discovery from roster injects and mock rules, and per-call routing over the Connection to the mock. */
  2. import { RemoteMock, frames, ok, openStream } from '@deepseek-ai/dsh-remote-mock'
  3. import { describe, expect, it, onTestFinished } from 'vitest'
  4. import type { ClientPluginModule } from '../src/assembly/index.ts'
  5. import { TestClient, remoteDefaultResponses, webApp } from '../src/assembly/index.ts'
  6. import { remoteNamespacesOf } from '../src/assembly/remote-proxies.ts'
  7. /** The api-remotes row and its cone (Gateway client, Typert registry, Connection); TestClient drops the api-remotes row itself. */
  8. const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-remotes'])
  9. type RemoteFace = Record<string, Record<string, (...args: unknown[]) => unknown>>
  10. async function drain(source: AsyncIterable<unknown>): Promise<unknown[]> {
  11. const items: unknown[] = []
  12. for await (const item of source) items.push(item)
  13. return items
  14. }
  15. describe('remoteNamespacesOf', () => {
  16. it('collects remote.<ns> injects in both inject forms plus the namespaces of registered endpoints, skipping Gateway-internal ones', () => {
  17. const modules: ClientPluginModule[] = [
  18. { apply() {}, inject: ['slots', 'remote.settings', 'remote'] },
  19. { apply() {}, inject: { 'remote.session': { required: true }, connection: { required: false } } },
  20. { apply() {} },
  21. ]
  22. const mock = RemoteMock.create().unary('goals/create', ok({ id: 'g' })).stream('workspace/follow', openStream())
  23. expect(remoteNamespacesOf(modules, mock)).toEqual(['goals', 'session', 'settings', 'workspace'])
  24. })
  25. })
  26. describe('remote proxies over a booted client', () => {
  27. it('boots the Office Remote subset from source and routes calls through its mock', async () => {
  28. const name = '@deepseek-ai/dsh-api-remotes'
  29. const mock = RemoteMock.create().load(remoteDefaultResponses).unary('officeToPdf/render', ok({ bytes: 3 }))
  30. const client = await TestClient.start({ roster: webApp.closure([name]) }, mock)
  31. onTestFinished(() => client.dispose())
  32. expect([...client.ctx.loader.entries()].map(entry => entry.options.name)).not.toContain(name)
  33. const remote = (client.ctx as unknown as { remote: RemoteFace }).remote
  34. await expect(remote.officeToPdf!.render!({ path: 'report.docx' })).resolves.toEqual(ok({ bytes: 3 }))
  35. expect(mock.log.calls('officeToPdf/render').map(call => call.args)).toEqual([[{ path: 'report.docx' }]])
  36. })
  37. async function booted(configure: (mock: RemoteMock) => void = () => {}) {
  38. const mock = RemoteMock.create().load(remoteDefaultResponses)
  39. configure(mock)
  40. const client = await TestClient.start({ roster: API_ROSTER }, mock)
  41. onTestFinished(() => client.dispose())
  42. return { client, mock, remote: (client.ctx as unknown as { remote: RemoteFace }).remote }
  43. }
  44. it('drops the api-remotes row and answers ctx.remote.<ns>.<method> with the mock value, logging positional args', async () => {
  45. const { client, mock, remote } = await booted((m) => {
  46. m.unary('session/rename', ok({ title: 'renamed', seq: 3 }))
  47. m.unary('session/cancel', { ok: false, error: { code: 'session/not-found', message: 'gone', details: {} } })
  48. })
  49. expect([...client.ctx.loader.entries()].map(entry => entry.options.name)).not.toContain('@deepseek-ai/dsh-api-remotes')
  50. await expect(remote.session!.rename!({ sessionId: 's1', title: 'renamed' })).resolves.toEqual({ ok: true, value: { title: 'renamed', seq: 3 } })
  51. await expect(remote.session!.cancel!({ sessionId: 's1' }, new AbortController().signal))
  52. .resolves.toEqual({ ok: false, error: { code: 'session/not-found', message: 'gone', details: {} } })
  53. expect(mock.log.calls('session/rename').map(call => call.args)).toEqual([[{ sessionId: 's1', title: 'renamed' }]])
  54. expect(mock.log.calls('session/cancel').map(call => call.args)).toEqual([[{ sessionId: 's1' }]])
  55. expect((remote.session as unknown as { then?: unknown }).then).toBeUndefined()
  56. expect((remote.session as unknown as Record<symbol, unknown>)[Symbol.toStringTag]).toBeUndefined()
  57. }, 60_000)
  58. it('folds a call without a rule into gateway/internal and leaves the miss in the log for dispose() to report', async () => {
  59. const mock = RemoteMock.create().load(remoteDefaultResponses)
  60. const client = await TestClient.start({ roster: API_ROSTER }, mock)
  61. const remote = (client.ctx as unknown as { remote: RemoteFace }).remote
  62. await expect(remote.session!.search!({ query: 'x' })).resolves.toMatchObject({
  63. ok: false,
  64. error: { code: 'gateway/internal', message: expect.stringMatching(/^client api: session\/search failed: remote-mock: no rule for session\/search; registered: \$events, /) as string },
  65. })
  66. expect(mock.log.unmatched()).toEqual([{ endpoint: 'session/search', mode: 'unary' }])
  67. await expect(client.dispose()).rejects.toThrow('session/search (unary)')
  68. })
  69. it('folds a rule rejection like the generated client: gateway/internal, or gateway/cancelled once the signal aborted', async () => {
  70. const { mock, remote } = await booted((m) => {
  71. m.unary('session/rename', () => Promise.reject(new Error('wire down')))
  72. m.unary('session/cancel', () => new Promise(() => {}))
  73. })
  74. await expect(remote.session!.rename!({ sessionId: 's1', title: 't' })).resolves.toMatchObject({
  75. ok: false, error: { code: 'gateway/internal', message: 'client api: session/rename failed: wire down' },
  76. })
  77. const controller = new AbortController()
  78. const cancelling = remote.session!.cancel!({ sessionId: 's1' }, controller.signal) as Promise<unknown>
  79. controller.abort()
  80. await expect(cancelling).resolves.toMatchObject({ ok: false, error: { code: 'gateway/cancelled' } })
  81. expect(mock.log.calls('session/rename').map(call => call.state)).toEqual(['failed'])
  82. expect(mock.log.calls('session/cancel').map(call => call.state)).toEqual(['pending'])
  83. })
  84. it('opens registered streams and hands their items and failures through unchanged', async () => {
  85. const { remote } = await booted((m) => {
  86. m.stream('session/follow', frames([{ type: 'a' }, { type: 'b' }]))
  87. m.stream('session/control', ((_args, stream) => { stream.fail(new Error('flap')) }))
  88. })
  89. const signal = new AbortController().signal
  90. await expect(drain(remote.session!.follow!({ address: { kind: 'session', sessionId: 's1' } }, signal) as AsyncIterable<unknown>))
  91. .resolves.toEqual([{ type: 'a' }, { type: 'b' }])
  92. await expect(drain(remote.session!.control!({}) as AsyncIterable<unknown>)).rejects.toThrow('flap')
  93. })
  94. })