Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,26 @@ need their own validation.
sessions short. Press Ctrl+C to finalize the capture; the command prints the
`.profiles/...-web` output directory. Load `.cpuprofile` and trace files in
Chromium DevTools (**Performance** > **Load profile**).
Use `just web profile --scenario <file>` for an unattended capture. The file
is any JavaScript module, inside or outside the repository (a relative path
resolves from the repository root), that default-exports
`async (page, { signal }) => {}` and drives the Playwright `page`. When it
returns, the command saves the app's [client metrics](client-metrics.md)
export as `client-metrics.json` and exits without Ctrl+C. A scenario that
throws or does not finish within five minutes fails the run: the command
prints the scenario's stack and the directory holding the remaining
artifacts, saves no client metrics, and exits nonzero. Ctrl+C during a
scenario also saves no client metrics but exits zero, like any interrupted
capture. `signal` aborts on Ctrl+C, at the timeout, and when the capture ends,
so pass it to any wait that would otherwise outlive the run. A scenario
outside the repository resolves bare imports from its own location, not from
the repository's `node_modules`. A scenario runs as your real account, so
keep it read-only. Every capture starts from a fresh browser profile with no
community selected unless `BUZZ_DEV_OPEN_RELAY=1` is set. `manifest.json`
records the scenario file and `relay`, the `https://` origin of the
`BUZZ_RELAY_URL` that Vite resolves from the environment or its `.env` files;
a value the dev server would reject is recorded as `null`. To profile in
another Vite mode, pass it as `--mode <mode>` so the manifest follows it.
- `just desktop [args...]`: install locked dependencies and forward arguments to
Tauri, e.g. `just desktop --port 1431 --no-watch`. Before launching, the adapter
builds the pinned agent runtime when missing/outdated, or verifies and reuses it.
Expand Down
168 changes: 161 additions & 7 deletions scripts/profile-dev.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { randomUUID } from "node:crypto";
import { spawn } from "node:child_process";
import { mkdir, writeFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { setTimeout as pause } from "node:timers/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import process from "node:process";
import { relayOrigin } from "../src/features/communities/destination.ts";

const root = fileURLToPath(new URL("../", import.meta.url));

Expand Down Expand Up @@ -173,6 +176,20 @@ async function recordManifest(directory, target, profileArgs, extra = {}) {
);
}

// The relay the dev server will use: Vite's environment for its mode (.env
// files, with the process environment winning) reduced to the validated public
// origin. A rejected value may carry a credential, so it is never recorded.
export async function configuredRelay(mode, directory = root) {
const { loadEnv } = await import("vite");
const value = loadEnv(mode, directory, "BUZZ_").BUZZ_RELAY_URL;
if (!value?.trim()) return null;
try {
return relayOrigin(value);
} catch {
return null;
}
}

