Skip to content

Commit ed9fa71

Browse files
fix: render ProfilingPlugin trace in Chrome DevTools (#21269)
* fix: add frames to ProfilingPlugin TracingStartedInBrowser event Chrome DevTools' performance panel treats TracingStartedInBrowser as the primary trace-bootstrap event and iterates args.data.frames. The plugin emitted that event without a frames array, so loading the generated events.json failed with "frames is not iterable". Add the frames array (matching the sibling TracingStartedInPage event) so the trace loads. Closes #17234 * test: assert ProfilingPlugin trace survives Chrome DevTools bootstrap * test: verify ProfilingPlugin trace loads in real Chrome via puppeteer Launch headless Chrome and run DevTools' frame-bootstrap iteration over the plugin-generated events.json. The test self-skips when puppeteer or a working Chrome is unavailable. * test: run ProfilingPlugin Chrome E2E only on latest LTS in CI puppeteer requires Node 18+ and its postinstall fails to parse on the Node 10.x/12.x install matrix, so it cannot be a committed devDependency. Drop it from package.json, exclude the browser test from the types-test tsc pass, and add a dedicated latest-LTS CI job that installs puppeteer on demand and runs the test. The test self-skips wherever puppeteer or Chrome is unavailable. * test: move ProfilingPlugin Chrome E2E into the unit suite via puppeteer-core Replace the dedicated latest-LTS CI job with a committed puppeteer-core devDependency (no postinstall, so it installs cleanly on the legacy-Node matrix) and run the browser check in the unit suite instead of the integration matrix. It launches the runner's installed Chrome and self-skips when none is available or under the Bun/Deno runtimes. * test: use real puppeteer-core types and skip the Chrome E2E on old Node Replace EXPECTED_ANY in the browser test with real puppeteer-core types (Browser/LaunchOptions) and a typed trace-event shape, and skip when Node < 18 (puppeteer-core's minimum runtime). Document in AGENTS.md that specific real types and generics are preferred over EXPECTED_ANY/OBJECT/FUNCTION. * test: only load puppeteer-core where the Chrome E2E can run Gate the require behind the same Node >= 18 / not-Bun-or-Deno check used to run the test, so the heavy puppeteer-core module graph is never loaded into the memory-limited Bun/Deno workers running the full suite. * test: skip ProfilingPlugin inspector test under Bun Bun's Node `inspector` CPU profiler never resolves, so this test hung the full Bun runtimes suite until its 120s timeout. It runs unchanged under Node, where the V8 inspector is fully supported.
1 parent 322b060 commit ed9fa71

8 files changed

Lines changed: 590 additions & 7 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"webpack": patch
3+
---
4+
5+
Add frames to ProfilingPlugin TracingStartedInBrowser event so the trace loads in Chrome DevTools.

AGENTS.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,12 @@ webpack is a JavaScript module bundler. Package manager: **yarn**.
9393

9494
`lib/` is CommonJS only. Use `module.exports` / `require()`, never `import`/`export` syntax. Types are declared via JSDoc — `@typedef {import("./Other")} Other` and friends — never TypeScript syntax inside `.js` files. The JSDoc annotations are compiled into `types.d.ts` by `yarn fix:special`.
9595

96+
### Type annotations
97+
98+
Prefer the most specific real type. `EXPECTED_ANY`, `EXPECTED_OBJECT`, and `EXPECTED_FUNCTION` (aliases for `any`, `object`, `Function`) are an escape hatch, not a default — use them **only** when the value genuinely can be any value, any object, or any function. When you simply don't know the type yet, reach for `unknown` and narrow it, rather than widening to `EXPECTED_ANY`. This applies in `test/` too: if a real type (e.g. an imported `import("…").Foo`) fits, use it instead of `EXPECTED_ANY`.
99+
100+
Prefer a generic (`@template`) over a widened type whenever a function's output type depends on its input — it keeps callers precisely typed instead of collapsing to `EXPECTED_ANY`.
101+
96102
### Source file headers
97103

98104
Every source file under `lib/` (and `hot/`, `tooling/`) opens with the MIT license header. When adding a **new** file, set the `Author` line to its actual author (`Author <Name> @<github-handle>`) — don't copy another file's author line.

