|
|
@@ -0,0 +1,454 @@
|
|
|
+/** Host-driven Cordis tree integration. */
|
|
|
+
|
|
|
+import { Context } from '@deepseek-ai/cordis'
|
|
|
+import WebSocket, { type RawData } from 'ws'
|
|
|
+import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
|
+import { CordisTreeCollector } from '../src/shared/cordis/collector.ts'
|
|
|
+import { startInspector, type InspectorHandle } from '../src/host/bridge/controller.ts'
|
|
|
+import { publishCordisTree as publishHostCordisTree } from '../src/host/inspection/cordis.ts'
|
|
|
+import { parseCordisTreeSnapshot, type CordisTreeNode } from '../src/shared/cordis/snapshot.ts'
|
|
|
+import { inspectorId } from '../src/shared/bridge/ids.ts'
|
|
|
+import type { InspectorJsonValue } from '../src/shared/json.ts'
|
|
|
+import type { InspectorSourceDescriptor } from '../src/shared/bridge/messages/observation.ts'
|
|
|
+import { CordisTreeStore } from '../src/worker/inspection/cordis-store.ts'
|
|
|
+import { InspectorClientFixture } from './fixtures/client-source.host.ts'
|
|
|
+
|
|
|
+interface CdpMessage {
|
|
|
+ readonly id?: number
|
|
|
+ readonly method?: string
|
|
|
+ readonly params?: Record<string, unknown>
|
|
|
+ readonly result?: Record<string, unknown>
|
|
|
+ readonly error?: { message: string }
|
|
|
+}
|
|
|
+
|
|
|
+interface CdpNode {
|
|
|
+ readonly nodeId: number
|
|
|
+ readonly backendNodeId: number
|
|
|
+ readonly localName: string
|
|
|
+ readonly attributes?: string[]
|
|
|
+ readonly children?: CdpNode[]
|
|
|
+}
|
|
|
+
|
|
|
+class CdpClient {
|
|
|
+ private nextId = 0
|
|
|
+ private readonly pending = new Map<number, (message: CdpMessage) => void>()
|
|
|
+ readonly events: CdpMessage[] = []
|
|
|
+
|
|
|
+ private constructor(private readonly socket: WebSocket) {
|
|
|
+ socket.on('message', (data) => {
|
|
|
+ const message = JSON.parse(rawText(data)) as CdpMessage
|
|
|
+ if (message.id !== undefined) this.pending.get(message.id)?.(message)
|
|
|
+ else this.events.push(message)
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ static async connect(url: string): Promise<CdpClient> {
|
|
|
+ const socket = new WebSocket(url)
|
|
|
+ await new Promise<void>((resolve, reject) => {
|
|
|
+ socket.once('open', () => { resolve() })
|
|
|
+ socket.once('error', reject)
|
|
|
+ })
|
|
|
+ return new CdpClient(socket)
|
|
|
+ }
|
|
|
+
|
|
|
+ call(method: string, params: Record<string, unknown> = {}): Promise<CdpMessage> {
|
|
|
+ const id = ++this.nextId
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+ const timer = setTimeout(() => { reject(new Error(`CDP call timed out: ${method}`)) }, 5_000)
|
|
|
+ this.pending.set(id, (message) => {
|
|
|
+ clearTimeout(timer)
|
|
|
+ this.pending.delete(id)
|
|
|
+ resolve(message)
|
|
|
+ })
|
|
|
+ this.socket.send(JSON.stringify({ id, method, params }))
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ async close(): Promise<void> {
|
|
|
+ if (this.socket.readyState === WebSocket.CLOSED) return
|
|
|
+ const closed = new Promise<void>((resolve) => { this.socket.once('close', () => { resolve() }) })
|
|
|
+ this.socket.close()
|
|
|
+ await closed
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+describe('Cordis tree inspection', () => {
|
|
|
+ let inspector: InspectorHandle | undefined
|
|
|
+ let cdp: CdpClient | undefined
|
|
|
+ let secondCdp: CdpClient | undefined
|
|
|
+ let clientSource: InspectorClientFixture | undefined
|
|
|
+ const observers: Array<() => void> = []
|
|
|
+ const fibers: Array<{ dispose(): Promise<void> }> = []
|
|
|
+
|
|
|
+ afterEach(async () => {
|
|
|
+ for (const dispose of observers.splice(0).reverse()) dispose()
|
|
|
+ for (const fiber of fibers.splice(0).reverse()) await fiber.dispose()
|
|
|
+ await clientSource?.close()
|
|
|
+ clientSource = undefined
|
|
|
+ await cdp?.close()
|
|
|
+ cdp = undefined
|
|
|
+ await secondCdp?.close()
|
|
|
+ secondCdp = undefined
|
|
|
+ await inspector?.close()
|
|
|
+ inspector = undefined
|
|
|
+ Reflect.deleteProperty(globalThis, '__cordisHostProbe')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('preserves separate Fiber and Context identities in one shared snapshot model', async () => {
|
|
|
+ const root = new Context()
|
|
|
+ const parent = root.isolate('probe')
|
|
|
+ const fiber = parent.plugin({ name: 'child', apply() {} })
|
|
|
+ await fiber.await()
|
|
|
+ const collector = new CordisTreeCollector(root, { maxNodes: 100, maxBytes: 64 * 1_024 })
|
|
|
+
|
|
|
+ const snapshot = collector.snapshot()
|
|
|
+ expect(parseCordisTreeSnapshot(snapshot, 100)).toEqual(snapshot)
|
|
|
+ const nodes = treeNodes(snapshot.root)
|
|
|
+ const fiberNode = nodes.find(node => node.kind === 'fiber' && node.uid === fiber.uid)
|
|
|
+ if (fiberNode === undefined) throw new Error('expected child Fiber node')
|
|
|
+ expect(nodes.every(node => !('id' in node) && !('parentId' in node))).toBe(true)
|
|
|
+ expect(() => parseCordisTreeSnapshot({
|
|
|
+ ...snapshot,
|
|
|
+ root: { ...snapshot.root, children: [{ ...fiberNode, children: [] }] },
|
|
|
+ }, 100)).toThrow('exactly one Context')
|
|
|
+ const contextNode = fiberNode.children[0]
|
|
|
+ const isolateNode = nodes.find(node => node.kind === 'context'
|
|
|
+ && collector.objects.resolve(node.objectHandle) === parent)
|
|
|
+
|
|
|
+ expect(snapshot.root.kind).toBe('context')
|
|
|
+ expect(nodes.some(node => node.kind === 'fiber' && node.uid === 0)).toBe(false)
|
|
|
+ expect(isolateNode?.children).toContain(fiberNode)
|
|
|
+ const retainedFiber = collector.objects.resolve(fiberNode.objectHandle)
|
|
|
+ expect(Reflect.get(retainedFiber ?? {}, 'uid')).toBe(fiber.uid)
|
|
|
+ expect(Reflect.get(retainedFiber ?? {}, 'ctx') === fiber.ctx).toBe(true)
|
|
|
+ expect(collector.objects.resolve(contextNode.objectHandle) === fiber.ctx).toBe(true)
|
|
|
+ const identifiedFiber = collector.objects.identify(fiber)
|
|
|
+ expect(identifiedFiber).toEqual({
|
|
|
+ registryId: snapshot.objectRegistryId,
|
|
|
+ handle: fiberNode.objectHandle,
|
|
|
+ })
|
|
|
+ expect(collector.objects.identify(Object.create(parent) as object)).toBeUndefined()
|
|
|
+
|
|
|
+ collector.close()
|
|
|
+ await fiber.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('freezes a disconnected snapshot and replaces it with the reconnect generation', () => {
|
|
|
+ const root = new Context()
|
|
|
+ const collector = new CordisTreeCollector(root, { maxNodes: 100, maxBytes: 64 * 1_024 })
|
|
|
+ const snapshot = collector.snapshot()
|
|
|
+ const store = new CordisTreeStore({ maxNodes: 100, maxDisconnectedTrees: 1 })
|
|
|
+ const first = source('client-a', 'generation-1')
|
|
|
+ store.replace(first, [{ sequence: 1, monotonicMs: 1, topic: 'cordis/tree', payload: asJson(snapshot) }])
|
|
|
+
|
|
|
+ const object = snapshot.root
|
|
|
+ expect(store.resolveObject(first, {
|
|
|
+ registryId: snapshot.objectRegistryId,
|
|
|
+ handle: object.objectHandle,
|
|
|
+ })).toBeDefined()
|
|
|
+ store.close(first, 'transport closed')
|
|
|
+ expect(store.snapshots()[0]?.connection).toEqual({ state: 'disconnected', reason: 'transport closed' })
|
|
|
+ expect(store.resolveObject(first, {
|
|
|
+ registryId: snapshot.objectRegistryId,
|
|
|
+ handle: object.objectHandle,
|
|
|
+ })).toBeUndefined()
|
|
|
+
|
|
|
+ const reconnected = source('client-a', 'generation-2')
|
|
|
+ store.replace(reconnected, [{
|
|
|
+ sequence: 1,
|
|
|
+ monotonicMs: 2,
|
|
|
+ topic: 'cordis/tree',
|
|
|
+ payload: asJson({ ...snapshot, revision: snapshot.revision + 1 }),
|
|
|
+ }])
|
|
|
+ expect(store.snapshots()).toEqual([
|
|
|
+ expect.objectContaining({ source: reconnected, connection: { state: 'connected' } }),
|
|
|
+ ])
|
|
|
+
|
|
|
+ store.close(reconnected, 'transport closed again')
|
|
|
+ const other = source('client-b', 'generation-1')
|
|
|
+ store.replace(other, [{ sequence: 1, monotonicMs: 3, topic: 'cordis/tree', payload: asJson(snapshot) }])
|
|
|
+ store.close(other, 'other transport closed')
|
|
|
+ const retained = store.snapshots()
|
|
|
+ expect(retained).toHaveLength(1)
|
|
|
+ expect(retained[0]?.source).toEqual(other)
|
|
|
+ expect(retained[0]?.connection.state).toBe('disconnected')
|
|
|
+ collector.close()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('projects Host and Client trees and resolves both node kinds to RemoteObjects', async () => {
|
|
|
+ inspector = await startInspector({ port: 0, captureFetch: false, maxCordisNodes: 100 })
|
|
|
+ const host = new Context()
|
|
|
+ const hostFiber = host.plugin({ name: 'host-child', apply() {} })
|
|
|
+ fibers.push(hostFiber)
|
|
|
+ await hostFiber.await()
|
|
|
+ Reflect.set(globalThis, '__cordisHostProbe', host)
|
|
|
+ observers.push(publishHostCordisTree(host, inspector.source, { maxNodes: 100, maxBytes: 64 * 1_024 }))
|
|
|
+
|
|
|
+ clientSource = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Tree Client' })
|
|
|
+ cdp = await CdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
|
|
|
+ await cdp.call('Runtime.enable')
|
|
|
+
|
|
|
+ let document: CdpNode | undefined
|
|
|
+ await vi.waitFor(async () => {
|
|
|
+ const response = await cdp!.call('DOM.getDocument')
|
|
|
+ expect(response.error).toBeUndefined()
|
|
|
+ document = response.result?.root as CdpNode
|
|
|
+ expect(hostContainer(document)).toBeDefined()
|
|
|
+ expect(clientContainers(document)).toHaveLength(1)
|
|
|
+ })
|
|
|
+ if (document === undefined) throw new Error('DOM.getDocument returned no root')
|
|
|
+ expect(document.children?.map(node => node.localName)).toEqual(['host', 'clients'])
|
|
|
+ expect(document.children?.every(node => (node.attributes ?? []).length === 0)).toBe(true)
|
|
|
+
|
|
|
+ const stored = await cdp.call('DSHInspector.getCordisTree')
|
|
|
+ const model = stored.result?.tree as {
|
|
|
+ host: { root: Record<string, unknown> } | null
|
|
|
+ clients: Array<{ root: Record<string, unknown> }>
|
|
|
+ }
|
|
|
+ expect(model.host?.root).toMatchObject({ kind: 'context' })
|
|
|
+ expect(model.clients).toHaveLength(1)
|
|
|
+ expect(model.clients[0]?.root).toMatchObject({ kind: 'context' })
|
|
|
+ expect(model.host?.root).not.toHaveProperty('nodeId')
|
|
|
+ expect(model.host?.root).not.toHaveProperty('backendNodeId')
|
|
|
+
|
|
|
+ const realms = [
|
|
|
+ ['host', hostContainer(document)],
|
|
|
+ ['client', clientContainers(document)[0]],
|
|
|
+ ] as const
|
|
|
+ for (const [realmKind, realm] of realms) {
|
|
|
+ expect(realm?.attributes ?? []).toEqual([])
|
|
|
+ const rootContext = realm?.children?.[0]
|
|
|
+ expect(rootContext?.localName).toBe('context')
|
|
|
+ expect(rootContext?.children?.[0]?.localName).toBe('fiber')
|
|
|
+ expect(rootContext?.children?.[0]?.children?.[0]?.localName).toBe('context')
|
|
|
+ for (const entityKind of ['context', 'fiber']) {
|
|
|
+ const node = realm === undefined ? undefined : walk(realm).find(item => item.localName === entityKind)
|
|
|
+ if (node === undefined) throw new Error(`missing ${realmKind} ${entityKind} node`)
|
|
|
+ expect(node.attributes ?? []).toEqual(entityKind === 'fiber'
|
|
|
+ ? ['uid', expect.stringMatching(/^\d+$/u)]
|
|
|
+ : [])
|
|
|
+ expect(node.nodeId).toBeGreaterThan(0)
|
|
|
+ expect(node.backendNodeId).toBeGreaterThan(0)
|
|
|
+ const objectGroup = `tree-${realmKind}-${entityKind}`
|
|
|
+ const resolved = await cdp.call('DOM.resolveNode', { nodeId: node.nodeId, objectGroup })
|
|
|
+ expect(resolved.error).toBeUndefined()
|
|
|
+ const remote = resolved.result?.object as Record<string, unknown>
|
|
|
+ expect(remote).toMatchObject({
|
|
|
+ type: 'object',
|
|
|
+ subtype: 'node',
|
|
|
+ className: entityKind === 'fiber' ? 'Fiber' : 'Context',
|
|
|
+ })
|
|
|
+ expect(typeof remote.objectId).toBe('string')
|
|
|
+ const properties = await cdp.call('Runtime.getProperties', { objectId: remote.objectId, ownProperties: true })
|
|
|
+ expect(properties.error).toBeUndefined()
|
|
|
+ await expect(cdp.call('DOM.requestNode', { objectId: remote.objectId })).resolves.toMatchObject({
|
|
|
+ result: { nodeId: node.nodeId },
|
|
|
+ })
|
|
|
+ await cdp.call('Runtime.releaseObjectGroup', { objectGroup })
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const hostNode = walk(hostContainer(document)!).find(item => item.localName === 'context')!
|
|
|
+ const hostEvaluated = await cdp.call('Runtime.evaluate', { expression: 'globalThis.__cordisHostProbe' })
|
|
|
+ expect(hostEvaluated.result?.result).toMatchObject({ type: 'object', subtype: 'node', className: 'Context' })
|
|
|
+ await expect(cdp.call('DOM.requestNode', {
|
|
|
+ objectId: (hostEvaluated.result?.result as Record<string, unknown>).objectId,
|
|
|
+ })).resolves.toMatchObject({ result: { nodeId: hostNode.nodeId } })
|
|
|
+ const hostThrown = await cdp.call('Runtime.evaluate', { expression: 'throw globalThis.__cordisHostProbe' })
|
|
|
+ const hostException = hostThrown.result?.exceptionDetails as Record<string, unknown>
|
|
|
+ const hostExceptionObject = hostException.exception as Record<string, unknown>
|
|
|
+ expect(hostExceptionObject).toMatchObject({ subtype: 'node', className: 'Context' })
|
|
|
+ await expect(cdp.call('DOM.requestNode', { objectId: hostExceptionObject.objectId }))
|
|
|
+ .resolves.toMatchObject({ result: { nodeId: hostNode.nodeId } })
|
|
|
+
|
|
|
+ let clientContextId: number | undefined
|
|
|
+ await vi.waitFor(() => {
|
|
|
+ const event = cdp!.events.find(item => item.method === 'Runtime.executionContextCreated'
|
|
|
+ && String((item.params?.context as { name?: string } | undefined)?.name).startsWith('Client'))
|
|
|
+ clientContextId = (event?.params?.context as { id?: number } | undefined)?.id
|
|
|
+ expect(clientContextId).toBeTypeOf('number')
|
|
|
+ })
|
|
|
+ const clientNode = walk(clientContainers(document)[0]!).find(item => item.localName === 'context')!
|
|
|
+ const clientEvaluated = await cdp.call('Runtime.evaluate', {
|
|
|
+ expression: 'globalThis.__cordisClientProbe',
|
|
|
+ contextId: clientContextId,
|
|
|
+ })
|
|
|
+ expect(clientEvaluated.result?.result).toMatchObject({ type: 'object', subtype: 'node', className: 'Context' })
|
|
|
+ await expect(cdp.call('DOM.requestNode', {
|
|
|
+ objectId: (clientEvaluated.result?.result as Record<string, unknown>).objectId,
|
|
|
+ })).resolves.toMatchObject({ result: { nodeId: clientNode.nodeId } })
|
|
|
+ const clientThrown = await cdp.call('Runtime.evaluate', {
|
|
|
+ expression: 'throw globalThis.__cordisClientProbe',
|
|
|
+ contextId: clientContextId,
|
|
|
+ })
|
|
|
+ const clientException = clientThrown.result?.exceptionDetails as Record<string, unknown>
|
|
|
+ const clientExceptionObject = clientException.exception as Record<string, unknown>
|
|
|
+ expect(clientExceptionObject).toMatchObject({ subtype: 'node', className: 'Context' })
|
|
|
+ await expect(cdp.call('DOM.requestNode', { objectId: clientExceptionObject.objectId }))
|
|
|
+ .resolves.toMatchObject({ result: { nodeId: clientNode.nodeId } })
|
|
|
+
|
|
|
+ const consoleOffset = cdp.events.length
|
|
|
+ await clientSource.logCordis('cordis-client-console')
|
|
|
+ let consoleObject: Record<string, unknown> | undefined
|
|
|
+ let consoleFiber: Record<string, unknown> | undefined
|
|
|
+ await vi.waitFor(() => {
|
|
|
+ const event = cdp!.events.slice(consoleOffset).find((candidate) => {
|
|
|
+ const params = candidate.params
|
|
|
+ if (params === undefined
|
|
|
+ || candidate.method !== 'Runtime.consoleAPICalled'
|
|
|
+ || params.executionContextId !== clientContextId
|
|
|
+ || !Array.isArray(params.args)) return false
|
|
|
+ return params.args.some(argument => (argument as { value?: unknown }).value === 'cordis-client-console')
|
|
|
+ })
|
|
|
+ const args = event?.params?.args
|
|
|
+ consoleObject = Array.isArray(args) ? args[0] as Record<string, unknown> | undefined : undefined
|
|
|
+ consoleFiber = Array.isArray(args) ? args[1] as Record<string, unknown> | undefined : undefined
|
|
|
+ expect(consoleObject).toMatchObject({ type: 'object', subtype: 'node', className: 'Context' })
|
|
|
+ expect(consoleFiber).toMatchObject({ type: 'object', subtype: 'node', className: 'Fiber' })
|
|
|
+ })
|
|
|
+ await expect(cdp.call('DOM.requestNode', { objectId: consoleObject!.objectId }))
|
|
|
+ .resolves.toMatchObject({ result: { nodeId: clientNode.nodeId } })
|
|
|
+ const requestedFiber = await cdp.call('DOM.requestNode', { objectId: consoleFiber!.objectId })
|
|
|
+ const requestedFiberId = (requestedFiber.result as { nodeId?: number } | undefined)?.nodeId
|
|
|
+ const clientFiberNode = walk(clientContainers(document)[0]!).find(node => node.nodeId === requestedFiberId)
|
|
|
+ expect(clientFiberNode).toMatchObject({
|
|
|
+ localName: 'fiber',
|
|
|
+ attributes: ['uid', String(clientSource.fiberUid)],
|
|
|
+ })
|
|
|
+
|
|
|
+ const firstResolved = await cdp.call('DOM.resolveNode', { backendNodeId: clientNode.backendNodeId })
|
|
|
+ const firstObjectId = (firstResolved.result?.object as Record<string, unknown>).objectId
|
|
|
+ secondCdp = await CdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
|
|
|
+ const secondDocument = (await secondCdp.call('DOM.getDocument')).result?.root as CdpNode
|
|
|
+ const secondNode = walk(secondDocument).find(node => node.backendNodeId === clientNode.backendNodeId)
|
|
|
+ expect(secondNode).toBeDefined()
|
|
|
+ const secondResolved = await secondCdp.call('DOM.resolveNode', { backendNodeId: clientNode.backendNodeId })
|
|
|
+ const secondObjectId = (secondResolved.result?.object as Record<string, unknown>).objectId
|
|
|
+ expect(secondObjectId).not.toBe(firstObjectId)
|
|
|
+ expect((await secondCdp.call('DOM.requestNode', { objectId: firstObjectId })).error).toBeDefined()
|
|
|
+
|
|
|
+ const eventOffset = cdp.events.length
|
|
|
+ await clientSource.close()
|
|
|
+ clientSource = undefined
|
|
|
+ await vi.waitFor(() => {
|
|
|
+ const events = cdp!.events.slice(eventOffset)
|
|
|
+ expect(events.some(event => event.method === 'Runtime.executionContextDestroyed'
|
|
|
+ && event.params?.executionContextId === clientContextId)).toBe(true)
|
|
|
+ expect(events.some(event => event.method === 'DOM.documentUpdated')).toBe(false)
|
|
|
+ })
|
|
|
+
|
|
|
+ const disconnectedDocument = (await cdp.call('DOM.getDocument')).result?.root as CdpNode
|
|
|
+ const disconnectedClient = clientContainers(disconnectedDocument)[0]
|
|
|
+ expect(disconnectedClient).toBeDefined()
|
|
|
+ expect(walk(disconnectedClient!).find(node => node.backendNodeId === clientNode.backendNodeId)?.nodeId)
|
|
|
+ .toBe(clientNode.nodeId)
|
|
|
+ expect((await cdp.call('DOM.resolveNode', { nodeId: clientNode.nodeId })).error?.message)
|
|
|
+ .toContain('Cordis realm is disconnected')
|
|
|
+ expect((await cdp.call('DOM.requestNode', {
|
|
|
+ objectId: (clientEvaluated.result?.result as Record<string, unknown>).objectId,
|
|
|
+ })).error).toBeDefined()
|
|
|
+ const disconnectedTree = (await cdp.call('DSHInspector.getCordisTree')).result?.tree as {
|
|
|
+ clients: Array<{ connection: { state: string } }>
|
|
|
+ }
|
|
|
+ expect(disconnectedTree.clients[0]?.connection.state).toBe('disconnected')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('restores a disconnected Client tree from a new transport generation', async () => {
|
|
|
+ inspector = await startInspector({
|
|
|
+ port: 0,
|
|
|
+ captureFetch: false,
|
|
|
+ maxCordisNodes: 100,
|
|
|
+ clientReconnectBaseMs: 10,
|
|
|
+ clientReconnectMaxMs: 20,
|
|
|
+ })
|
|
|
+ clientSource = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Reconnect Client' })
|
|
|
+ cdp = await CdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
|
|
|
+ await cdp.call('Runtime.enable')
|
|
|
+
|
|
|
+ let document: CdpNode | undefined
|
|
|
+ let contextId: number | undefined
|
|
|
+ await vi.waitFor(async () => {
|
|
|
+ document = (await cdp!.call('DOM.getDocument')).result?.root as CdpNode
|
|
|
+ expect(clientContainers(document)).toHaveLength(1)
|
|
|
+ const created = cdp!.events.find(event => event.method === 'Runtime.executionContextCreated'
|
|
|
+ && String((event.params?.context as { name?: string } | undefined)?.name).startsWith('Client'))
|
|
|
+ contextId = (created?.params?.context as { id?: number } | undefined)?.id
|
|
|
+ expect(contextId).toBeTypeOf('number')
|
|
|
+ })
|
|
|
+ const initialTree = (await cdp.call('DSHInspector.getCordisTree')).result?.tree as {
|
|
|
+ clients: Array<{ source: { sourceId: string } }>
|
|
|
+ }
|
|
|
+ const sourceId = initialTree.clients[0]?.source.sourceId
|
|
|
+ const eventOffset = cdp.events.length
|
|
|
+ await clientSource.disconnect()
|
|
|
+
|
|
|
+ await vi.waitFor(() => {
|
|
|
+ const events = cdp!.events.slice(eventOffset)
|
|
|
+ const destroyed = events.findIndex(event => event.method === 'Runtime.executionContextDestroyed'
|
|
|
+ && event.params?.executionContextId === contextId)
|
|
|
+ const created = events.findIndex((event) => {
|
|
|
+ if (event.method !== 'Runtime.executionContextCreated') return false
|
|
|
+ const context = event.params?.context as { id?: number } | undefined
|
|
|
+ return typeof context?.id === 'number' && context.id !== contextId
|
|
|
+ })
|
|
|
+ const refreshed = events.findIndex(event => event.method === 'DOM.documentUpdated')
|
|
|
+ expect(destroyed).toBeGreaterThanOrEqual(0)
|
|
|
+ expect(created).toBeGreaterThan(destroyed)
|
|
|
+ expect(refreshed).toBeGreaterThan(created)
|
|
|
+ expect(events.slice(0, created).some(event => event.method?.startsWith('DOM.'))).toBe(false)
|
|
|
+ })
|
|
|
+
|
|
|
+ await vi.waitFor(async () => {
|
|
|
+ const current = (await cdp!.call('DOM.getDocument')).result?.root as CdpNode
|
|
|
+ expect(clientContainers(current)).toHaveLength(1)
|
|
|
+ expect(clientContainers(current)[0]?.children?.[0]?.localName).toBe('context')
|
|
|
+ const tree = (await cdp!.call('DSHInspector.getCordisTree')).result?.tree as {
|
|
|
+ clients: Array<{
|
|
|
+ source: { sourceId: string }
|
|
|
+ connection: { state: string }
|
|
|
+ }>
|
|
|
+ }
|
|
|
+ expect(tree.clients).toHaveLength(1)
|
|
|
+ expect(tree.clients[0]?.source.sourceId).toBe(sourceId)
|
|
|
+ expect(tree.clients[0]?.connection.state).toBe('connected')
|
|
|
+ })
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+function source(sourceId: string, generation: string): InspectorSourceDescriptor {
|
|
|
+ return {
|
|
|
+ sourceId: inspectorId<'InspectorSourceId'>(sourceId, 'sourceId'),
|
|
|
+ generation: inspectorId<'InspectorSourceGeneration'>(generation, 'generation'),
|
|
|
+ kind: 'client',
|
|
|
+ label: sourceId,
|
|
|
+ timeOriginMs: 0,
|
|
|
+ capabilities: [],
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function hostContainer(root: CdpNode | undefined): CdpNode | undefined {
|
|
|
+ return root?.children?.find(node => node.localName === 'host')
|
|
|
+}
|
|
|
+
|
|
|
+function clientContainers(root: CdpNode | undefined): CdpNode[] {
|
|
|
+ return root?.children?.find(node => node.localName === 'clients')?.children
|
|
|
+ ?.filter(node => node.localName === 'client') ?? []
|
|
|
+}
|
|
|
+
|
|
|
+function walk(root: CdpNode): CdpNode[] {
|
|
|
+ return [root, ...(root.children ?? []).flatMap(walk)]
|
|
|
+}
|
|
|
+
|
|
|
+function treeNodes(root: CordisTreeNode): CordisTreeNode[] {
|
|
|
+ return [root, ...root.children.flatMap(treeNodes)]
|
|
|
+}
|
|
|
+
|
|
|
+function rawText(data: RawData): string {
|
|
|
+ if (Array.isArray(data)) return Buffer.concat(data).toString('utf8')
|
|
|
+ if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8')
|
|
|
+ return Buffer.from(data).toString('utf8')
|
|
|
+}
|
|
|
+
|
|
|
+function asJson(value: object): InspectorJsonValue {
|
|
|
+ return value as unknown as InspectorJsonValue
|
|
|
+}
|