plugin.host.spec.ts 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136
  1. import { createServer } from 'node:http'
  2. import type { AddressInfo } from 'node:net'
  3. import { Context } from '@deepseek-ai/cordis'
  4. import type { IndexInjection, WebServer } from '@deepseek-ai/dsh-host-webserver'
  5. import WebSocket, { type RawData } from 'ws'
  6. import { afterEach, describe, expect, it, vi } from 'vitest'
  7. import { apply, Config, inject, name, startInspector } from '../src/index.ts'
  8. import { isPlainObject } from '../src/shared/json.ts'
  9. interface CdpResponse {
  10. readonly id: number
  11. readonly result?: Record<string, unknown>
  12. }
  13. describe('experimental Inspector Host plugin', () => {
  14. let context: Context | undefined
  15. afterEach(async () => {
  16. await context?.fiber.dispose()
  17. context = undefined
  18. vi.restoreAllMocks()
  19. })
  20. it('starts the Worker, provides ctx.inspector, injects Client bootstrap, and disposes', async () => {
  21. context = new Context()
  22. const log = vi.spyOn(console, 'log').mockImplementation(() => undefined)
  23. context.provide('webServer', {} as WebServer)
  24. const fiber = context.plugin(
  25. { name, inject: [...inject], Config, apply },
  26. { port: 0, captureFetch: false },
  27. )
  28. await fiber.await()
  29. const rows: IndexInjection[] = []
  30. context.emit('webserver/index-inject', rows)
  31. const bootstrap = rows.find(row => row.kind === 'global' && row.name === '__DSH_INSPECTOR__')
  32. expect(bootstrap).toMatchObject({ kind: 'global', name: '__DSH_INSPECTOR__' })
  33. expect(log).toHaveBeenCalledWith(expect.stringMatching(/^dsh inspector: devtools:\/\//u))
  34. expect(context.inspector).toBeDefined()
  35. await vi.waitFor(async () => {
  36. const tree = await context!.inspector.cordis.getTree()
  37. expect(tree.host?.source.kind).toBe('host')
  38. })
  39. expect(() => { context!.inspector.publish('', {}) }).toThrow('topic must contain 1 to 128 characters')
  40. expect(() => { context!.inspector.publish('host/invalid-time', {}, Number.NaN) }).toThrow('monotonicMs must be finite')
  41. context.inspector.publish('host/plugin-probe', { ready: true })
  42. const value = bootstrap?.kind === 'global' ? bootstrap.value : undefined
  43. const endpoint = value as { endpoint: string; protocol: string }
  44. const authority = new URL(endpoint.endpoint)
  45. const targets: unknown = await fetch(`http://${authority.host}/json`).then(response => response.json())
  46. if (!Array.isArray(targets) || !isPlainObject(targets[0]) || typeof targets[0].webSocketDebuggerUrl !== 'string') {
  47. throw new Error('Inspector discovery did not return a target')
  48. }
  49. const socket = new WebSocket(targets[0].webSocketDebuggerUrl)
  50. await new Promise<void>((resolve, reject) => {
  51. socket.once('open', () => { resolve() })
  52. socket.once('error', reject)
  53. })
  54. const response = new Promise<CdpResponse>((resolve) => {
  55. socket.on('message', (data) => {
  56. const message = JSON.parse(rawText(data)) as CdpResponse
  57. if (message.id === 1) resolve(message)
  58. })
  59. })
  60. socket.send(JSON.stringify({ id: 1, method: 'DSHInspector.getSources' }))
  61. await vi.waitFor(async () => {
  62. const sources = (await response).result?.sources as Array<{ topics: Record<string, number> }>
  63. expect(sources.some(source => source.topics['host/plugin-probe'] === 1)).toBe(true)
  64. })
  65. socket.close()
  66. await new Promise<void>((resolve) => { socket.once('close', () => { resolve() }) })
  67. await fiber.dispose()
  68. expect(rows).toHaveLength(1)
  69. const afterDispose: IndexInjection[] = []
  70. context.emit('webserver/index-inject', afterDispose)
  71. expect(afterDispose).toEqual([])
  72. })
  73. it('closes the started Worker when a later plugin registration fails', async () => {
  74. const port = await availablePort()
  75. context = new Context()
  76. context.provide('webServer', {} as WebServer)
  77. context.provide('inspector', {
  78. publish: () => undefined,
  79. cordis: { getTree: () => Promise.reject(new Error('unused test service')) },
  80. })
  81. const fiber = context.plugin(
  82. { name, inject: [...inject], Config, apply },
  83. { port, captureFetch: false },
  84. )
  85. await expect(fiber.await()).rejects.toThrow('service "inspector" has been registered')
  86. const replacement = await startInspector({ port, captureFetch: false })
  87. expect(new URL(replacement.endpoint.httpUrl).port).toBe(String(port))
  88. await replacement.close()
  89. })
  90. it('closes the Worker when fetch capture installation fails', async () => {
  91. const port = await availablePort()
  92. const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'fetch')
  93. const nativeFetch = globalThis.fetch
  94. Object.defineProperty(globalThis, 'fetch', {
  95. configurable: true,
  96. get: () => nativeFetch,
  97. })
  98. try {
  99. await expect(startInspector({ port })).rejects.toThrow('globalThis.fetch is an accessor')
  100. } finally {
  101. if (descriptor === undefined) Reflect.deleteProperty(globalThis, 'fetch')
  102. else Object.defineProperty(globalThis, 'fetch', descriptor)
  103. }
  104. const replacement = await startInspector({ port, captureFetch: false })
  105. expect(new URL(replacement.endpoint.httpUrl).port).toBe(String(port))
  106. await replacement.close()
  107. })
  108. })
  109. async function availablePort(): Promise<number> {
  110. const server = createServer()
  111. await new Promise<void>((resolve) => { server.listen(0, '127.0.0.1', resolve) })
  112. const port = (server.address() as AddressInfo).port
  113. await new Promise<void>((resolve, reject) => {
  114. server.close((error) => { if (error === undefined) resolve(); else reject(error) })
  115. })
  116. return port
  117. }
  118. function rawText(data: RawData): string {
  119. if (Array.isArray(data)) return Buffer.concat(data).toString('utf8')
  120. if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8')
  121. return Buffer.from(data).toString('utf8')
  122. }