lib/debug/ProfilingPlugin.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,13 +208,24 @@ const createTrace = (fs, outputPath) => {
208208
}
209209
});
210210

211+
// Chrome DevTools treats this as the primary trace-bootstrap event and
212+
// iterates `args.data.frames`; it must be present or the trace fails to load.
211213
trace.instantEvent({
212214
name: "TracingStartedInBrowser",
213215
id: ++counter,
214216
cat: ["disabled-by-default-devtools.timeline"],
215217
args: {
216218
data: {
217-
sessionId: "-1"
219+
sessionId: "-1",
220+
frameTreeNodeId: 1,
221+
persistentIds: true,
222+
frames: [
223+
{
224+
frame: "0xfff",
225+
url: "webpack",
226+
name: ""
227+
}
228+
]
218229
}
219230
}
220231
});

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@
182182
"prettier-2": "npm:prettier@^2",
183183
"pretty-format": "^30.0.5",
184184
"pug": "^3.0.3",
185+
"puppeteer-core": "^23.11.1",
185186
"raw-loader": "^4.0.1",
186187
"react": "^19.2.7",
187188
"react-dom": "^19.2.7",

test/ProfilingPlugin.test.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,15 @@ const path = require("path");
66
const fs = require("graceful-fs");
77
const rimraf = require("rimraf");
88

