client-runtime.client.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. /** Client-face Runtime behavior. */
  2. import { afterEach, describe, expect, it } from 'vitest'
  3. import { ClientRuntimeExecutor } from '../src/client/cdp/runtime.ts'
  4. import type {
  5. ClientRuntimeCommand,
  6. ClientRuntimeRequestFrame,
  7. ClientRuntimeResult,
  8. } from '../src/shared/bridge/messages/runtime/index.ts'
  9. import {
  10. inspectorId,
  11. } from '../src/shared/bridge/ids.ts'
  12. const sourceId = inspectorId<'InspectorSourceId'>('client-test', 'sourceId')
  13. const generation = inspectorId<'InspectorSourceGeneration'>('generation-test', 'generation')
  14. const sessionId = inspectorId<'ClientRuntimeSessionId'>('session-test', 'sessionId')
  15. const secondSessionId = inspectorId<'ClientRuntimeSessionId'>('session-second', 'sessionId')
  16. describe('Client Runtime executor', () => {
  17. afterEach(() => {
  18. Reflect.deleteProperty(globalThis, '__clientRuntimeFixture')
  19. Reflect.deleteProperty(globalThis, '__clientRuntimeGetterCalls')
  20. })
  21. it('retains RemoteObjects, reads descriptors lazily, calls functions, and releases groups', async () => {
  22. Reflect.set(globalThis, '__clientRuntimeGetterCalls', 0)
  23. const fixture = {
  24. value: 4,
  25. get dangerous(): number {
  26. const calls = Number(Reflect.get(globalThis, '__clientRuntimeGetterCalls'))
  27. Reflect.set(globalThis, '__clientRuntimeGetterCalls', calls + 1)
  28. return 99
  29. },
  30. }
  31. Object.defineProperty(fixture, Symbol.toStringTag, {
  32. get() {
  33. const calls = Number(Reflect.get(globalThis, '__clientRuntimeGetterCalls'))
  34. Reflect.set(globalThis, '__clientRuntimeGetterCalls', calls + 1)
  35. return 'DangerousTag'
  36. },
  37. })
  38. Reflect.set(globalThis, '__clientRuntimeFixture', fixture)
  39. const runtime = new ClientRuntimeExecutor({
  40. maxObjectsPerSession: 100,
  41. maxPropertiesPerResult: 100,
  42. maxResponseBytes: 32_768,
  43. })
  44. const evaluated = success(await runtime.execute(frame({
  45. op: 'evaluate',
  46. expression: 'globalThis.__clientRuntimeFixture',
  47. objectGroup: 'console',
  48. generatePreview: true,
  49. })), 'evaluate')
  50. const handle = evaluated.completion.result.object?.handle
  51. if (handle === undefined) throw new Error('evaluate did not return a Client object handle')
  52. const properties = success(await runtime.execute(frame({
  53. op: 'get-properties',
  54. handle,
  55. ownProperties: true,
  56. })), 'get-properties')
  57. const valueProperty = properties.properties.find(property => property.name === 'value')
  58. const getterProperty = properties.properties.find(property => property.name === 'dangerous')
  59. expect(valueProperty?.value).toMatchObject({ descriptor: { type: 'number', value: 4 } })
  60. expect(getterProperty?.get).toMatchObject({ descriptor: { type: 'function' } })
  61. expect(Reflect.get(globalThis, '__clientRuntimeGetterCalls')).toBe(0)
  62. const called = success(await runtime.execute(frame({
  63. op: 'call-function',
  64. functionDeclaration: 'function (increment) { return this.value + increment }',
  65. receiver: handle,
  66. arguments: [{ kind: 'value', value: 3 }],
  67. returnByValue: true,
  68. })), 'call-function')
  69. expect(called.completion.result).toMatchObject({ descriptor: { type: 'number', value: 7 } })
  70. success(await runtime.execute(frame({ op: 'release-object-group', objectGroup: 'console' })), 'release-object-group')
  71. const released = await runtime.execute(frame({ op: 'get-properties', handle }))
  72. expect(released.outcome).toEqual({
  73. ok: false,
  74. error: { code: 'object-not-found', message: 'Client RemoteObject was released' },
  75. })
  76. })
  77. it('keeps evaluated exceptions separate from transport failures', async () => {
  78. const runtime = new ClientRuntimeExecutor({
  79. maxObjectsPerSession: 100,
  80. maxPropertiesPerResult: 100,
  81. maxResponseBytes: 32_768,
  82. })
  83. const result = success(await runtime.execute(frame({
  84. op: 'evaluate',
  85. expression: 'throw new TypeError("bad value")',
  86. })), 'evaluate')
  87. expect(result.completion.exceptionDetails).toMatchObject({
  88. text: 'Uncaught',
  89. exception: { descriptor: { type: 'object', subtype: 'error' } },
  90. })
  91. expect(result.completion.result).toMatchObject({ descriptor: { type: 'object', subtype: 'error' } })
  92. })
  93. it('preserves non-JSON primitives and reports bounded async execution failures', async () => {
  94. const runtime = new ClientRuntimeExecutor({
  95. maxObjectsPerSession: 100,
  96. maxPropertiesPerResult: 100,
  97. maxResponseBytes: 32_768,
  98. })
  99. const values = [
  100. ['NaN', { descriptor: { type: 'number', unserializableValue: 'NaN' } }],
  101. ['-0', { descriptor: { type: 'number', unserializableValue: '-0' } }],
  102. ['12n', { descriptor: { type: 'bigint', unserializableValue: '12n' } }],
  103. ['null', { descriptor: { type: 'object', subtype: 'null', value: null } }],
  104. ] as const
  105. for (const [expression, expected] of values) {
  106. const result = success(await runtime.execute(frame({ op: 'evaluate', expression })), 'evaluate')
  107. expect(result.completion.result).toMatchObject(expected)
  108. }
  109. const fn = success(await runtime.execute(frame({
  110. op: 'evaluate',
  111. expression: '(value) => value',
  112. generatePreview: true,
  113. })), 'evaluate')
  114. expect(fn.completion.result).toMatchObject({ descriptor: { type: 'function' } })
  115. expect(fn.completion.result.descriptor.preview).toBeUndefined()
  116. const timedOut = await runtime.execute(frame({
  117. op: 'evaluate',
  118. expression: 'new Promise(() => {})',
  119. awaitPromise: true,
  120. timeoutMs: 1,
  121. }))
  122. expect(timedOut.outcome).toMatchObject({ ok: false, error: { code: 'timeout' } })
  123. })
  124. it('rolls back only objects allocated by the failing concurrent request', async () => {
  125. const runtime = new ClientRuntimeExecutor({
  126. maxObjectsPerSession: 100,
  127. maxPropertiesPerResult: 100,
  128. maxResponseBytes: 32_768,
  129. })
  130. const blocked = runtime.execute(frame({
  131. op: 'evaluate',
  132. expression: 'new Promise(() => {})',
  133. awaitPromise: true,
  134. timeoutMs: 10,
  135. }))
  136. const completed = success(await runtime.execute(frame({
  137. op: 'evaluate',
  138. expression: '({ retainedByConcurrentRequest: true })',
  139. })), 'evaluate')
  140. const handle = completed.completion.result.object?.handle
  141. if (handle === undefined) throw new Error('concurrent evaluation did not retain an object')
  142. await expect(blocked).resolves.toMatchObject({ outcome: { ok: false, error: { code: 'timeout' } } })
  143. const properties = success(await runtime.execute(frame({
  144. op: 'get-properties',
  145. handle,
  146. ownProperties: true,
  147. })), 'get-properties')
  148. expect(properties.properties.find(property => property.name === 'retainedByConcurrentRequest')?.value)
  149. .toMatchObject({ descriptor: { value: true } })
  150. })
  151. it('rolls back a canceled function call instead of returning its cancellation as a JavaScript exception', async () => {
  152. const runtime = new ClientRuntimeExecutor({
  153. maxObjectsPerSession: 1,
  154. maxPropertiesPerResult: 100,
  155. maxResponseBytes: 32_768,
  156. })
  157. const controller = new AbortController()
  158. const pending = runtime.execute(frame({
  159. op: 'call-function',
  160. functionDeclaration: 'function () { return new Promise(() => {}) }',
  161. awaitPromise: true,
  162. }), controller.signal)
  163. controller.abort()
  164. await expect(pending).resolves.toMatchObject({ outcome: { ok: false, error: { code: 'timeout' } } })
  165. await expect(runtime.execute(frame({
  166. op: 'evaluate',
  167. expression: '({ retainedAfterCancellation: true })',
  168. }))).resolves.toMatchObject({ outcome: { ok: true } })
  169. })
  170. it('keeps response handles provisional until the Worker accepts or cancels them', async () => {
  171. const runtime = new ClientRuntimeExecutor({
  172. maxObjectsPerSession: 2,
  173. maxPropertiesPerResult: 100,
  174. maxResponseBytes: 32_768,
  175. })
  176. const canceledFrame = frame({ op: 'evaluate', expression: '({ canceled: true })' })
  177. const canceled = success(await runtime.execute(canceledFrame, undefined, true), 'evaluate')
  178. const canceledHandle = canceled.completion.result.object?.handle
  179. if (canceledHandle === undefined) throw new Error('deferred response did not retain an object')
  180. runtime.cancel(canceledFrame.sessionId, canceledFrame.requestId)
  181. expect((await runtime.execute(frame({ op: 'get-properties', handle: canceledHandle }))).outcome)
  182. .toMatchObject({ ok: false, error: { code: 'object-not-found' } })
  183. const acceptedFrame = frame({ op: 'evaluate', expression: '({ accepted: true })' })
  184. const accepted = success(await runtime.execute(acceptedFrame, undefined, true), 'evaluate')
  185. const acceptedHandle = accepted.completion.result.object?.handle
  186. if (acceptedHandle === undefined) throw new Error('deferred response did not retain an object')
  187. runtime.acknowledge(acceptedFrame.sessionId, acceptedFrame.requestId)
  188. const properties = success(await runtime.execute(frame({
  189. op: 'get-properties',
  190. handle: acceptedHandle,
  191. ownProperties: true,
  192. })), 'get-properties')
  193. expect(properties.properties.find(property => property.name === 'accepted')?.value)
  194. .toMatchObject({ descriptor: { value: true } })
  195. })
  196. it('rejects oversized by-value results before they enter the source transport', async () => {
  197. const runtime = new ClientRuntimeExecutor({
  198. maxObjectsPerSession: 100,
  199. maxPropertiesPerResult: 100,
  200. maxResponseBytes: 256,
  201. })
  202. const response = await runtime.execute(frame({
  203. op: 'evaluate',
  204. expression: '"x".repeat(1000)',
  205. returnByValue: true,
  206. }))
  207. expect(response.outcome).toMatchObject({ ok: false, error: { code: 'result-too-large' } })
  208. })
  209. it('drops every retained handle when its DevTools Runtime session closes', async () => {
  210. const runtime = new ClientRuntimeExecutor({
  211. maxObjectsPerSession: 100,
  212. maxPropertiesPerResult: 100,
  213. maxResponseBytes: 32_768,
  214. })
  215. const evaluated = success(await runtime.execute(frame({
  216. op: 'evaluate',
  217. expression: '({ retained: true })',
  218. })), 'evaluate')
  219. const handle = evaluated.completion.result.object?.handle
  220. if (handle === undefined) throw new Error('evaluate did not return a Client object handle')
  221. runtime.closeSession(sessionId)
  222. const response = await runtime.execute(frame({ op: 'get-properties', handle }))
  223. expect(response.outcome).toMatchObject({ ok: false, error: { code: 'object-not-found' } })
  224. })
  225. it('serializes Console objects into isolated DevTools sessions', async () => {
  226. const runtime = new ClientRuntimeExecutor({
  227. maxObjectsPerSession: 100,
  228. maxPropertiesPerResult: 100,
  229. maxResponseBytes: 32_768,
  230. })
  231. const value = { owner: 'console' }
  232. const first = runtime.consoleEvent(sessionId, 'log', [value], 12)
  233. const second = runtime.consoleEvent(secondSessionId, 'log', [value], 12)
  234. if (first?.type !== 'console-api' || second?.type !== 'console-api') {
  235. throw new Error('Console event was unexpectedly dropped')
  236. }
  237. const firstHandle = first.event.arguments[0]?.object?.handle
  238. const secondHandle = second.event.arguments[0]?.object?.handle
  239. if (firstHandle === undefined || secondHandle === undefined) throw new Error('Console object was not retained')
  240. runtime.releaseObjectGroup(sessionId, 'console')
  241. expect((await runtime.execute(frame({ op: 'get-properties', handle: firstHandle }))).outcome)
  242. .toMatchObject({ ok: false, error: { code: 'object-not-found' } })
  243. const properties = success(await runtime.execute(
  244. frame({ op: 'get-properties', handle: secondHandle }, secondSessionId),
  245. ), 'get-properties').properties
  246. expect(properties.find(property => property.name === 'owner')?.value?.descriptor.value).toBe('console')
  247. })
  248. })
  249. let nextRequestId = 0
  250. function frame(
  251. command: ClientRuntimeCommand,
  252. owner: ClientRuntimeRequestFrame['sessionId'] = sessionId,
  253. ): ClientRuntimeRequestFrame {
  254. return {
  255. v: 0,
  256. t: 'client-runtime/request',
  257. sourceId,
  258. generation,
  259. sessionId: owner,
  260. requestId: inspectorId<'ClientRuntimeRequestId'>(`request-${String(++nextRequestId)}`, 'requestId'),
  261. command,
  262. }
  263. }
  264. function success<Operation extends ClientRuntimeResult['op']>(
  265. response: Awaited<ReturnType<ClientRuntimeExecutor['execute']>>,
  266. operation: Operation,
  267. ): Extract<ClientRuntimeResult, { op: Operation }> {
  268. if (!response.outcome.ok) throw new Error(response.outcome.error.message)
  269. if (response.outcome.result.op !== operation) throw new Error('unexpected Client Runtime result')
  270. return response.outcome.result as Extract<ClientRuntimeResult, { op: Operation }>
  271. }