protocol.host.spec.ts 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  1. /** Worker and shared protocol behavior. */
  2. import { describe, expect, it, vi } from 'vitest'
  3. import { INSPECTOR_PROTOCOL_VERSION, parseSourceFrame, parseWorkerSourceFrame } from '../src/shared/bridge/messages/observation.ts'
  4. import { InspectorSourceRegistry, type InspectorRecordConsumer, type SourceConnection } from '../src/worker/bridge/hub.ts'
  5. describe('Inspector source protocol', () => {
  6. it('rebuilds a valid source frame and rejects non-JSON payloads', () => {
  7. const frame = parseSourceFrame({
  8. v: INSPECTOR_PROTOCOL_VERSION,
  9. t: 'source/append',
  10. sourceId: 'host-1',
  11. generation: 'generation-1',
  12. firstSequence: 1,
  13. droppedBefore: 0,
  14. records: [{ monotonicMs: 12, topic: 'probe', payload: { ok: true } }],
  15. }, 4)
  16. expect(frame.t).toBe('source/append')
  17. expect(() => parseSourceFrame({
  18. v: INSPECTOR_PROTOCOL_VERSION,
  19. t: 'source/append',
  20. sourceId: 'host-1',
  21. generation: 'generation-1',
  22. firstSequence: 1,
  23. droppedBefore: 0,
  24. records: [{ monotonicMs: 12, topic: 'probe', payload: { bad: undefined } }],
  25. }, 4)).toThrow('lossless JSON object')
  26. })
  27. it('isolates generations and reports sequence gaps', () => {
  28. const replace = vi.fn()
  29. const append = vi.fn()
  30. const close = vi.fn()
  31. const consumer: InspectorRecordConsumer = {
  32. topics: new Set(['probe']),
  33. replace,
  34. append,
  35. close,
  36. }
  37. const replies: unknown[] = []
  38. const send = vi.fn((frame: unknown) => { replies.push(frame) })
  39. const closeConnection = vi.fn()
  40. const connection: SourceConnection = {
  41. kind: 'host',
  42. send,
  43. close: closeConnection,
  44. }
  45. const registry = new InspectorSourceRegistry([consumer], 16_384, 4)
  46. registry.receive(connection, {
  47. v: 0,
  48. t: 'source/open',
  49. source: {
  50. sourceId: 'host-1',
  51. generation: 'g-1',
  52. kind: 'host',
  53. label: 'Host',
  54. timeOriginMs: 1_000,
  55. capabilities: [],
  56. },
  57. topics: ['probe'],
  58. })
  59. registry.receive(connection, {
  60. v: 0,
  61. t: 'source/append',
  62. sourceId: 'host-1',
  63. generation: 'g-1',
  64. firstSequence: 2,
  65. droppedBefore: 1,
  66. records: [{ monotonicMs: 1, topic: 'probe', payload: { value: 1 } }],
  67. })
  68. expect(append).toHaveBeenCalledOnce()
  69. expect(registry.describe()[0]).toMatchObject({ expectedSequence: 3, dropped: 1, topics: { probe: 1 } })
  70. registry.receive(connection, {
  71. v: 0,
  72. t: 'source/append',
  73. sourceId: 'host-1',
  74. generation: 'g-1',
  75. firstSequence: 5,
  76. droppedBefore: 0,
  77. records: [],
  78. })
  79. expect(replies.at(-1)).toMatchObject({ t: 'source/resnapshot', expectedSequence: 3 })
  80. expect(append).toHaveBeenCalledOnce()
  81. })
  82. it('closes only a malformed source connection', () => {
  83. const send = vi.fn()
  84. const closeConnection = vi.fn()
  85. const connection: SourceConnection = {
  86. kind: 'client',
  87. send,
  88. close: closeConnection,
  89. }
  90. const registry = new InspectorSourceRegistry([], 1_024, 2)
  91. registry.receive(connection, { v: 99, t: 'source/open' })
  92. expect(send).toHaveBeenCalledWith(expect.objectContaining({ t: 'source/rejected' }))
  93. expect(closeConnection).toHaveBeenCalledOnce()
  94. })
  95. it('decodes Runtime commands and rejects undeclared fields', () => {
  96. const request = parseWorkerSourceFrame({
  97. v: 0,
  98. t: 'client-runtime/request',
  99. sourceId: 'client-1',
  100. generation: 'g-1',
  101. sessionId: 'session-1',
  102. requestId: 'request-1',
  103. command: {
  104. op: 'call-function',
  105. functionDeclaration: 'function () { return this.value }',
  106. receiver: 'object-1',
  107. arguments: [{ kind: 'unserializable', value: 'NaN' }],
  108. returnByValue: true,
  109. },
  110. })
  111. expect(request).toMatchObject({
  112. t: 'client-runtime/request',
  113. command: { op: 'call-function', receiver: 'object-1', returnByValue: true },
  114. })
  115. if (request.t !== 'client-runtime/request') throw new Error('unexpected frame type')
  116. expect(() => parseWorkerSourceFrame({
  117. ...request,
  118. command: { ...request.command, unversionedExtension: true },
  119. })).toThrow('unknown field')
  120. expect(parseWorkerSourceFrame({
  121. v: 0,
  122. t: 'client-runtime/response-acknowledged',
  123. sourceId: 'client-1',
  124. generation: 'g-1',
  125. sessionId: 'session-1',
  126. requestId: 'request-1',
  127. })).toMatchObject({ t: 'client-runtime/response-acknowledged', requestId: 'request-1' })
  128. })
  129. it('rejects invalid RemoteObject representations', () => {
  130. expect(() => parseSourceFrame({
  131. v: 0,
  132. t: 'client-runtime/response',
  133. sourceId: 'client-1',
  134. generation: 'g-1',
  135. sessionId: 'session-1',
  136. requestId: 'request-1',
  137. outcome: {
  138. ok: true,
  139. result: {
  140. op: 'evaluate',
  141. completion: {
  142. result: {
  143. descriptor: { type: 'number', value: 1 },
  144. object: { handle: 'object-1' },
  145. },
  146. },
  147. },
  148. },
  149. }, 4)).toThrow('invalid number RemoteObject representation')
  150. })
  151. it('decodes exact Client Console lifecycle and event frames', () => {
  152. expect(parseWorkerSourceFrame({
  153. v: 0,
  154. t: 'client-console/enable',
  155. sourceId: 'client-1',
  156. generation: 'g-1',
  157. sessionId: 'session-1',
  158. })).toMatchObject({ t: 'client-console/enable', sessionId: 'session-1' })
  159. const frame = parseSourceFrame({
  160. v: 0,
  161. t: 'client-console/event',
  162. sourceId: 'client-1',
  163. generation: 'g-1',
  164. sessionId: 'session-1',
  165. event: {
  166. type: 'console-api',
  167. event: {
  168. type: 'log',
  169. arguments: [{
  170. descriptor: { type: 'object', className: 'Object', description: 'Object' },
  171. object: { handle: 'object-1' },
  172. }],
  173. timestamp: 12,
  174. },
  175. },
  176. }, 4)
  177. expect(frame).toMatchObject({
  178. t: 'client-console/event',
  179. sessionId: 'session-1',
  180. event: {
  181. type: 'console-api',
  182. event: { type: 'log', arguments: [{ object: { handle: 'object-1' } }] },
  183. },
  184. })
  185. expect(() => parseWorkerSourceFrame({
  186. v: 0,
  187. t: 'client-console/disable',
  188. sourceId: 'client-1',
  189. generation: 'g-1',
  190. sessionId: 'session-1',
  191. extra: true,
  192. })).toThrow('unknown field')
  193. })
  194. it('decodes bounded Client source commands and responses', () => {
  195. expect(parseWorkerSourceFrame({
  196. v: 0,
  197. t: 'client-sources/request',
  198. sourceId: 'client-1',
  199. generation: 'g-1',
  200. sessionId: 'source-session-1',
  201. requestId: 'source-request-1',
  202. command: {
  203. op: 'get-content-chunk',
  204. scriptKey: 'bundle',
  205. content: 'source',
  206. offset: 0,
  207. maxBytes: 1024,
  208. },
  209. })).toMatchObject({
  210. t: 'client-sources/request',
  211. command: { op: 'get-content-chunk', maxBytes: 1024 },
  212. })
  213. expect(parseSourceFrame({
  214. v: 0,
  215. t: 'client-sources/response',
  216. sourceId: 'client-1',
  217. generation: 'g-1',
  218. sessionId: 'source-session-1',
  219. requestId: 'source-request-1',
  220. outcome: {
  221. ok: true,
  222. result: {
  223. op: 'get-content-chunk',
  224. scriptKey: 'bundle',
  225. content: 'source',
  226. available: true,
  227. offset: 0,
  228. nextOffset: 3,
  229. data: 'YWJj',
  230. eof: true,
  231. },
  232. },
  233. }, 4)).toMatchObject({
  234. t: 'client-sources/response',
  235. outcome: { ok: true, result: { data: 'YWJj', eof: true } },
  236. })
  237. expect(() => parseSourceFrame({
  238. v: 0,
  239. t: 'client-sources/response',
  240. sourceId: 'client-1',
  241. generation: 'g-1',
  242. sessionId: 'source-session-1',
  243. requestId: 'source-request-1',
  244. outcome: {
  245. ok: true,
  246. result: {
  247. op: 'get-content-chunk',
  248. scriptKey: 'bundle',
  249. content: 'source',
  250. available: true,
  251. offset: 0,
  252. nextOffset: 3,
  253. data: 'not base64',
  254. eof: true,
  255. },
  256. },
  257. }, 4)).toThrow('chunk data')
  258. })
  259. })