function processGroupAlive(pid) {
try {
process.kill(-pid, 0);
Expand Down Expand Up @@ -228,10 +245,23 @@ export function normalizeWebViteArgs(values) {
let port = 1430;
let sawPort = false;
let sawHost = false;
let mode;
for (let index = 0; index < values.length; index++) {
const value = values[index];
if (value === "--")
throw new Error("Web profiling does not accept Vite arguments after --.");
if (/^(?:--mode|-m)(?:=|$)/.test(value)) {
if (mode !== undefined)
throw new Error("Web profiling accepts only one --mode option.");
const equals = value.indexOf("=");
mode = equals < 0 ? values[++index] : value.slice(equals + 1);
if (!mode) throw new Error("--mode requires a mode name.");
continue;
}
// Vite also takes a mode from a short-flag group or --m. The manifest's
// relay follows the mode, so a mode Vite alone sees would misattribute it.
if (/^-[^-=]*m|^--m(?:=|$)/.test(value))
throw new Error("Web profiling requires the mode as --mode <mode>.");
if (value === "--port") {
if (sawPort)
throw new Error("Web profiling accepts only one --port option.");
Expand Down Expand Up @@ -270,13 +300,15 @@ export function normalizeWebViteArgs(values) {
throw new Error("--port must be an integer between 1 and 65535.");
return {
port,
mode: mode ?? "development",
args: [
...normalized,
"--host",
"127.0.0.1",
"--port",
String(port),
"--strictPort",
...(mode === undefined ? [] : ["--mode", mode]),
],
};
}
Expand Down Expand Up @@ -501,24 +533,101 @@ export function networkRecorder(session, directory) {
};
}

const SCENARIO_TIMEOUT_MS = 5 * 60_000;

export function takeScenario(values) {
const args = [];
let scenario;
for (let index = 0; index < values.length; index++) {
const value = values[index];
if (value !== "--scenario" && !value.startsWith("--scenario=")) {
args.push(value);
continue;
}
if (scenario !== undefined)
throw new Error("Profiling accepts only one --scenario option.");
scenario = value === "--scenario" ? values[++index] : value.slice(11);
if (!scenario) throw new Error("--scenario requires a scenario file.");
}
return { scenario, args };
}

// A scenario file default-exports `async (page, { signal }) => {}` and drives
// the Playwright page. It runs as the developer's real account.
export async function loadScenario(file) {
const location = path.resolve(file);
const { default: run } = await import(pathToFileURL(location).href);
if (typeof run !== "function")
throw new Error(`Scenario ${location} must default-export a function.`);
return { file: location, run };
}

export async function runScenario(
scenario,
page,
directory,
signal,
timeoutMs = SCENARIO_TIMEOUT_MS,
) {
// The scenario's signal aborts on Ctrl-C, at the timeout, and once this run
// settles, so abort-aware work it leaves behind cannot keep the process alive.
const ended = new AbortController();
const stop = AbortSignal.any([signal, ended.signal]);
try {
const metrics = await Promise.race([
(async () => {
await scenario.run(page, { signal: stop });
// The app's own client-metrics export (docs/client-metrics.md).
return await page.evaluate(
(label) => globalThis.__buzzClientMetrics.export(label),
path.basename(scenario.file),
);
})(),
// Ctrl-C also ends this wait, so an interrupted scenario that finishes
// late saves no metrics.
pause(timeoutMs, undefined, { signal: stop }).then(() => {
throw new Error(
`Scenario did not finish within ${timeoutMs / 1000} seconds.`,
);
}),
]);
await writeFile(
`${directory}/client-metrics.json`,
`${JSON.stringify(metrics, null, 2)}\n`,
);
return { reason: "scenario", code: 0 };
} catch (error) {
return { reason: "scenario", code: 1, error };
} finally {
ended.abort();
}
}

export async function profileWeb({
directory,
profileArgs,
args,
network,
trace = false,
scenario,
scenarioTimeoutMs,
}) {
const vite = normalizeWebViteArgs(args);
await recordManifest(directory, "web", profileArgs, {
coverage: [
trace ? "chromium-trace" : "chromium-renderer",
"vite-broker",
...(network ? ["chromium-network"] : []),
...(scenario ? ["client-metrics"] : []),
],
network,
scenario: scenario?.file ?? null,
relay: await configuredRelay(vite.mode),
});

const control = stopController();
// Aborts the scenario's signal however the capture ends, including Vite exit.
const captureEnd = new AbortController();
// Browser operations do not accept AbortSignal. Stop waiting immediately and
// let finally close the owning browser; late results must not resume startup.
const duringStartup = async (operation) => {
Expand Down Expand Up @@ -618,13 +727,38 @@ export async function profileWeb({
control.abort.signal.throwIfAborted();
captureStarted = true;
console.log(
`\nProfiling ${url}. Press Ctrl-C to stop and save the profile.`,
`\nProfiling ${url}. ${
scenario
? `Running scenario ${scenario.file}; the capture stops when it ends.`
: "Press Ctrl-C to stop and save the profile."
}`,
);
outcome = await Promise.race([control.requested, viteExit]);
outcome = await Promise.race([
control.requested,
viteExit,
...(scenario
? [
runScenario(
scenario,
page,
directory,
AbortSignal.any([control.abort.signal, captureEnd.signal]),
scenarioTimeoutMs,
),
]
: []),
]);
if (outcome.error)
failures.push(
new Error(`Scenario failed; artifacts remain at ${directory}.`, {
cause: outcome.error,
}),
);
} catch (error) {
if (!control.abort.signal.aborted) failures.push(error);
outcome ??= { reason: "startup", code: 1 };
} finally {
captureEnd.abort();
let rendererProfile;
let brokerProfile;
if (rendererStarted) {
Expand Down Expand Up @@ -772,13 +906,23 @@ async function profileDesktop({ directory, profileArgs, args }) {
process.exitCode = forced ? 130 : outcome.code;
}

// A wrapped failure names the artifacts; its cause carries the stack to debug.
export function failureReport(error) {
if (!(error instanceof Error)) return error;
const { cause } = error;
if (cause === undefined) return error.message;
return `${error.message}\n${cause instanceof Error ? cause.stack : cause}`;
}

async function main() {
const target = process.argv[2];
const profileArgs = process.argv.slice(3);
const network = target === "web" && profileArgs.includes("--network");
const trace = target === "web" && profileArgs.includes("--trace");
const args = profileArgs.filter(
(argument) => argument !== "--network" && argument !== "--trace",
const { scenario: scenarioFile, args } = takeScenario(
profileArgs.filter(
(argument) => argument !== "--network" && argument !== "--trace",
),
);
if (target !== "web" && target !== "desktop") {
console.error(
Expand All @@ -787,6 +931,8 @@ async function main() {
process.exitCode = 1;
return;
}
if (scenarioFile !== undefined && target !== "web")
throw new Error("--scenario is supported for web profiling only.");
if (process.platform !== "darwin") {
console.error("Development profiling currently supports macOS only.");
process.exitCode = 1;
Expand All @@ -796,10 +942,18 @@ async function main() {
.toISOString()
.replaceAll(":", "-")
.replace(/\.\d{3}Z$/, "Z");
const scenario = scenarioFile && (await loadScenario(scenarioFile));
const directory = `${root}.profiles/${stamp}-${target}`;
await mkdir(directory, { recursive: true });
if (target === "web")
await profileWeb({ directory, profileArgs, args, network, trace });
await profileWeb({
directory,
profileArgs,
args,
network,
trace,
scenario,
});
else await profileDesktop({ directory, profileArgs, args });
}

Expand All @@ -810,7 +964,7 @@ if (
try {
await main();
} catch (error) {
console.error(error instanceof Error ? error.message : error);
console.error(failureReport(error));
await stopChildren();
process.exitCode = 1;
}
Expand Down
1 change: 1 addition & 0 deletions tests/integration/fixtures/profile-web/browser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const page = {
context() {
return { newCDPSession: () => operation("newCDPSession", session) };
},
locator: () => ({ click: () => operation("click") }),
evaluate: () =>
operation("evaluate", { epochMilliseconds: 1, monotonicMilliseconds: 1 }),
async goto() {
Expand Down
9 changes: 6 additions & 3 deletions tests/integration/fixtures/profile-web/driver.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { profileWeb } from "./scripts/profile-dev.mjs";
import { loadScenario, profileWeb } from "./scripts/profile-dev.mjs";

// IPC stays open until the parent has observed cleanup and any late completion.
process.on("message", (message) => {
Expand All @@ -10,13 +10,16 @@ console.log = (...args) => {
if (args[0]?.startsWith("\nProfiling http"))
process.send({ type: "capturing" });
};
const fixture = JSON.parse(process.env.BUZZ_TEST_SCENARIO);
try {
await profileWeb({
directory: `${process.cwd()}/profiles`,
profileArgs: [],
args: [],
args: fixture.args ?? [],
network: true,
trace: JSON.parse(process.env.BUZZ_TEST_SCENARIO).trace === true,
trace: fixture.trace === true,
scenario: fixture.scenario && (await loadScenario(fixture.scenario)),
scenarioTimeoutMs: fixture.scenarioTimeoutMs,
});
process.send({ type: "settled" });
} catch (error) {
Expand Down
3 changes: 3 additions & 0 deletions tests/integration/fixtures/profile-web/scenario.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default async (page) => {
await page.locator("button").click();
};
1 change: 1 addition & 0 deletions tests/integration/fixtures/profile-web/vite.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createServer } from "node:http";

// A real HTTP server and Node inspector, without the Buzz broker or live identity.
writeFileSync("vite.pid", String(process.pid));
writeFileSync("vite.args", JSON.stringify(process.argv.slice(2)));
const server = createServer((_request, response) => response.end("fixture"));
server.listen(0, "127.0.0.1", () => {
console.log(
Expand Down
10 changes: 10 additions & 0 deletions tests/integration/fixtures/profile-web/waiting-scenario.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { setTimeout as pause } from "node:timers/promises";

// Abort-aware outstanding work: only the harness aborting `signal` ends it.
export default async (_page, { signal }) => {
signal.addEventListener("abort", () =>
process.send({ type: "scenarioAborted" }),
);
process.send({ type: "scenarioWaiting" });
await pause(60_000, undefined, { signal });
};
Loading
Loading