Files
deepseek-harness/packages/experimental/inspector/tests/integration.host.spec.ts
T

546 lines
25 KiB
TypeScript

/** Host-driven integration over an isolated Client fixture. */
import { createServer, type Server } from 'node:http'
import WebSocket, { type RawData } from 'ws'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { startInspector, type InspectorHandle } from '../src/host/bridge/controller.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 }
}
class TestCdpClient {
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<TestCdpClient> {
const socket = new WebSocket(url)
await new Promise<void>((resolve, reject) => {
socket.once('open', () => { resolve() })
socket.once('error', reject)
})
return new TestCdpClient(socket)
}
call(method: string, params: Record<string, unknown> = {}): Promise<CdpMessage> {
const id = ++this.nextId
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id)
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('experimental Inspector real Worker', () => {
let inspector: InspectorHandle | undefined
let cdp: TestCdpClient | undefined
let secondCdp: TestCdpClient | undefined
let client: InspectorClientFixture | undefined
let server: Server | undefined
afterEach(async () => {
await client?.close()
client = undefined
await cdp?.close()
cdp = undefined
await secondCdp?.close()
secondCdp = undefined
await inspector?.close()
inspector = undefined
if (server !== undefined) await new Promise<void>((resolve) => { server!.close(() => { resolve() }) })
server = undefined
})
it('switches between Host and Client contexts and routes Client RemoteObjects', async () => {
inspector = await startInspector({ port: 0, captureFetch: false, clientReconnectBaseMs: 10, clientReconnectMaxMs: 20 })
cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
inspector.source.publish('host/probe', { value: 1 })
client = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Test Client' })
await client.publish('client/probe', { value: 2 })
await vi.waitFor(async () => {
const response = await cdp!.call('DSHInspector.getSources')
const sources = response.result?.sources as Array<{ kind: string; topics: Record<string, number> }>
expect(sources).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: 'host', topics: { 'host/probe': 1 } }),
expect.objectContaining({
kind: 'client',
topics: expect.objectContaining({ 'client/probe': 1 }),
}),
]))
})
;(globalThis as Record<string, unknown>).__inspectorHostProbe = 73
expect((await cdp.call('Runtime.enable')).error).toBeUndefined()
let clientContextId: number | undefined
let clientUniqueContextId: string | undefined
await vi.waitFor(() => {
expect(runtimeContexts(cdp!).some(context => context.name === 'Host')).toBe(true)
const clientContext = cdp!.events
.filter(event => event.method === 'Runtime.executionContextCreated')
.map(event => event.params?.context as Record<string, unknown> | undefined)
.find(context => String(context?.name).startsWith('Client —'))
expect(clientContext).toBeDefined()
clientContextId = clientContext?.id as number
clientUniqueContextId = clientContext?.uniqueId as string
})
if (clientContextId === undefined || clientUniqueContextId === undefined) {
throw new Error('Client execution context was not announced')
}
const hostEvaluated = await cdp.call('Runtime.evaluate', {
expression: 'globalThis.__inspectorHostProbe',
returnByValue: true,
})
expect(hostEvaluated.result?.result).toMatchObject({ type: 'number', value: 73 })
await client.setGlobal('__inspectorClientProbe', { value: 17, nested: { ready: true } })
const clientEvaluated = await cdp.call('Runtime.evaluate', {
expression: 'globalThis.__inspectorClientProbe',
contextId: clientContextId,
objectGroup: 'console',
generatePreview: true,
})
const clientObject = clientEvaluated.result?.result as Record<string, unknown>
expect(clientObject).toMatchObject({ type: 'object', className: 'Object' })
expect(String(clientObject.objectId)).toMatch(/^runtime:/u)
const properties = await cdp.call('Runtime.getProperties', {
objectId: clientObject.objectId,
ownProperties: true,
})
const propertyRows = recordArray(properties.result?.result)
const valueProperty = propertyRows.find(property => property.name === 'value')
const nestedProperty = propertyRows.find(property => property.name === 'nested')
expect(asRecord(valueProperty?.value)).toMatchObject({ type: 'number', value: 17 })
expect(asRecord(nestedProperty?.value).type).toBe('object')
const called = await cdp.call('Runtime.callFunctionOn', {
objectId: clientObject.objectId,
functionDeclaration: 'function (increment) { return this.value + increment }',
arguments: [{ value: 5 }],
returnByValue: true,
})
expect(called.result?.result).toMatchObject({ type: 'number', value: 22 })
const hostObject = await cdp.call('Runtime.evaluate', { expression: '({ realm: "host" })' })
const hostObjectId = asRecord(hostObject.result?.result).objectId
expect((await cdp.call('Runtime.callFunctionOn', {
executionContextId: clientContextId,
functionDeclaration: 'function (value) { return value }',
arguments: [{ objectId: hostObjectId }],
})).error?.message).toContain('between realms')
expect((await cdp.call('Runtime.callFunctionOn', {
objectId: hostObjectId,
functionDeclaration: 'function (value) { return value }',
arguments: [{ objectId: clientObject.objectId }],
})).error?.message).toContain('between realms')
expect((await cdp.call('Runtime.queryObjects', {
prototypeObjectId: clientObject.objectId,
})).error?.message).toContain('Client realm has no native CDP transport')
const awaited = await cdp.call('Runtime.evaluate', {
expression: 'Promise.resolve({ realm: "client" })',
contextId: clientContextId,
awaitPromise: true,
returnByValue: true,
})
expect(awaited.result?.result).toMatchObject({ type: 'object', value: { realm: 'client' } })
const uniquelyRouted = await cdp.call('Runtime.evaluate', {
expression: '6 * 7',
uniqueContextId: clientUniqueContextId,
returnByValue: true,
})
expect(uniquelyRouted.result?.result).toMatchObject({ type: 'number', value: 42 })
expect((await cdp.call('Runtime.releaseObject', { objectId: clientObject.objectId })).error).toBeUndefined()
expect((await cdp.call('Runtime.getProperties', { objectId: clientObject.objectId })).error).toBeDefined()
const thrown = await cdp.call('Runtime.evaluate', {
expression: 'throw new Error("client failure")',
contextId: clientContextId,
})
expect(asRecord(thrown.result?.exceptionDetails)).toMatchObject({
text: 'Uncaught',
executionContextId: clientContextId,
})
const pendingEvaluation = cdp.call('Runtime.evaluate', {
expression: 'new Promise(() => {})',
contextId: clientContextId,
awaitPromise: true,
})
await new Promise<void>((resolve) => { setTimeout(resolve, 10) })
await client.close()
client = undefined
expect((await pendingEvaluation).error).toBeDefined()
await vi.waitFor(() => {
expect(cdp!.events.some(event =>
event.method === 'Runtime.executionContextDestroyed'
&& event.params?.executionContextId === clientContextId)).toBe(true)
})
})
it('isolates Client object ids and object groups by DevTools connection', async () => {
inspector = await startInspector({ port: 0, captureFetch: false })
client = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Shared Client' })
cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
secondCdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
await Promise.all([cdp.call('Runtime.enable'), secondCdp.call('Runtime.enable')])
const firstContext = await clientContext(cdp)
const secondContext = await clientContext(secondCdp)
const first = await cdp.call('Runtime.evaluate', {
expression: '({ owner: "first" })',
contextId: firstContext,
objectGroup: 'console',
})
const second = await secondCdp.call('Runtime.evaluate', {
expression: '({ owner: "second" })',
contextId: secondContext,
objectGroup: 'console',
})
const firstObjectId = asRecord(first.result?.result).objectId
const secondObjectId = asRecord(second.result?.result).objectId
expect(firstObjectId).not.toBe(secondObjectId)
expect((await secondCdp.call('Runtime.getProperties', { objectId: firstObjectId })).error).toBeDefined()
await cdp.close()
cdp = undefined
const secondProperties = await secondCdp.call('Runtime.getProperties', {
objectId: secondObjectId,
ownProperties: true,
})
const owner = recordArray(secondProperties.result?.result).find(property => property.name === 'owner')
expect(asRecord(owner?.value).value).toBe('second')
expect((await secondCdp.call('Runtime.releaseObjectGroup', { objectGroup: 'console' })).error).toBeUndefined()
expect((await secondCdp.call('Runtime.getProperties', { objectId: secondObjectId })).error).toBeDefined()
const beforeDisable = await secondCdp.call('Runtime.evaluate', {
expression: '({ retained: true })',
contextId: secondContext,
})
const disabledObjectId = asRecord(beforeDisable.result?.result).objectId
expect((await secondCdp.call('Runtime.disable')).error).toBeUndefined()
expect((await secondCdp.call('Runtime.enable')).error).toBeUndefined()
expect((await secondCdp.call('Runtime.getProperties', { objectId: disabledObjectId })).error).toBeDefined()
})
it('uses the same Runtime value model for Host and Client realms', async () => {
inspector = await startInspector({ port: 0, captureFetch: false })
client = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Compatibility Client' })
cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
await cdp.call('Runtime.enable')
const clientContextId = await clientContext(cdp)
for (const [name, contextId] of [['Host', undefined], ['Client', clientContextId]] as const) {
const select = contextId === undefined ? {} : { contextId }
const nan = await cdp.call('Runtime.evaluate', { expression: 'NaN', ...select })
expect(nan.result?.result, name).toMatchObject({ type: 'number', unserializableValue: 'NaN' })
const array = await cdp.call('Runtime.evaluate', {
expression: '[1, 2]',
objectGroup: `compat-${name}`,
...select,
})
const arrayObject = asRecord(array.result?.result)
expect(arrayObject, name).toMatchObject({ type: 'object', subtype: 'array', className: 'Array' })
const properties = await cdp.call('Runtime.getProperties', {
objectId: arrayObject.objectId,
ownProperties: true,
})
const first = recordArray(properties.result?.result).find(property => property.name === '0')
expect(first, name).toMatchObject({ configurable: true, enumerable: true, writable: true })
expect(asRecord(first?.value), name).toMatchObject({ type: 'number', value: 1 })
const thrown = await cdp.call('Runtime.evaluate', {
expression: 'throw new TypeError("realm-compatibility")',
...select,
})
expect(thrown.result?.result, name).toMatchObject({ type: 'object', subtype: 'error' })
expect(thrown.result?.exceptionDetails, name).toMatchObject({ text: 'Uncaught' })
expect((await cdp.call('Runtime.releaseObjectGroup', { objectGroup: `compat-${name}` })).error).toBeUndefined()
expect((await cdp.call('Runtime.getProperties', { objectId: arrayObject.objectId })).error).toBeDefined()
}
expect((await cdp.call('Runtime.evaluate', {
expression: '1 + 1',
throwOnSideEffect: true,
})).result?.result).toMatchObject({ type: 'number', value: 2 })
expect((await cdp.call('Runtime.evaluate', {
expression: '1 + 1',
contextId: clientContextId,
throwOnSideEffect: true,
})).error?.message).toContain('does not support throwOnSideEffect')
expect((await cdp.call('Runtime.compileScript', {
expression: '1 + 1',
sourceURL: 'client-eval.js',
persistScript: true,
executionContextId: clientContextId,
})).error?.message).toContain('Client realm has no native CDP transport')
})
it('forwards Client Console objects through isolated realm sessions', async () => {
inspector = await startInspector({ port: 0, captureFetch: false })
client = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Console Client' })
cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
secondCdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
await Promise.all([cdp.call('Runtime.enable'), secondCdp.call('Runtime.enable')])
const firstContext = await clientContext(cdp)
const secondContext = await clientContext(secondCdp)
const value = { owner: 'client-console' }
const marker = 'client-console-event'
await client.log(value, marker)
let firstEvent: CdpMessage | undefined
let secondEvent: CdpMessage | undefined
await vi.waitFor(() => {
firstEvent = consoleEvent(cdp!, firstContext, marker)
secondEvent = consoleEvent(secondCdp!, secondContext, marker)
expect(firstEvent).toBeDefined()
expect(secondEvent).toBeDefined()
})
const firstObjectId = asRecord(recordArray(firstEvent!.params?.args)[0]).objectId
const secondObjectId = asRecord(recordArray(secondEvent!.params?.args)[0]).objectId
expect(firstObjectId).toBeTypeOf('string')
expect(secondObjectId).toBeTypeOf('string')
expect(firstObjectId).not.toBe(secondObjectId)
expect((await secondCdp.call('Runtime.getProperties', { objectId: firstObjectId })).error).toBeDefined()
const secondProperties = await secondCdp.call('Runtime.getProperties', {
objectId: secondObjectId,
ownProperties: true,
})
const owner = recordArray(secondProperties.result?.result).find(property => property.name === 'owner')
expect(asRecord(owner?.value).value).toBe('client-console')
expect((await cdp.call('Runtime.discardConsoleEntries')).error).toBeUndefined()
expect((await cdp.call('Runtime.getProperties', { objectId: firstObjectId })).error).toBeDefined()
expect((await secondCdp.call('Runtime.getProperties', { objectId: secondObjectId })).error).toBeUndefined()
})
it('projects a chunked Client bundle as read-only Debugger source', async () => {
const sourceText = `const clientSourceMarker = 42\n/*${'x'.repeat(150_000)}*/\n`
const sourceMap = JSON.stringify({ version: 3, sources: ['client/index.ts'], mappings: 'AAAA' })
const sourceUrl = 'http://client.test/plugins/inspector/client.js?rev=test'
const sourceMapUrl = 'http://client.test/plugins/inspector/client.js.map?rev=test'
inspector = await startInspector({ port: 0, captureFetch: false, maxClientSourceBytes: 1_000_000 })
client = await InspectorClientFixture.start(inspector.endpoint.client, {
label: 'Source Client',
sourceCatalog: { sourceText, sourceMap, sourceUrl, sourceMapUrl },
})
cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
await cdp.call('Runtime.enable')
const contextId = await clientContext(cdp)
expect((await cdp.call('Debugger.enable')).error).toBeUndefined()
let script: CdpMessage | undefined
await vi.waitFor(() => {
script = cdp!.events.find(event => event.method === 'Debugger.scriptParsed'
&& event.params?.url === sourceUrl)
expect(script).toBeDefined()
})
expect(script?.params).toMatchObject({
executionContextId: contextId,
sourceMapURL: sourceMapUrl,
hash: 'test',
isModule: false,
length: sourceText.length,
})
const scriptId = script?.params?.scriptId
expect(scriptId).toBeTypeOf('string')
await expect(cdp.call('Debugger.getScriptSource', { scriptId })).resolves.toMatchObject({
result: { scriptSource: sourceText },
})
await expect(cdp.call('Debugger.searchInContent', {
scriptId,
query: 'clientSourceMarker',
caseSensitive: true,
})).resolves.toMatchObject({
result: { result: [{ lineNumber: 0, lineContent: 'const clientSourceMarker = 42' }] },
})
expect((await cdp.call('Debugger.setBreakpointByUrl', { url: sourceUrl, lineNumber: 0 })).error?.message)
.toContain('Client native debugging is unavailable')
expect((await cdp.call('Debugger.setBreakpointByUrl', {
urlRegex: 'client\\.js',
lineNumber: 0,
})).error?.message).toContain('Client native debugging is unavailable')
expect((await cdp.call('Debugger.setBreakpointByUrl', {
scriptHash: 'test',
lineNumber: 0,
})).error?.message).toContain('Client native debugging is unavailable')
expect((await cdp.call('Debugger.evaluateOnCallFrame', {
callFrameId: 'client:unsupported-frame',
expression: '1',
})).error?.message).toContain('Client native debugging is unavailable')
})
it('projects full Host fetch data through the Network domain', async () => {
server = createServer((request, response) => {
let body = ''
request.setEncoding('utf8')
request.on('data', (chunk: string) => { body += chunk })
request.on('end', () => {
response.writeHead(201, { authorization: 'response-secret', 'content-type': 'application/json' })
response.end(JSON.stringify({ body }))
})
})
await new Promise<void>((resolve) => { server!.listen(0, '127.0.0.1', () => { resolve() }) })
const port = (server.address() as import('node:net').AddressInfo).port
inspector = await startInspector({ port: 0 })
cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
await cdp.call('Network.enable')
const response = await fetch(`http://127.0.0.1:${String(port)}/capture?secret=query`, {
method: 'POST',
headers: { authorization: 'Bearer request-secret' },
body: 'request-body',
})
expect(await response.json()).toEqual({ body: 'request-body' })
let started: CdpMessage | undefined
await vi.waitFor(() => {
started = cdp!.events.find(event =>
event.method === 'Network.requestWillBeSent'
&& String((event.params?.request as Record<string, unknown> | undefined)?.url).includes('/capture'))
expect(started).toBeDefined()
expect(cdp!.events.some(event =>
event.method === 'Network.loadingFinished'
&& event.params?.requestId === started!.params?.requestId)).toBe(true)
})
const request = started!.params?.request as Record<string, unknown>
expect(request.url).toBe(`http://127.0.0.1:${String(port)}/capture?secret=query`)
expect(request.headers).toMatchObject({ authorization: 'Bearer request-secret' })
const requestId = started!.params?.requestId
const post = await cdp.call('Network.getRequestPostData', { requestId })
expect(post.result?.postData).toBe('request-body')
const body = await cdp.call('Network.getResponseBody', { requestId })
expect(Buffer.from(String(body.result?.body), 'base64').toString('utf8')).toBe('{"body":"request-body"}')
})
it('streams later Host fetch response chunks to an opted-in CDP connection', async () => {
const continueResponse = Promise.withResolvers<true>()
const firstChunk = 'data: first\n\n'
const laterChunk = 'event: update\nid: 2\ndata: second\ndata: line\n\n'
server = createServer((_request, response) => {
response.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8' })
response.write(firstChunk)
void continueResponse.promise.then(() => { response.end(laterChunk) })
})
await new Promise<void>((resolve) => { server!.listen(0, '127.0.0.1', () => { resolve() }) })
const port = (server.address() as import('node:net').AddressInfo).port
inspector = await startInspector({ port: 0 })
cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
await cdp.call('Network.enable')
try {
const response = await fetch(`http://127.0.0.1:${String(port)}/events`)
let requestId: string | undefined
await vi.waitFor(() => {
const received = cdp!.events.find(event =>
event.method === 'Network.responseReceived'
&& (event.params?.response as Record<string, unknown> | undefined)?.mimeType === 'text/event-stream')
requestId = received?.params?.requestId as string | undefined
expect(requestId).toBeTypeOf('string')
expect(cdp!.events.some(event =>
event.method === 'Network.dataReceived'
&& event.params?.requestId === requestId)).toBe(true)
})
if (requestId === undefined) throw new Error('SSE request was not observed')
const streaming = await cdp.call('Network.streamResourceContent', { requestId })
expect(Buffer.from(String(streaming.result?.bufferedData), 'base64').toString('utf8')).toBe(firstChunk)
const laterEventOffset = cdp.events.length
continueResponse.resolve(true)
expect(await response.text()).toBe(firstChunk + laterChunk)
await vi.waitFor(() => {
expect(cdp!.events.some(event =>
event.method === 'Network.loadingFinished'
&& event.params?.requestId === requestId)).toBe(true)
const streamed = cdp!.events.slice(laterEventOffset)
.filter(event => event.method === 'Network.dataReceived'
&& event.params?.requestId === requestId
&& typeof event.params?.data === 'string')
.map(event => Buffer.from(String(event.params!.data), 'base64'))
expect(Buffer.concat(streamed).toString('utf8')).toBe(laterChunk)
})
const body = await cdp.call('Network.getResponseBody', { requestId })
expect(Buffer.from(String(body.result?.body), 'base64').toString('utf8')).toBe(firstChunk + laterChunk)
} finally {
continueResponse.resolve(true)
}
})
})
async function clientContext(client: TestCdpClient): Promise<number> {
let contextId: number | undefined
await vi.waitFor(() => {
const context = runtimeContexts(client).find(candidate => String(candidate.name).startsWith('Client —'))
expect(context).toBeDefined()
contextId = context?.id as number
})
if (contextId === undefined) throw new Error('Client execution context was not announced')
return contextId
}
function runtimeContexts(client: TestCdpClient): Readonly<Record<string, unknown>>[] {
return client.events
.filter(event => event.method === 'Runtime.executionContextCreated')
.map(event => asRecord(event.params?.context))
}
function consoleEvent(client: TestCdpClient, contextId: number, marker: string): CdpMessage | undefined {
return client.events.find((event) => {
if (event.method !== 'Runtime.consoleAPICalled' || event.params?.executionContextId !== contextId) return false
const args = event.params.args
return Array.isArray(args) && args.some(argument => asRecord(argument).value === marker)
})
}
function recordArray(value: unknown): Readonly<Record<string, unknown>>[] {
if (!Array.isArray(value)) throw new Error('expected an array of records')
return value.map(asRecord)
}
function asRecord(value: unknown): Readonly<Record<string, unknown>> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('expected a record')
return value as Readonly<Record<string, unknown>>
}
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')
}