plugin.client.spec.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462
  1. // @vitest-environment jsdom
  2. import { Context } from '@deepseek-ai/cordis'
  3. import { afterEach, describe, expect, it, vi } from 'vitest'
  4. import { apply } from '../src/client/index.ts'
  5. import { ClientRealmSource } from '../src/client/inspection/realm.ts'
  6. import type { InspectorClientBootstrap } from '../src/shared/bridge/messages/control.ts'
  7. class FakeWebSocket extends EventTarget {
  8. static readonly CONNECTING = 0
  9. static readonly OPEN = 1
  10. static readonly CLOSING = 2
  11. static readonly CLOSED = 3
  12. static readonly sockets: FakeWebSocket[] = []
  13. readonly sent: string[] = []
  14. readonly url: string
  15. readonly protocol: string
  16. readyState = FakeWebSocket.CONNECTING
  17. bufferedAmount = 0
  18. constructor(url: string | URL, protocols?: string | string[]) {
  19. super()
  20. this.url = String(url)
  21. this.protocol = typeof protocols === 'string' ? protocols : protocols?.[0] ?? ''
  22. FakeWebSocket.sockets.push(this)
  23. }
  24. send(data: string): void {
  25. this.sent.push(data)
  26. }
  27. close(): void {
  28. if (this.readyState === FakeWebSocket.CLOSED) return
  29. this.readyState = FakeWebSocket.CLOSED
  30. this.dispatchEvent(new Event('close'))
  31. }
  32. open(): void {
  33. this.readyState = FakeWebSocket.OPEN
  34. this.dispatchEvent(new Event('open'))
  35. }
  36. receive(value: unknown): void {
  37. this.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(value) }))
  38. }
  39. }
  40. const bootstrap: InspectorClientBootstrap = {
  41. endpoint: 'ws://127.0.0.1:9230/ingest',
  42. protocol: 'dsh-inspector-v0-token',
  43. maxQueuedRecords: 16,
  44. maxQueuedBytes: 16_384,
  45. maxRecordsPerFrame: 8,
  46. maxFrameBytes: 32_768,
  47. reconnectBaseMs: 10,
  48. reconnectMaxMs: 20,
  49. queryTimeoutMs: 100,
  50. maxRuntimeObjectsPerSession: 100,
  51. maxRuntimePropertiesPerResult: 100,
  52. maxClientSourceBytes: 1_048_576,
  53. maxCordisNodes: 100,
  54. }
  55. describe('experimental Inspector Client plugin', () => {
  56. const nativeWebSocket = globalThis.WebSocket
  57. const nativeFetch = globalThis.fetch
  58. afterEach(() => {
  59. vi.restoreAllMocks()
  60. FakeWebSocket.sockets.length = 0
  61. globalThis.WebSocket = nativeWebSocket
  62. globalThis.fetch = nativeFetch
  63. sessionStorage.clear()
  64. delete globalThis.__DSH_INSPECTOR__
  65. Reflect.deleteProperty(globalThis, '__DSH_BOOT__')
  66. })
  67. it('provides ctx.inspector and sends observations after the Worker accepts the source', async () => {
  68. globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket
  69. globalThis.__DSH_INSPECTOR__ = bootstrap
  70. const ctx = new Context()
  71. const fiber = ctx.plugin({ apply })
  72. await fiber.await()
  73. const socket = FakeWebSocket.sockets[0]!
  74. expect(socket.url).toBe(bootstrap.endpoint)
  75. expect(socket.protocol).toBe(bootstrap.protocol)
  76. socket.open()
  77. const open = JSON.parse(socket.sent[0]!) as {
  78. source: { sourceId: string; generation: string }
  79. }
  80. socket.receive({
  81. v: 0,
  82. t: 'source/accepted',
  83. sourceId: open.source.sourceId,
  84. generation: open.source.generation,
  85. })
  86. expect(JSON.parse(socket.sent[1]!) as unknown).toMatchObject({
  87. t: 'source/replace',
  88. records: [{ topic: 'cordis/tree', payload: { schemaVersion: 0, truncated: false } }],
  89. })
  90. const treePromise = ctx.inspector.cordis.getTree()
  91. const treeRequest = socket.sent.map(value => JSON.parse(value) as { t: string; requestId?: string })
  92. .find(frame => frame.t === 'query/request')
  93. expect(treeRequest?.requestId).toBeTypeOf('string')
  94. socket.receive({
  95. v: 0,
  96. t: 'query/response',
  97. sourceId: open.source.sourceId,
  98. generation: open.source.generation,
  99. requestId: treeRequest!.requestId,
  100. outcome: {
  101. ok: true,
  102. result: { op: 'cordis-tree/get', tree: { schemaVersion: 0, host: null, clients: [] } },
  103. },
  104. })
  105. await expect(treePromise).resolves.toEqual({ schemaVersion: 0, host: null, clients: [] })
  106. ctx.inspector.publish('client/probe', { ready: true }, 7)
  107. const append = socket.sent.map(value => JSON.parse(value) as {
  108. t: string
  109. records: Array<{ topic: string; monotonicMs: number; payload: unknown }>
  110. }).find(frame => frame.t === 'source/append'
  111. && frame.records.some(record => record.topic === 'client/probe'))
  112. expect(append).toMatchObject({
  113. t: 'source/append',
  114. records: [{ topic: 'client/probe', monotonicMs: 7, payload: { ready: true } }],
  115. })
  116. document.title = 'Inspector Client Realm'
  117. socket.receive({
  118. v: 0,
  119. t: 'client-runtime/request',
  120. sourceId: open.source.sourceId,
  121. generation: open.source.generation,
  122. sessionId: 'devtools-1',
  123. requestId: 'runtime-1',
  124. command: { op: 'evaluate', expression: 'document.title', returnByValue: true },
  125. })
  126. await vi.waitFor(() => {
  127. const response = socket.sent.map(value => JSON.parse(value) as { requestId?: string })
  128. .find(frame => frame.requestId === 'runtime-1')
  129. expect(response).toMatchObject({
  130. t: 'client-runtime/response',
  131. sessionId: 'devtools-1',
  132. requestId: 'runtime-1',
  133. outcome: {
  134. ok: true,
  135. result: { op: 'evaluate', completion: { result: { descriptor: { value: 'Inspector Client Realm' } } } },
  136. },
  137. })
  138. })
  139. await fiber.dispose()
  140. expect(JSON.parse(socket.sent.at(-1)!)).toMatchObject({ t: 'source/close' })
  141. expect(socket.readyState).toBe(FakeWebSocket.CLOSED)
  142. })
  143. it('keeps the realm source id and rotates the transport generation on reconnect', async () => {
  144. globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket
  145. globalThis.__DSH_INSPECTOR__ = bootstrap
  146. const ctx = new Context()
  147. const fiber = ctx.plugin({ apply })
  148. await fiber.await()
  149. const firstSocket = FakeWebSocket.sockets[0]!
  150. firstSocket.open()
  151. const firstOpen = JSON.parse(firstSocket.sent[0]!) as {
  152. source: { sourceId: string; generation: string }
  153. }
  154. firstSocket.close()
  155. await vi.waitFor(() => { expect(FakeWebSocket.sockets).toHaveLength(2) })
  156. const secondSocket = FakeWebSocket.sockets[1]!
  157. secondSocket.open()
  158. const secondOpen = JSON.parse(secondSocket.sent[0]!) as {
  159. source: { sourceId: string; generation: string }
  160. }
  161. expect(secondOpen.source.sourceId).toBe(firstOpen.source.sourceId)
  162. expect(secondOpen.source.generation).not.toBe(firstOpen.source.generation)
  163. await fiber.dispose()
  164. })
  165. it('keeps the logical source id when the Client plugin is recreated after a page refresh', async () => {
  166. globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket
  167. globalThis.__DSH_INSPECTOR__ = bootstrap
  168. const firstContext = new Context()
  169. const firstFiber = firstContext.plugin({ apply })
  170. await firstFiber.await()
  171. const firstSocket = FakeWebSocket.sockets[0]!
  172. firstSocket.open()
  173. const firstOpen = JSON.parse(firstSocket.sent[0]!) as {
  174. source: { sourceId: string; generation: string }
  175. }
  176. await firstFiber.dispose()
  177. const secondContext = new Context()
  178. const secondFiber = secondContext.plugin({ apply })
  179. await secondFiber.await()
  180. const secondSocket = FakeWebSocket.sockets[1]!
  181. secondSocket.open()
  182. const secondOpen = JSON.parse(secondSocket.sent[0]!) as {
  183. source: { sourceId: string; generation: string }
  184. }
  185. expect(secondOpen.source.sourceId).toBe(firstOpen.source.sourceId)
  186. expect(secondOpen.source.generation).not.toBe(firstOpen.source.generation)
  187. await secondFiber.dispose()
  188. })
  189. it('rotates a copied session identity while its original page remains live', async () => {
  190. const descriptor = Object.getOwnPropertyDescriptor(navigator, 'locks')
  191. const held = new Set<string>()
  192. const request = async (
  193. name: string,
  194. _options: LockOptions,
  195. callback: (lock: Lock | null) => unknown,
  196. ): Promise<unknown> => {
  197. const acquired = !held.has(name)
  198. if (acquired) held.add(name)
  199. try {
  200. return await callback(acquired ? { name, mode: 'exclusive' } : null)
  201. } finally {
  202. if (acquired) held.delete(name)
  203. }
  204. }
  205. Object.defineProperty(navigator, 'locks', {
  206. configurable: true,
  207. value: { request },
  208. })
  209. let first: ClientRealmSource | undefined
  210. let duplicate: ClientRealmSource | undefined
  211. let refreshed: ClientRealmSource | undefined
  212. try {
  213. first = await ClientRealmSource.claim('first')
  214. duplicate = await ClientRealmSource.claim('duplicate')
  215. expect(duplicate.sourceId).not.toBe(first.sourceId)
  216. first.close()
  217. await vi.waitFor(() => { expect(held.size).toBe(1) })
  218. sessionStorage.setItem('dsh.experimental-inspector.client-source-id.v0', first.sourceId)
  219. refreshed = await ClientRealmSource.claim('refreshed')
  220. expect(refreshed.sourceId).toBe(first.sourceId)
  221. } finally {
  222. first?.close()
  223. duplicate?.close()
  224. refreshed?.close()
  225. if (descriptor === undefined) Reflect.deleteProperty(navigator, 'locks')
  226. else Object.defineProperty(navigator, 'locks', descriptor)
  227. }
  228. })
  229. it('falls back to a page-lifetime source id when session storage is unavailable', async () => {
  230. globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket
  231. globalThis.__DSH_INSPECTOR__ = bootstrap
  232. vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
  233. throw new DOMException('storage disabled', 'SecurityError')
  234. })
  235. const ctx = new Context()
  236. const fiber = ctx.plugin({ apply })
  237. await fiber.await()
  238. const socket = FakeWebSocket.sockets[0]!
  239. socket.open()
  240. const open = JSON.parse(socket.sent[0]!) as { source: { sourceId: string } }
  241. expect(open.source.sourceId).toMatch(/^client-/u)
  242. await fiber.dispose()
  243. })
  244. it('cancels an outstanding Client Runtime operation without sending a late response', async () => {
  245. globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket
  246. globalThis.__DSH_INSPECTOR__ = bootstrap
  247. const ctx = new Context()
  248. const fiber = ctx.plugin({ apply })
  249. await fiber.await()
  250. const socket = FakeWebSocket.sockets[0]!
  251. socket.open()
  252. const open = JSON.parse(socket.sent[0]!) as {
  253. source: { sourceId: string; generation: string }
  254. }
  255. socket.receive({
  256. v: 0,
  257. t: 'source/accepted',
  258. sourceId: open.source.sourceId,
  259. generation: open.source.generation,
  260. })
  261. socket.receive({
  262. v: 0,
  263. t: 'client-runtime/request',
  264. sourceId: open.source.sourceId,
  265. generation: open.source.generation,
  266. sessionId: 'devtools-cancel',
  267. requestId: 'runtime-cancel',
  268. command: { op: 'evaluate', expression: 'new Promise(() => {})', awaitPromise: true },
  269. })
  270. socket.receive({
  271. v: 0,
  272. t: 'client-runtime/cancel',
  273. sourceId: open.source.sourceId,
  274. generation: open.source.generation,
  275. sessionId: 'devtools-cancel',
  276. requestId: 'runtime-cancel',
  277. })
  278. await new Promise(resolve => setTimeout(resolve, 0))
  279. expect(socket.sent.map(value => JSON.parse(value) as { requestId?: string })
  280. .some(frame => frame.requestId === 'runtime-cancel')).toBe(false)
  281. socket.receive({
  282. v: 0,
  283. t: 'client-runtime/request',
  284. sourceId: open.source.sourceId,
  285. generation: open.source.generation,
  286. sessionId: 'devtools-cancel',
  287. requestId: 'runtime-after-cancel',
  288. command: { op: 'evaluate', expression: '42', returnByValue: true },
  289. })
  290. await vi.waitFor(() => {
  291. expect(socket.sent.map(value => JSON.parse(value) as { requestId?: string })
  292. .some(frame => frame.requestId === 'runtime-after-cancel')).toBe(true)
  293. })
  294. await fiber.dispose()
  295. })
  296. it('does not report queue loss again after a replacement absorbs it', async () => {
  297. globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket
  298. globalThis.__DSH_INSPECTOR__ = { ...bootstrap, maxQueuedRecords: 1 }
  299. const ctx = new Context()
  300. const fiber = ctx.plugin({ apply })
  301. await fiber.await()
  302. const socket = FakeWebSocket.sockets[0]!
  303. ctx.inspector.publish('client/first', { ordinal: 1 })
  304. ctx.inspector.publish('client/second', { ordinal: 2 })
  305. socket.open()
  306. const open = JSON.parse(socket.sent[0]!) as {
  307. source: { sourceId: string; generation: string }
  308. }
  309. socket.receive({
  310. v: 0,
  311. t: 'source/accepted',
  312. sourceId: open.source.sourceId,
  313. generation: open.source.generation,
  314. })
  315. const replacement = JSON.parse(socket.sent[1]!) as { nextSequence: number }
  316. const append = JSON.parse(socket.sent[2]!) as {
  317. firstSequence: number
  318. droppedBefore: number
  319. records: Array<{ topic: string }>
  320. }
  321. expect(append).toMatchObject({
  322. firstSequence: replacement.nextSequence,
  323. droppedBefore: 0,
  324. records: [{ topic: 'client/second' }],
  325. })
  326. await fiber.dispose()
  327. })
  328. it('discovers and serves its built Client bundle through the source protocol', async () => {
  329. globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket
  330. globalThis.__DSH_INSPECTOR__ = bootstrap
  331. Reflect.set(globalThis, '__DSH_BOOT__', {
  332. rev: 'graph',
  333. entries: [{
  334. id: '@deepseek-ai/dsh-experimental-inspector',
  335. url: '/plugins/@deepseek-ai/dsh-experimental-inspector/client.js?rev=bundle-rev',
  336. rev: 'bundle-rev',
  337. }],
  338. })
  339. const source = 'const clientBundleMarker = "你好"\n'
  340. const sourceMap = '{"version":3,"sources":["client/index.ts"]}'
  341. globalThis.fetch = vi.fn(async (input: string | URL | Request) => {
  342. const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
  343. return new Response(url.includes('.js.map') ? sourceMap : source)
  344. })
  345. const ctx = new Context()
  346. const fiber = ctx.plugin({ apply })
  347. await fiber.await()
  348. const socket = FakeWebSocket.sockets[0]!
  349. socket.open()
  350. const open = JSON.parse(socket.sent[0]!) as {
  351. source: { sourceId: string; generation: string; capabilities: Array<{ type: string }> }
  352. }
  353. expect(open.source.capabilities).toEqual(expect.arrayContaining([{ type: 'client-sources' }]))
  354. socket.receive({
  355. v: 0,
  356. t: 'source/accepted',
  357. sourceId: open.source.sourceId,
  358. generation: open.source.generation,
  359. })
  360. socket.receive({
  361. v: 0,
  362. t: 'client-sources/request',
  363. sourceId: open.source.sourceId,
  364. generation: open.source.generation,
  365. sessionId: 'source-session-1',
  366. requestId: 'source-request-1',
  367. command: { op: 'list-scripts' },
  368. })
  369. let scriptKey: string | undefined
  370. await vi.waitFor(() => {
  371. const response = socket.sent.map(value => JSON.parse(value) as {
  372. requestId?: string
  373. outcome?: { result?: { scripts?: Array<{ scriptKey: string; url: string; sourceMapUrl: string }> } }
  374. }).find(frame => frame.requestId === 'source-request-1')
  375. const script = response?.outcome?.result?.scripts?.[0]
  376. expect(script?.url).toContain('/plugins/@deepseek-ai/dsh-experimental-inspector/client.js?rev=bundle-rev')
  377. expect(script?.sourceMapUrl)
  378. .toContain('/plugins/@deepseek-ai/dsh-experimental-inspector/client.js.map?rev=bundle-rev')
  379. scriptKey = script?.scriptKey
  380. })
  381. socket.receive({
  382. v: 0,
  383. t: 'client-sources/request',
  384. sourceId: open.source.sourceId,
  385. generation: open.source.generation,
  386. sessionId: 'source-session-1',
  387. requestId: 'source-request-2',
  388. command: { op: 'get-content-chunk', scriptKey, content: 'source', offset: 0, maxBytes: 1_024 },
  389. })
  390. await vi.waitFor(() => {
  391. const response = socket.sent.map(value => JSON.parse(value) as {
  392. requestId?: string
  393. outcome?: { result?: { data?: string; eof?: boolean } }
  394. }).find(frame => frame.requestId === 'source-request-2')
  395. expect(response?.outcome?.result?.eof).toBe(true)
  396. const bytes = Uint8Array.from(atob(response?.outcome?.result?.data ?? ''), character => character.charCodeAt(0))
  397. expect(new TextDecoder().decode(bytes)).toBe(source)
  398. })
  399. await fiber.dispose()
  400. })
  401. it('fails loud when the Host did not inject a bootstrap', async () => {
  402. globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket
  403. const ctx = new Context()
  404. const fiber = ctx.plugin({ apply })
  405. await expect(fiber).rejects.toThrow('Host bootstrap is missing')
  406. await fiber.dispose()
  407. })
  408. it('closes the Client source when a later plugin registration fails', async () => {
  409. globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket
  410. globalThis.__DSH_INSPECTOR__ = bootstrap
  411. const ctx = new Context()
  412. ctx.provide('inspector', {
  413. publish: () => undefined,
  414. cordis: { getTree: () => Promise.reject(new Error('unused test service')) },
  415. })
  416. const fiber = ctx.plugin({ apply })
  417. await expect(fiber.await()).rejects.toThrow('service "inspector" has been registered')
  418. expect(FakeWebSocket.sockets).toHaveLength(1)
  419. expect(FakeWebSocket.sockets[0]?.readyState).toBe(FakeWebSocket.CLOSED)
  420. await fiber.dispose()
  421. })
  422. })