9+
// Bun's Node `inspector` CPU profiler never resolves, hanging this test until timeout.
10+
const itSkipBun = process.versions.bun ? it.skip : it;
11+
912
// ProfilingPlugin intercepts the deprecated Compilation.hooks.normalModuleLoader
1013
// hook; that deprecation is asserted in configCases/plugins/profiling-plugin.
1114
describe("Profiling Plugin", () => {
1215
jest.setTimeout(120000);
1316

14-
it("should handle output path with folder creation", (done) => {
17+
itSkipBun("should handle output path with folder creation", (done) => {
1518
const webpack = require("../");
1619

1720
const outputPath = path.join(__dirname, "js/profilingPath");

test/ProfilingPlugin.unittest.js

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"use strict";
22

33
const path = require("path");
4+
const fs = require("graceful-fs");
5+
const rimraf = require("rimraf");
46
const ProfilingPlugin = require("../lib/debug/ProfilingPlugin");
57

68
describe("Profiling Plugin", () => {
@@ -52,3 +54,124 @@ describe("Profiling Plugin", () => {
5254
return profiler.destroy();
5355
});
5456
});
57+
58+
// Optional dependency: the browser end-to-end check only runs where puppeteer-core
59+
// (and a Chrome it can launch) are present. puppeteer-core needs Node >= 18, and
60+
// loading it under the memory-limited Bun/Deno workers is wasteful, so only require
61+
// it where the check can actually run; it self-skips everywhere else. See #17234.
62+
const globalScope = /** @type {{ Bun?: unknown, Deno?: unknown }} */ (
63+
globalThis
64+
);
65+
const onBunOrDeno = Boolean(globalScope.Bun) || Boolean(globalScope.Deno);
66+
const nodeMajor = Number.parseInt(process.versions.node, 10);
67+
68+
/** @type {typeof import("puppeteer-core") | undefined} */
69+
let puppeteer;
70+
if (!onBunOrDeno && nodeMajor >= 18) {
71+
try {
72+
const name = "puppeteer-core";
73+
puppeteer = require(name);
74+
} catch (_err) {
75+
puppeteer = undefined;
76+
}
77+
}
78+
79+
/**
80+
* @typedef {{ frame: string, parent?: string }} TraceFrame
81+
* @typedef {{ name: string, args: { data: { frames: TraceFrame[] } } }} TraceEvent
82+
*/
83+
84+
describe("ProfilingPlugin in real Chrome", () => {
85+
/** @type {import("puppeteer-core").Browser | undefined} */
86+
let browser;
87+
88+
beforeAll(async () => {
89+
if (!puppeteer || onBunOrDeno || nodeMajor < 18) return;
90+
try {
91+
/** @type {import("puppeteer-core").LaunchOptions} */
92+
const launchOptions = {
93+
headless: true,
94+
args: ["--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"]
95+
};
96+
// Use an explicit binary when provided, otherwise the installed Chrome.
97+
if (process.env.PUPPETEER_EXECUTABLE_PATH) {
98+
launchOptions.executablePath = process.env.PUPPETEER_EXECUTABLE_PATH;
99+
} else {
100+
launchOptions.channel = "chrome";
101+
}
102+
browser = await puppeteer.launch(launchOptions);
103+
} catch (_err) {
104+
// No usable Chrome in this environment — the test self-skips below.
105+
browser = undefined;
106+
}
107+
}, 120000);
108+
109+
afterAll(async () => {
110+
if (browser) await browser.close();
111+
});
112+
113+
it("should generate a trace Chrome DevTools can load (#17234)", (done) => {
114+
if (!browser) {
115+
console.warn("Skipping: could not launch Chrome via puppeteer-core.");
116+
return done();
117+
}
118+
119+
// Narrowed handle so the type survives into the callbacks below.
120+
const activeBrowser = browser;
121+
122+
const webpack = require("..");
123+
124+
const outputPath = path.join(__dirname, "js/profiling-chrome");
125+
const eventsPath = path.join(outputPath, "events.json");
126+
127+
rimraf(outputPath, () => {
128+
const compiler = webpack({
129+
context: __dirname,
130+
entry: "./fixtures/a.js",
131+
output: { path: path.join(outputPath, "dist") },
132+
plugins: [new webpack.debug.ProfilingPlugin({ outputPath: eventsPath })]
133+
});
134+
compiler.run(async (err) => {
135+
if (err) return done(err);
136+
try {
137+
/** @type {TraceEvent[]} */
138+
const events = JSON.parse(fs.readFileSync(eventsPath, "utf8"));
139+
const page = await activeBrowser.newPage();
140+
// Run Chrome DevTools' trace bootstrap (MetaHandler) in the real
141+
// browser: iterate the TracingStartedInBrowser frames and pick the
142+
// parent-less main frame. A missing `frames` array threw
143+
// "frames is not iterable" and the whole trace failed to load.
144+
const result = await page.evaluate(
145+
(/** @type {TraceEvent[]} */ evs) => {
146+
const event = evs.find(
147+
(e) => e && e.name === "TracingStartedInBrowser"
148+
);
149+
if (!event) return { ok: false, threw: null, mainFrame: null };
150+
/** @type {string | null} */
151+
let threw = null;
152+
/** @type {string | null} */
153+
let mainFrame = null;
154+
try {
155+
for (const frame of event.args.data.frames) {
156+
if (!frame.parent) mainFrame = frame.frame;
157+
}
158+
} catch (err_) {
159+
threw = err_ instanceof Error ? err_.message : String(err_);
160+
}
161+
return { ok: threw === null, threw, mainFrame };
162+
},
163+
events
164+
);
165+
await page.close();
166+
167+
expect(result.threw).toBeNull();
168+
expect(result.ok).toBe(true);
169+
expect(typeof result.mainFrame).toBe("string");
170+
done();
171+
} catch (err_) {
172+
done(err_);
173+
}
174+
});
175+
});
176+
}, 120000);
177+
});

test/configCases/plugins/profiling-plugin/index.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,27 @@ it("should have proper setup record inside of the json stream", () => {
1818
);
1919
expect(source[0].id).toEqual(1);
2020
});
21+
22+
it("should produce a trace Chrome DevTools can load (#17234)", () => {
23+
var fs = require("fs");
24+
var path = require("path");
25+
26+
var source = JSON.parse(
27+
fs.readFileSync(path.join(__dirname, "in/directory/events.json"), "utf-8")
28+
);
29+
30+
// Replicates Chrome DevTools' trace bootstrap: it iterates the
31+
// TracingStartedInBrowser frames and picks the parent-less one as the main
32+
// frame. A missing `frames` array made this throw "not iterable".
33+
var event = source.find((e) => e.name === "TracingStartedInBrowser");
34+
expect(event).toBeDefined();
35+
36+
var mainFrame;
37+
expect(() => {
38+
for (var frame of event.args.data.frames) {
39+
if (!frame.parent) mainFrame = frame;
40+
}
41+
}).not.toThrow();
42+
expect(mainFrame).toBeDefined();
43+
expect(typeof mainFrame.frame).toBe("string");
44+
});

0 commit comments

Comments
 (0)