mirror of
https://github.com/penpot/penpot.git
synced 2026-08-29 16:18:48 +00:00
190 lines
7.5 KiB
JavaScript
190 lines
7.5 KiB
JavaScript
import { test } from "@playwright/test";
|
|
import { WasmWorkspacePage } from "../pages/WasmWorkspacePage";
|
|
import fs from "fs";
|
|
import path from "path";
|
|
|
|
/**
|
|
* Zoom performance capture. Not an assertion test — it drives a fixed zoom
|
|
* sequence and writes a Chrome trace, so two builds can be compared under an
|
|
* identical interaction.
|
|
*
|
|
* Run one build, stash/checkout the other, run again, then diff the traces:
|
|
*
|
|
* PERF_LABEL=branch npx playwright test zoom-perf --project render-wasm --workers=1
|
|
* PERF_LABEL=develop npx playwright test zoom-perf --project render-wasm --workers=1
|
|
* python3 playwright/scripts/trace-compare.py \
|
|
* perf-traces/zoom-perf-branch.json perf-traces/zoom-perf-develop.json
|
|
*
|
|
* The bundled fixtures are small (<150 shapes, most with shadows) and will not
|
|
* reproduce the dense-tile case that `max_per_tile` flagged. For the decisive
|
|
* comparison, save the `get-file` response of the real document under
|
|
* playwright/data/ and point PERF_GET_FILE at it (PERF_PAGE_NAME too, if its
|
|
* first page is not called "Page 1").
|
|
*/
|
|
|
|
const LABEL = process.env.PERF_LABEL ?? "run";
|
|
const CYCLES = Number(process.env.PERF_CYCLES ?? 6);
|
|
const STEPS = Number(process.env.PERF_STEPS ?? 20);
|
|
const STEP_DELAY = Number(process.env.PERF_STEP_DELAY ?? 16);
|
|
const SETTLE = Number(process.env.PERF_SETTLE ?? 600);
|
|
const TRACE = process.env.PERF_TRACE !== "0";
|
|
// zoom | pan | both. Penpot zooms on ctrl+wheel and pans on plain wheel
|
|
// (viewport/actions.cljs: `if (or ctrl? mod?) schedule-zoom! else
|
|
// schedule-scroll!`), so both gestures reuse the same wheel mechanism.
|
|
const MODE = process.env.PERF_MODE ?? "both";
|
|
const PAN_DELTA = Number(process.env.PERF_PAN_DELTA ?? 200);
|
|
// Zoom in before panning: at zoom-to-fit the whole document is on screen and
|
|
// panning just scrolls into empty space without crossing populated tiles.
|
|
const PAN_ZOOM_IN = Number(process.env.PERF_PAN_ZOOM_IN ?? 12);
|
|
const TIMEOUT = Number(process.env.PERF_TIMEOUT ?? 180000);
|
|
|
|
// File and page ids are read out of the payload by mockGetFile, so pointing
|
|
// PERF_GET_FILE at a different dump is the only change needed.
|
|
// Must be a fixture with no media assets: anything needing mockFileMediaAsset
|
|
// stalls on image fetches and `wasmSetObjectsFinished` never fires.
|
|
const GET_FILE =
|
|
process.env.PERF_GET_FILE ?? "render-wasm/get-file-shapes-groups-boards.json";
|
|
const PAGE_NAME = process.env.PERF_PAGE_NAME ?? "Page 1";
|
|
|
|
// Excludes v8.inspector and v8.cpu_profiler on purpose: in the hand-captured
|
|
// traces those accounted for ~400k events and inflated main-thread cost on
|
|
// both sides.
|
|
const CATEGORIES = [
|
|
"__metadata",
|
|
"devtools.timeline",
|
|
"disabled-by-default-devtools.timeline",
|
|
"disabled-by-default-devtools.timeline.frame",
|
|
"blink.user_timing",
|
|
"benchmark",
|
|
"cc",
|
|
];
|
|
|
|
// Headed is mandatory, not cosmetic: headless Chromium falls back to
|
|
// SwiftShader (CPU rasterizer), which makes every GPU measurement useless.
|
|
// Verified renderer strings on this machine:
|
|
// headless -> ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device ...))
|
|
// headed -> ANGLE (Intel, Mesa Intel(R) Arc(tm) Graphics (MTL), ...)
|
|
test.use({ headless: false });
|
|
|
|
test.setTimeout(TIMEOUT);
|
|
|
|
test(`zoom perf capture [${LABEL}]`, async ({ page }) => {
|
|
await WasmWorkspacePage.init(page);
|
|
await WasmWorkspacePage.mockConfigFlags(page, [
|
|
"enable-feature-render-wasm",
|
|
"enable-render-wasm-dpr",
|
|
]);
|
|
|
|
const workspace = new WasmWorkspacePage(page);
|
|
await workspace.setupEmptyFile();
|
|
await workspace.mockGetFile(GET_FILE);
|
|
|
|
await workspace.goToWorkspace({ pageName: PAGE_NAME });
|
|
await workspace.waitForFirstRenderWithoutUI();
|
|
// Not waitForIdle(): requestIdleCallback never fires while the progressive
|
|
// render loop keeps the main thread busy, and the test hangs to timeout.
|
|
await page.waitForTimeout(2000);
|
|
|
|
const box = await workspace.canvas.boundingBox();
|
|
const cx = box.x + box.width / 2;
|
|
const cy = box.y + box.height / 2;
|
|
await page.mouse.move(cx, cy);
|
|
|
|
// Zoom to fit so every run starts from the same viewbox.
|
|
await page.keyboard.press("Shift+1");
|
|
await page.waitForTimeout(1500);
|
|
|
|
// Not test-results/: Playwright wipes outputDir at the start of every run,
|
|
// which would delete the first build's trace before the second one lands.
|
|
const outDir = path.resolve("perf-traces");
|
|
fs.mkdirSync(outDir, { recursive: true });
|
|
const out = path.join(outDir, `zoom-perf-${LABEL}.json`);
|
|
|
|
// browser.startTracing, not a raw CDPSession: Tracing is a browser-level
|
|
// domain, and driving it from a page session deadlocks input — the
|
|
// dataCollected flood blocks the same connection Playwright sends keys on.
|
|
const browser = page.context().browser();
|
|
if (TRACE) {
|
|
await browser.startTracing(page, {
|
|
path: out,
|
|
categories: CATEGORIES,
|
|
screenshots: false,
|
|
});
|
|
}
|
|
|
|
const burst = async (dx, dy) => {
|
|
for (let i = 0; i < STEPS; i++) {
|
|
await page.mouse.wheel(dx, dy);
|
|
await page.waitForTimeout(STEP_DELAY);
|
|
}
|
|
await page.waitForTimeout(SETTLE); // let the full-quality pass finish
|
|
};
|
|
|
|
if (MODE === "zoom" || MODE === "both") {
|
|
// Zoom OUT first, then back in. Starting from zoom-to-fit and zooming in
|
|
// never goes below fit scale, so scale-dependent level-of-detail paths
|
|
// (imperceptible shadows/strokes) would never activate and the run would
|
|
// measure nothing. Going out first spends half the phase at low scale.
|
|
await page.keyboard.down("Control");
|
|
for (let c = 0; c < CYCLES; c++) {
|
|
await burst(0, 120); // zoom out, below fit
|
|
await burst(0, -120); // zoom back in
|
|
}
|
|
await page.keyboard.up("Control");
|
|
await page.waitForTimeout(500);
|
|
}
|
|
|
|
if (MODE === "pan" || MODE === "both") {
|
|
await page.keyboard.down("Control");
|
|
for (let i = 0; i < PAN_ZOOM_IN; i++) {
|
|
await page.mouse.wheel(0, -120);
|
|
await page.waitForTimeout(STEP_DELAY);
|
|
}
|
|
await page.keyboard.up("Control");
|
|
await page.waitForTimeout(SETTLE);
|
|
|
|
for (let c = 0; c < CYCLES; c++) {
|
|
await burst(0, PAN_DELTA); // down
|
|
await burst(PAN_DELTA, 0); // right
|
|
await burst(0, -PAN_DELTA); // up
|
|
await burst(-PAN_DELTA, 0); // left
|
|
}
|
|
}
|
|
|
|
await page.waitForTimeout(1000);
|
|
|
|
let events = [];
|
|
if (TRACE) {
|
|
const buf = await browser.stopTracing();
|
|
const parsed = JSON.parse(buf.toString());
|
|
events = Array.isArray(parsed) ? parsed : parsed.traceEvents;
|
|
}
|
|
|
|
// Inline summary so a single run is readable without the python analyzer.
|
|
const durs = (name) =>
|
|
events
|
|
.filter((e) => e.name === name && e.ph === "X" && e.dur != null)
|
|
.map((e) => e.dur);
|
|
const stat = (v) => {
|
|
if (!v.length) return "n=0";
|
|
const s = [...v].sort((a, b) => a - b);
|
|
const p = (q) => s[Math.min(s.length - 1, Math.round(q * (s.length - 1)))];
|
|
const mean = v.reduce((a, b) => a + b, 0) / v.length;
|
|
return (
|
|
`n=${v.length} mean=${(mean / 1000).toFixed(2)}ms ` +
|
|
`p50=${(p(0.5) / 1000).toFixed(2)} p95=${(p(0.95) / 1000).toFixed(2)} ` +
|
|
`p99=${(p(0.99) / 1000).toFixed(2)} max=${(p(1) / 1000).toFixed(2)}`
|
|
);
|
|
};
|
|
const dropped = events.filter((e) => e.name === "DroppedFrame").length;
|
|
const zooms = events.filter(
|
|
(e) => e.name === "set-view-box" && e.ph === "b",
|
|
).length;
|
|
|
|
console.log(`\n[PERF ${LABEL}] trace -> ${out}`);
|
|
console.log(`[PERF ${LABEL}] mode=${MODE} file=${GET_FILE.split("/").pop()} ` +
|
|
`events=${events.length} viewbox=${zooms} droppedFrames=${dropped}`);
|
|
console.log(`[PERF ${LABEL}] FireAnimationFrame ${stat(durs("FireAnimationFrame"))}`);
|
|
console.log(`[PERF ${LABEL}] GPUTask ${stat(durs("GPUTask"))}`);
|
|
});
|