From c4e07f4c0b2de2212016b42afee197f5a3b1328e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 23:33:29 +0000 Subject: [PATCH 1/3] bun-inspector-protocol: carry the types other domains refer to into the JSC snapshot generate-protocol.ts kept only the domains whose debuggableTypes list "javascript", but Console, Debugger and Runtime $ref Network.RequestId, Network.FrameId and GenericTypes.SearchMatch, so the generated jsc/index.d.ts referred to two namespaces it never declared. Every consumer compiles with skipLibCheck, so those four properties silently typed as the error type instead of string / SearchMatch[]. The generator now keeps domains that declare no debuggableTypes (GenericTypes), which WebKit's frontend activates for every debuggable, and emits a types-only copy of any other domain whose types the selected domains refer to, transitively (Network with FrameId and RequestId). A $ref may also name a primitive (Runtime.PropertyDescriptor.isPrivate is a $ref to boolean); those are left alone. The same closure is applied to the V8 snapshot when it is regenerated with --v8; its committed files are not regenerated here. Namespaces are closed with "}" rather than "};" so the output is a valid .d.ts before prettier runs. The script's CLI moved under import.meta.main so the test can import formatProtocol and selectJscDomains. The test now checks that every $ref in protocol.json resolves, that the generator carries along exactly the referenced types for a small fixture, that the committed index.d.ts (and the fixture's) type-checks without skipLibCheck, and that bun has no agent for the types-only domains. --- .../scripts/generate-protocol.ts | 211 +++++++++++++----- .../src/protocol/jsc/index.d.ts | 25 +++ .../src/protocol/jsc/protocol.json | 40 ++++ .../src/protocol/schema.d.ts | 5 + .../inspect/bun-inspector-protocol.test.ts | 202 +++++++++++++++-- test/tsconfig.json | 1 + 6 files changed, 415 insertions(+), 69 deletions(-) diff --git a/packages/bun-inspector-protocol/scripts/generate-protocol.ts b/packages/bun-inspector-protocol/scripts/generate-protocol.ts index 6a074a44c728..6141c7466875 100644 --- a/packages/bun-inspector-protocol/scripts/generate-protocol.ts +++ b/packages/bun-inspector-protocol/scripts/generate-protocol.ts @@ -12,13 +12,17 @@ // // Pass --v8 to also refresh src/protocol/v8 from the Chrome DevTools protocol // repository (requires network access). +// +// test/cli/inspect/bun-inspector-protocol.test.ts imports the exported functions +// and checks the generated files, so only the `import.meta.main` block below may +// have side effects. import { spawnSync } from "node:child_process"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import path from "node:path"; import type { Domain, Property, Protocol } from "../src/protocol/schema"; -function formatProtocol(protocol: Protocol, extraTs?: string): string { +export function formatProtocol(protocol: Protocol, extraTs?: string): string { const { name, domains } = protocol; const eventMap = new Map(); const commandMap = new Map(); @@ -57,7 +61,7 @@ function formatProtocol(protocol: Protocol, extraTs?: string): string { properties: returns, }); } - body += "};"; + body += "}"; } for (const type of ["Event", "Request", "Response"]) { const sourceMap = type === "Event" ? eventMap : commandMap; @@ -74,7 +78,7 @@ function formatProtocol(protocol: Protocol, extraTs?: string): string { if (extraTs) { body += extraTs; } - return body + "};"; + return body + "}"; } function formatProperty(property: Property): string { @@ -132,6 +136,101 @@ function formatProperty(property: Property): string { return body; } +/** + * Besides a type of the referring domain (`RemoteObject`) or of another one (`Network.RequestId`), a + * `$ref` may name one of these, the types WebKit's protocol generator predeclares + * (inspector/scripts/codegen/models.py, resolve_types): Runtime.PropertyDescriptor.isPrivate is a + * `$ref` to `boolean`. There is nothing to declare for them. + */ +export const primitiveTypes: ReadonlySet = new Set(["any", "boolean", "integer", "number", "object", "string"]); + +/** + * The domains of the snapshot: those of `all` (a CombinedDomains.json) that a JavaScript debuggable (a bun + * process, as opposed to a web page or a service worker) speaks, by the rule WebKit's own frontend applies + * in InspectorBackend.activateDomain (a domain applies to the debuggable types it lists, and one that lists + * none, like GenericTypes, applies to every debuggable), except `domainsWithoutAgent`; plus the types they + * refer to in the remaining domains. + */ +export function selectJscDomains(all: readonly Domain[], domainsWithoutAgent: ReadonlySet): Domain[] { + const selected = all.filter( + ({ domain, debuggableTypes }) => + (debuggableTypes === undefined || debuggableTypes.includes("javascript")) && !domainsWithoutAgent.has(domain), + ); + return withReferencedTypes(selected, all); +} + +/** + * `selected`, plus a types-only copy of every other domain of `all` whose types they `$ref` + * (Console.ConsoleMessage has a Network.RequestId, although Network itself is a web page domain), + * holding just the referenced types and whatever those refer to in turn. Without them, index.d.ts + * names namespaces it does not declare, which every consumer compiles with skipLibCheck, so the + * affected properties silently type-check as anything. + */ +function withReferencedTypes(selected: readonly Domain[], all: readonly Domain[]): Domain[] { + const allByName = new Map(all.map(domain => [domain.domain, domain])); + const selectedNames = new Set(selected.map(domain => domain.domain)); + /** Domain name to the ids of its types that `selected` refers to, for domains outside `selected`. */ + const referenced = new Map>(); + + function visit(property: Property, domain: string): void { + if ("$ref" in property) { + const { $ref } = property; + const [refDomain, id] = $ref.includes(".") ? $ref.split(".") : [domain, $ref]; + if (selectedNames.has(refDomain)) { + return; + } + const type = allByName.get(refDomain)?.types?.find(type => type.id === id); + if (!type) { + if (primitiveTypes.has($ref)) { + return; + } + throw new Error(`${domain} refers to ${$ref}, which no domain declares`); + } + const ids = referenced.get(refDomain) ?? new Set(); + referenced.set(refDomain, ids); + if (!ids.has(id)) { + ids.add(id); + visit(type, refDomain); + } + } else if (property.type === "array") { + if (property.items) { + visit(property.items, domain); + } + } else if (property.type === "object") { + for (const member of property.properties ?? []) { + visit(member, domain); + } + } + } + + for (const { domain, types = [], commands = [], events = [] } of selected) { + for (const type of types) { + visit(type, domain); + } + for (const { parameters = [], returns = [] } of commands) { + for (const property of [...parameters, ...returns]) { + visit(property, domain); + } + } + for (const { parameters = [] } of events) { + for (const property of parameters) { + visit(property, domain); + } + } + } + + const referencedDomains = [...referenced].map(([name, ids]): Domain => { + const { debuggableTypes, types = [] } = allByName.get(name)!; + return { + domain: name, + description: "Only the types that the other domains of this snapshot refer to.", + debuggableTypes, + types: types.filter(type => ids.has(type.id!)), + }; + }); + return [...selected, ...referencedDomains].sort((a, b) => a.domain.localeCompare(b.domain)); +} + /** * @link https://github.com/ChromeDevTools/devtools-protocol/tree/master/json */ @@ -141,13 +240,17 @@ async function downloadV8(): Promise { return Promise.all([ download(`${baseUrl}/js_protocol.json`), download(`${baseUrl}/browser_protocol.json`), - ]).then(([js, browser]) => ({ - name: "V8", - version: js.version, - domains: [...js.domains, ...browser.domains] - .filter(domain => !domains.includes(domain.domain)) - .sort((a, b) => a.domain.localeCompare(b.domain)), - })); + ]).then(([js, browser]) => { + const all = [...js.domains, ...browser.domains]; + return { + name: "V8", + version: js.version, + domains: withReferencedTypes( + all.filter(domain => !domains.includes(domain.domain)), + all, + ), + }; + }); } async function download(url: string): Promise { @@ -202,49 +305,49 @@ function findPinnedCombinedDomains(): string | undefined { */ const domainsWithoutAgent = new Set(["File", "Process"]); -const args = process.argv.slice(2); -const includeV8 = args.includes("--v8"); -const combinedDomainsPath = args.find(arg => !arg.startsWith("--")) ?? findPinnedCombinedDomains(); -if (!combinedDomainsPath) { - console.error( - "Could not find CombinedDomains.json for the pinned WebKit version. " + - "Run `bun bd` first or pass the path to a WebKit build's CombinedDomains.json.", - ); - process.exit(1); -} -console.log(`Reading ${combinedDomainsPath}`); -const combinedDomains: { domains: Domain[] } = await Bun.file(combinedDomainsPath).json(); - -const protocolDir = path.resolve(import.meta.dir, "..", "src", "protocol"); -const written: string[] = []; -const write = (name: string, data: string) => { - const filePath = path.join(protocolDir, name); - writeFileSync(filePath, data); - written.push(filePath); -}; -const base = readFileSync(path.join(protocolDir, "protocol.d.ts"), "utf-8"); -const baseNoComments = base.replace(/\/\/.*/g, ""); - -const jsc: Protocol = { - name: "JSC", - version: { - major: 1, - minor: 4, - }, - domains: combinedDomains.domains - .filter(domain => domain.debuggableTypes?.includes("javascript") && !domainsWithoutAgent.has(domain.domain)) - .sort((a, b) => a.domain.localeCompare(b.domain)), -}; -write("jsc/protocol.json", JSON.stringify(jsc, null, 2)); -write("jsc/index.d.ts", "// GENERATED - DO NOT EDIT\n" + formatProtocol(jsc, baseNoComments)); - -if (includeV8) { - const v8 = await downloadV8(); - write("v8/protocol.json", JSON.stringify(v8)); - write("v8/index.d.ts", "// GENERATED - DO NOT EDIT\n" + formatProtocol(v8, baseNoComments)); -} +if (import.meta.main) { + const args = process.argv.slice(2); + const includeV8 = args.includes("--v8"); + const combinedDomainsPath = args.find(arg => !arg.startsWith("--")) ?? findPinnedCombinedDomains(); + if (!combinedDomainsPath) { + console.error( + "Could not find CombinedDomains.json for the pinned WebKit version. " + + "Run `bun bd` first or pass the path to a WebKit build's CombinedDomains.json.", + ); + process.exit(1); + } + console.log(`Reading ${combinedDomainsPath}`); + const combinedDomains: { domains: Domain[] } = await Bun.file(combinedDomainsPath).json(); -const { status } = spawnSync("bunx", ["prettier", "--write", ...written], { cwd: repoRoot, stdio: "inherit" }); -if (status !== 0) { - process.exit(status ?? 1); + const protocolDir = path.resolve(import.meta.dir, "..", "src", "protocol"); + const written: string[] = []; + const write = (name: string, data: string) => { + const filePath = path.join(protocolDir, name); + writeFileSync(filePath, data); + written.push(filePath); + }; + const base = readFileSync(path.join(protocolDir, "protocol.d.ts"), "utf-8"); + const baseNoComments = base.replace(/\/\/.*/g, ""); + + const jsc: Protocol = { + name: "JSC", + version: { + major: 1, + minor: 4, + }, + domains: selectJscDomains(combinedDomains.domains, domainsWithoutAgent), + }; + write("jsc/protocol.json", JSON.stringify(jsc, null, 2)); + write("jsc/index.d.ts", "// GENERATED - DO NOT EDIT\n" + formatProtocol(jsc, baseNoComments)); + + if (includeV8) { + const v8 = await downloadV8(); + write("v8/protocol.json", JSON.stringify(v8)); + write("v8/index.d.ts", "// GENERATED - DO NOT EDIT\n" + formatProtocol(v8, baseNoComments)); + } + + const { status } = spawnSync("bunx", ["prettier", "--write", ...written], { cwd: repoRoot, stdio: "inherit" }); + if (status !== 0) { + process.exit(status ?? 1); + } } diff --git a/packages/bun-inspector-protocol/src/protocol/jsc/index.d.ts b/packages/bun-inspector-protocol/src/protocol/jsc/index.d.ts index fa466c69f6b0..d804e16639f2 100644 --- a/packages/bun-inspector-protocol/src/protocol/jsc/index.d.ts +++ b/packages/bun-inspector-protocol/src/protocol/jsc/index.d.ts @@ -1393,6 +1393,21 @@ export namespace JSC { */ export type SetBlackboxBreakpointEvaluationsResponse = {}; } + export namespace GenericTypes { + /** + * Search match in a resource. + */ + export type SearchMatch = { + /** + * Line number in resource content. + */ + lineNumber: number; + /** + * Line with match content. + */ + lineContent: string; + }; + } export namespace Heap { /** * Information about a garbage collection. @@ -2040,6 +2055,16 @@ export namespace JSC { argv: string[]; }; } + export namespace Network { + /** + * Unique frame identifier. + */ + export type FrameId = string; + /** + * Unique request identifier. + */ + export type RequestId = string; + } export namespace Runtime { /** * Unique object identifier. diff --git a/packages/bun-inspector-protocol/src/protocol/jsc/protocol.json b/packages/bun-inspector-protocol/src/protocol/jsc/protocol.json index ed5e1657d39c..cfb6e76e6b50 100644 --- a/packages/bun-inspector-protocol/src/protocol/jsc/protocol.json +++ b/packages/bun-inspector-protocol/src/protocol/jsc/protocol.json @@ -1603,6 +1603,29 @@ } ] }, + { + "domain": "GenericTypes", + "description": "Exposes generic types to be used by any domain.", + "types": [ + { + "id": "SearchMatch", + "type": "object", + "description": "Search match in a resource.", + "properties": [ + { + "name": "lineNumber", + "type": "number", + "description": "Line number in resource content." + }, + { + "name": "lineContent", + "type": "string", + "description": "Line with match content." + } + ] + } + ] + }, { "domain": "Heap", "description": "Heap domain exposes JavaScript heap attributes and capabilities.", @@ -2348,6 +2371,23 @@ } ] }, + { + "domain": "Network", + "description": "Only the types that the other domains of this snapshot refer to.", + "debuggableTypes": ["itml", "service-worker", "web-page"], + "types": [ + { + "id": "FrameId", + "type": "string", + "description": "Unique frame identifier." + }, + { + "id": "RequestId", + "type": "string", + "description": "Unique request identifier." + } + ] + }, { "domain": "Runtime", "description": "Runtime domain exposes JavaScript runtime by means of remote evaluation and mirror objects. Evaluation results are returned as mirror object that expose object type, string representation and unique identifier that can be used for further object reference. Original objects are maintained in memory unless they are either explicitly released or are released along with the other objects in their object group.", diff --git a/packages/bun-inspector-protocol/src/protocol/schema.d.ts b/packages/bun-inspector-protocol/src/protocol/schema.d.ts index ec5e0a672e1e..fe558e7d8e2e 100644 --- a/packages/bun-inspector-protocol/src/protocol/schema.d.ts +++ b/packages/bun-inspector-protocol/src/protocol/schema.d.ts @@ -11,6 +11,7 @@ export type Protocol = { export type Domain = { readonly domain: string; + readonly description?: string; readonly debuggableTypes?: readonly string[]; readonly dependencies?: readonly string[]; readonly types?: readonly Property[]; @@ -54,6 +55,10 @@ export type Property = { } | { readonly type: undefined; + /** + * A type of the same domain (`RemoteObject`), of another domain (`Network.RequestId`), or one of + * the primitives in `primitiveTypes` of scripts/generate-protocol.ts (`boolean`). + */ readonly $ref: string; } ); diff --git a/test/cli/inspect/bun-inspector-protocol.test.ts b/test/cli/inspect/bun-inspector-protocol.test.ts index 64d58cc7936b..e8a5d98f3d7c 100644 --- a/test/cli/inspect/bun-inspector-protocol.test.ts +++ b/test/cli/inspect/bun-inspector-protocol.test.ts @@ -1,19 +1,64 @@ // packages/bun-inspector-protocol ships a snapshot of the inspector protocol of the WebKit // build bun links against (src/protocol/jsc/protocol.json, from which index.d.ts is -// generated). Nothing regenerates it when WebKit is bumped, so this test runs a short -// debugging session against this build of bun and validates every message it sends +// generated). Nothing regenerates it when WebKit is bumped, so the last test here runs a +// short debugging session against this build of bun and validates every message it sends // against the snapshot. If it fails after a WebKit upgrade, regenerate the snapshot: // // bun packages/bun-inspector-protocol/scripts/generate-protocol.ts +// +// The tests before it check that the snapshot is self-contained: the domains it holds refer +// to types of domains that only exist for web pages (Network.RequestId), which the generator +// has to carry along for index.d.ts to type-check. Its consumers compile with skipLibCheck, +// so a reference it leaves dangling is not a compile error for them, just a property that +// silently types as anything. import { spawn } from "bun"; import { expect, test } from "bun:test"; -import { bunEnv, bunExe, tempDir } from "harness"; -import { basename } from "node:path"; +import { bunEnv, bunExe, isDebug, tempDir } from "harness"; +import { basename, join } from "node:path"; +import { + formatProtocol, + primitiveTypes, + selectJscDomains, +} from "../../../packages/bun-inspector-protocol/scripts/generate-protocol"; import protocolJson from "../../../packages/bun-inspector-protocol/src/protocol/jsc/protocol.json"; -import type { Property, Protocol } from "../../../packages/bun-inspector-protocol/src/protocol/schema"; +import type { Domain, Property, Protocol } from "../../../packages/bun-inspector-protocol/src/protocol/schema"; +const protocolDir = join(import.meta.dir, "../../../packages/bun-inspector-protocol/src/protocol"); const protocol = protocolJson as Protocol; const domains = new Map(protocol.domains.map(domain => [domain.domain, domain])); +/** The domains that are in the snapshot only because domains bun speaks refer to their types. */ +const typesOnlyDomains = protocol.domains + .filter(domain => !domain.commands && !domain.events) + .map(domain => domain.domain); + +/** + * The snapshot's declaration of the type a `$ref` names (a bare name is a type of the referring domain), + * or an equivalent declaration when it names a primitive; see `$ref` in schema.d.ts. + */ +function resolveRef($ref: string, domain: string): { type: Property; domain: string } | undefined { + const [refDomain, id] = $ref.includes(".") ? $ref.split(".") : [domain, $ref]; + const type = domains.get(refDomain)?.types?.find(type => type.id === id); + if (type) return { type, domain: refDomain }; + if (primitiveTypes.has($ref)) return { type: { type: $ref } as Property, domain }; + return undefined; +} + +/** What tsc reports for a .d.ts checked on its own, i.e. what a consumer would see without skipLibCheck. */ +async function typeErrors(dtsPath: string): Promise { + // Loading typescript takes tens of seconds in a debug build of bun, so only the test that needs it pays for it. + const ts = (await import("typescript")).default; + const program = ts.createProgram([dtsPath], { + strict: true, + noEmit: true, + skipLibCheck: false, + lib: ["lib.es5.d.ts"], + types: [], + }); + return ts.getPreEmitDiagnostics(program).map(({ file, start, messageText }) => { + const line = file && start !== undefined ? file.getLineAndCharacterOfPosition(start).line + 1 : "?"; + return `${basename(file?.fileName ?? "")}:${line}: ${ts.flattenDiagnosticMessageText(messageText, "\n")}`; + }); +} function declaredEventParameters(method: string): readonly Property[] | undefined { const [domain, name] = method.split("."); @@ -30,11 +75,12 @@ function declaredCommandReturns(method: string): readonly Property[] | undefined /** Appends to `problems` every way `value` disagrees with `property`, the snapshot's declaration of it. */ function check(value: unknown, property: Property, domain: string, where: string, problems: string[]): void { if ("$ref" in property) { - const [refDomain, refId] = property.$ref.includes(".") ? property.$ref.split(".") : [domain, property.$ref]; - const type = domains.get(refDomain)?.types?.find(type => type.id === refId); - // A few JavaScriptCore types reference domains that only exist for web pages (e.g. Network.RequestId). - // Those domains are not part of the snapshot, and bun never sends values of those types. - if (type) check(value, type, refDomain, where, problems); + const target = resolveRef(property.$ref, domain); + if (target) { + check(value, target.type, target.domain, where, problems); + } else { + problems.push(`${where}: ${property.$ref} is not in the snapshot`); + } return; } switch (property.type) { @@ -94,6 +140,127 @@ function checkObject( } } +test("every type the snapshot refers to is in the snapshot", () => { + const dangling: string[] = []; + function visit(property: Property, domain: string, where: string): void { + if ("$ref" in property) { + if (!resolveRef(property.$ref, domain)) dangling.push(`${where}: ${property.$ref}`); + } else if (property.type === "array") { + if (property.items) visit(property.items, domain, `${where}[]`); + } else if (property.type === "object") { + for (const member of property.properties ?? []) visit(member, domain, `${where}.${member.name}`); + } + } + for (const { domain, types = [], commands = [], events = [] } of protocol.domains) { + for (const type of types) visit(type, domain, `${domain}.${type.id}`); + for (const { name, parameters = [], returns = [] } of commands) { + for (const parameter of parameters) visit(parameter, domain, `${domain}.${name} parameter ${parameter.name}`); + for (const returned of returns) visit(returned, domain, `${domain}.${name} returns ${returned.name}`); + } + for (const { name, parameters = [] } of events) { + for (const parameter of parameters) visit(parameter, domain, `${domain}.${name} parameter ${parameter.name}`); + } + } + expect(dangling).toEqual([]); +}); + +const ref = ($ref: string, name?: string): Property => ({ name, type: undefined, $ref }); +// A miniature CombinedDomains.json, in which Debugger is the only domain declared for JavaScript debuggables +// that bun has an agent for. +const debuggerDomain: Domain = { + domain: "Debugger", + debuggableTypes: ["javascript", "web-page"], + types: [{ id: "Location", type: "object", properties: [ref("Page.FrameId", "frameId"), ref("Process.Id", "pid")] }], + commands: [ + { name: "searchInContent", returns: [{ name: "result", type: "array", items: ref("GenericTypes.SearchMatch") }] }, + ], + events: [{ name: "paused", parameters: [ref("Page.PauseReason", "reason")] }], +}; +const combinedDomains: Domain[] = [ + debuggerDomain, + // Declares no debuggableTypes, so it applies to every debuggable and is kept whole. + { + domain: "GenericTypes", + types: [ + { id: "SearchMatch", type: "string" }, + { id: "Unreferenced", type: "string" }, + ], + }, + { + domain: "Page", + debuggableTypes: ["web-page"], + types: [ + // Referred to by Debugger; refers on to a primitive and, by its bare name, to LoaderId, which refers on + // to a third domain. + { id: "FrameId", type: "object", properties: [ref("boolean", "isMainFrame"), ref("LoaderId", "loaderId")] }, + { id: "LoaderId", type: "array", items: ref("Network.RequestId") }, + { id: "PauseReason", type: "string", enum: ["breakpoint", "exception"] }, + { id: "Unreferenced", type: "string" }, + ], + commands: [{ name: "navigate" }], + events: [{ name: "loaded" }], + }, + { + domain: "Network", + debuggableTypes: ["web-page"], + types: [ + { id: "RequestId", type: "string" }, + { id: "Unreferenced", type: "string" }, + ], + }, + // Nothing refers to it. + { domain: "DOM", debuggableTypes: ["web-page"], types: [{ id: "NodeId", type: "integer" }] }, + // Declared for JavaScript, but bun has no agent for it, so only what Debugger refers to is kept. + { + domain: "Process", + debuggableTypes: ["javascript"], + types: [{ id: "Id", type: "integer" }], + commands: [{ name: "enable" }], + }, +]; + +test("generate-protocol.ts carries along the types that the JavaScript domains refer to in other domains", () => { + const selected = selectJscDomains(combinedDomains, new Set(["Process"])); + expect(selected.map(domain => ({ ...domain, types: domain.types?.map(type => type.id) }))).toEqual([ + { ...debuggerDomain, types: ["Location"] }, + { domain: "GenericTypes", types: ["SearchMatch", "Unreferenced"] }, + { domain: "Network", description: expect.any(String), debuggableTypes: ["web-page"], types: ["RequestId"] }, + { + domain: "Page", + description: expect.any(String), + debuggableTypes: ["web-page"], + types: ["FrameId", "LoaderId", "PauseReason"], + }, + { domain: "Process", description: expect.any(String), debuggableTypes: ["javascript"], types: ["Id"] }, + ]); +}); + +// The two tests above check the same thing at the protocol.json level; this one is what the consumers of the +// package would see, but type-checking takes tens of seconds in a debug build of bun. +test.skipIf(isDebug)("the generated index.d.ts type-checks without skipLibCheck", async () => { + expect(await typeErrors(join(protocolDir, "jsc/index.d.ts"))).toEqual([]); + + const version = { major: 1, minor: 0 }; + using dir = tempDir("bun-inspector-protocol-generated", { + "selected.d.ts": formatProtocol({ + name: "JSC", + version, + domains: selectJscDomains(combinedDomains, new Set(["Process"])), + }), + // The JavaScript domains on their own, which is what the generator used to emit. + "debugger-only.d.ts": formatProtocol({ name: "JSC", version, domains: [debuggerDomain] }), + }); + expect(await typeErrors(join(String(dir), "selected.d.ts"))).toEqual([]); + expect( + (await typeErrors(join(String(dir), "debugger-only.d.ts"))).map(error => error.replace(/^.*?:\d+: /, "")).sort(), + ).toEqual([ + "Cannot find namespace 'GenericTypes'.", + "Cannot find namespace 'Page'.", + "Cannot find namespace 'Page'.", + "Cannot find namespace 'Process'.", + ]); +}); + // An ES module and a CommonJS module, so Debugger.scriptParsed is sent for both script types. const fixtureFiles = ["entry.mjs", "dep.cjs"]; @@ -212,12 +379,13 @@ test("the protocol snapshot in packages/bun-inspector-protocol matches what bun send("Debugger.setPauseOnDebuggerStatements", { enabled: true }), ]); - // Conversely, these domains are left out of the snapshot by generate-protocol.ts because bun has - // no agent for them. Once bun answers, remove the domain from the generator's list and regenerate. - for (const domain of ["File", "Process"]) { + // Conversely, bun has no agent for these domains: generate-protocol.ts leaves File and Process out + // of the snapshot for that reason, and holds only the types of the others. Once bun answers one of + // them, its commands belong in the snapshot: update the generator's list and regenerate. + for (const domain of ["File", "Process", ...typesOnlyDomains]) { const { error } = await request(`${domain}.enable`); if (error?.message !== `'${domain}' domain was not found`) { - problems.push(`${domain}: bun implements this domain, but generate-protocol.ts excludes it from the snapshot`); + problems.push(`${domain}: bun implements this domain, but the snapshot has none of its commands`); } } @@ -237,20 +405,24 @@ test("the protocol snapshot in packages/bun-inspector-protocol matches what bun } expect([...new Set(problems)]).toEqual([]); - // JavaScriptCore's own domains plus the agents bun registers in src/jsc/bindings/BunDebugger.cpp. + // JavaScriptCore's own domains, the agents bun registers in src/jsc/bindings/BunDebugger.cpp, and the + // domains those refer to the types of. expect(protocol.domains.map(domain => domain.domain)).toEqual([ "Audit", "BunFrontendDevServer", "Console", "Debugger", + "GenericTypes", "Heap", "HTTPServer", "Inspector", "LifecycleReporter", + "Network", "Runtime", "ScriptProfiler", "TestReporter", ]); + expect(typesOnlyDomains).toEqual(["GenericTypes", "Network"]); expect(scriptTypes).toEqual({ "entry.mjs": "module", "dep.cjs": "program" }); expect([...eventsSeen].sort()).toEqual( expect.arrayContaining([ diff --git a/test/tsconfig.json b/test/tsconfig.json index f0c1ebc8d00c..23e54156776b 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -31,6 +31,7 @@ "../src/js/internal-for-testing.ts", "../scripts/glob-sources.ts", "../scripts/build/error.ts", + "../packages/bun-inspector-protocol/scripts/generate-protocol.ts", "bake/exit-code-map.mjs", "docker/prestart-map.mjs", "../src/runtime/bake/client/**.ts" From 805c3414ede1cac5a1fa4863eaf60496c413b1d1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 18 Aug 2026 05:09:58 +0000 Subject: [PATCH 2/3] Leave the V8 snapshot path alone; make the fixture exercise the cycle guard Nothing exports or imports src/protocol/v8 and its committed files are not regenerated here, so downloadV8 goes back to what it is on main. The fixture's Page.Frame now refers to itself, so the walk's already-visited check is what keeps the generator test from recursing forever. --- .../scripts/generate-protocol.ts | 18 +++++++----------- .../cli/inspect/bun-inspector-protocol.test.ts | 16 ++++++++++------ 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/bun-inspector-protocol/scripts/generate-protocol.ts b/packages/bun-inspector-protocol/scripts/generate-protocol.ts index 6141c7466875..c1b2ff27e79d 100644 --- a/packages/bun-inspector-protocol/scripts/generate-protocol.ts +++ b/packages/bun-inspector-protocol/scripts/generate-protocol.ts @@ -240,17 +240,13 @@ async function downloadV8(): Promise { return Promise.all([ download(`${baseUrl}/js_protocol.json`), download(`${baseUrl}/browser_protocol.json`), - ]).then(([js, browser]) => { - const all = [...js.domains, ...browser.domains]; - return { - name: "V8", - version: js.version, - domains: withReferencedTypes( - all.filter(domain => !domains.includes(domain.domain)), - all, - ), - }; - }); + ]).then(([js, browser]) => ({ + name: "V8", + version: js.version, + domains: [...js.domains, ...browser.domains] + .filter(domain => !domains.includes(domain.domain)) + .sort((a, b) => a.domain.localeCompare(b.domain)), + })); } async function download(url: string): Promise { diff --git a/test/cli/inspect/bun-inspector-protocol.test.ts b/test/cli/inspect/bun-inspector-protocol.test.ts index e8a5d98f3d7c..24599431bee9 100644 --- a/test/cli/inspect/bun-inspector-protocol.test.ts +++ b/test/cli/inspect/bun-inspector-protocol.test.ts @@ -170,7 +170,7 @@ const ref = ($ref: string, name?: string): Property => ({ name, type: undefined, const debuggerDomain: Domain = { domain: "Debugger", debuggableTypes: ["javascript", "web-page"], - types: [{ id: "Location", type: "object", properties: [ref("Page.FrameId", "frameId"), ref("Process.Id", "pid")] }], + types: [{ id: "Location", type: "object", properties: [ref("Page.Frame", "frame"), ref("Process.Id", "pid")] }], commands: [ { name: "searchInContent", returns: [{ name: "result", type: "array", items: ref("GenericTypes.SearchMatch") }] }, ], @@ -190,10 +190,14 @@ const combinedDomains: Domain[] = [ domain: "Page", debuggableTypes: ["web-page"], types: [ - // Referred to by Debugger; refers on to a primitive and, by its bare name, to LoaderId, which refers on - // to a third domain. - { id: "FrameId", type: "object", properties: [ref("boolean", "isMainFrame"), ref("LoaderId", "loaderId")] }, - { id: "LoaderId", type: "array", items: ref("Network.RequestId") }, + // Referred to by Debugger. Refers on to a primitive, to itself (the walk has to notice that to + // terminate) and, by bare name, to RequestIds, which refers on to a third domain. + { + id: "Frame", + type: "object", + properties: [ref("boolean", "isMainFrame"), ref("Frame", "parent"), ref("RequestIds", "requests")], + }, + { id: "RequestIds", type: "array", items: ref("Network.RequestId") }, { id: "PauseReason", type: "string", enum: ["breakpoint", "exception"] }, { id: "Unreferenced", type: "string" }, ], @@ -229,7 +233,7 @@ test("generate-protocol.ts carries along the types that the JavaScript domains r domain: "Page", description: expect.any(String), debuggableTypes: ["web-page"], - types: ["FrameId", "LoaderId", "PauseReason"], + types: ["Frame", "RequestIds", "PauseReason"], }, { domain: "Process", description: expect.any(String), debuggableTypes: ["javascript"], types: ["Id"] }, ]); From b6a9f4727a7dc029c102526db970c325e21b15f4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 18 Aug 2026 05:46:32 +0000 Subject: [PATCH 3/3] test: end the inspector protocol session while the inspectee is paused The fixture's reportError makes bun exit with code 1 as soon as the entry module finishes evaluating, which happens right after Debugger.resume. That exit raced the delivery of the resume response and the Debugger.resumed event, so the test failed with "WebSocket closed (1006) (inspectee exit: 1)" in roughly one run in eight under load (20 of 160 locally with 16 running at once; flagged flaky in 8 of the 9 main builds since it landed). The fixture now has a second debugger statement, so after resuming, the inspectee pauses again before the module can finish: everything it sent in between is delivered while it waits there, and the session is closed while it is still paused. 0 of 160 under the same load afterwards. --- test/cli/inspect/bun-inspector-protocol.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/cli/inspect/bun-inspector-protocol.test.ts b/test/cli/inspect/bun-inspector-protocol.test.ts index 24599431bee9..7aa1b63fa1e5 100644 --- a/test/cli/inspect/bun-inspector-protocol.test.ts +++ b/test/cli/inspect/bun-inspector-protocol.test.ts @@ -275,7 +275,7 @@ test("the protocol snapshot in packages/bun-inspector-protocol matches what bun console.log("hello"); reportError(new Error("reported")); debugger; - setInterval(() => {}, 60_000); + debugger; `, "dep.cjs": `module.exports = 1;`, }); @@ -401,9 +401,15 @@ test("the protocol snapshot in packages/bun-inspector-protocol matches what bun await send("Debugger.evaluateOnCallFrame", { callFrameId: callFrames[0].callFrameId, expression: "globalThis" }); await send("LifecycleReporter.getModuleGraph"); + // The reported error makes bun exit (with code 1) as soon as the module finishes evaluating, and + // that exit races the delivery of whatever the inspector sent last. So the fixture's second + // debugger statement stops it again right after it resumes: everything sent in between is + // delivered while it sits in that pause, and the session ends (ws.close below) while it is paused. const resumed = waitForEvent("Debugger.resumed"); + const pausedAgain = waitForEvent("Debugger.paused"); await send("Debugger.resume"); await resumed; + await pausedAgain; } finally { ws.close(); }