mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 10:16:03 -10:00
Dragging the Git Log scrollbar through a long history stuttered and showed blank rows: the native scrollbar scrolls asynchronously in WebKit, so the virtual list rendered rows a frame or more after the new offset was painted. The list now uses a scrollbar drawn in the DOM (`Scrollbar` in `src/ui`) that sets the offset from pointer events and notifies scroll listeners at once, so the virtualizer renders the matching rows before the next paint. Rows are keyed by slot so a large jump updates the mounted rows instead of rebuilding them, overscan is lowered, and rows rendered mid-scroll draw their graph without edge hit targets, which come back once scrolling stops. Adds a `gitLogScrollbar` perf scenario with budgets.
614 lines
22 KiB
TypeScript
614 lines
22 KiB
TypeScript
/**
|
||
* Interaction scenarios. Each returns flat metrics (lower is better); budgets in
|
||
* `budgets.json` are keyed `<scenario>.<metric>`.
|
||
*/
|
||
import { Harness, type Metrics, type Scenario } from "./lib/harness.ts";
|
||
import { frameSummary, keySummary, latencySummary, totalBlockingTime } from "./lib/stats.ts";
|
||
|
||
const ROOT = "/dev/acme-api";
|
||
const SAMPLE = `${ROOT}/src/main/kotlin/com/acme/orders/service/OrderService.kt`;
|
||
|
||
/** ~`lines` lines of TypeScript with a unique first line. */
|
||
function tsSource(lines: number, tag: string): string {
|
||
const out = [`// ${tag}`, `import { Injectable } from "./di";`, ""];
|
||
for (let i = 0; out.length < lines; i++) {
|
||
out.push(
|
||
`/** Repository for entity ${i}. */`,
|
||
`export class Repository${i}<T extends { id: number }> {`,
|
||
` private readonly items = new Map<number, T>();`,
|
||
` constructor(private readonly name = "repo-${i}") {}`,
|
||
` find(id: number): T | undefined {`,
|
||
` return this.items.get(id) ?? (id > ${i} ? undefined : this.items.get(${i}));`,
|
||
` }`,
|
||
` save(item: T): void {`,
|
||
` if (item.id < 0) throw new Error(\`invalid id \${item.id} in \${this.name}\`);`,
|
||
` this.items.set(item.id, item);`,
|
||
` }`,
|
||
`}`,
|
||
``,
|
||
);
|
||
}
|
||
return out.join("\n");
|
||
}
|
||
|
||
/** ~`lines` lines of an Angular template: bindings, interpolations and control-flow blocks. */
|
||
function angularTemplate(lines: number, tag: string): string {
|
||
const out = [`<!-- ${tag} -->`];
|
||
for (let i = 0; out.length < lines; i++) {
|
||
out.push(
|
||
`<section class="card" [class.active]="selected() === ${i}">`,
|
||
` <h2 (click)="select(${i})">{{ title${i}() | titlecase }}</h2>`,
|
||
` @if (items${i}().length) {`,
|
||
` <ul>`,
|
||
` @for (item of items${i}(); track item.id; let last = $last) {`,
|
||
` <li [title]="item.label" (keydown.enter)="open(item)">{{ item.name }} & {{ item.count }}</li>`,
|
||
` } @empty {`,
|
||
` <li>None</li>`,
|
||
` }`,
|
||
` </ul>`,
|
||
` } @else {`,
|
||
` <p>Loading</p>`,
|
||
` }`,
|
||
`</section>`,
|
||
`@let total${i} = items${i}().length + ${i};`,
|
||
``,
|
||
);
|
||
}
|
||
return out.join("\n");
|
||
}
|
||
|
||
const TYPED = 'val total = orders.sumOf { it.amount * (1 + rate) }\nif (total > limit) log("over limit")\n';
|
||
|
||
/** Typing at a brisk pace (~12 keys/s) in the small Kotlin sample. */
|
||
const typing: Scenario = {
|
||
id: "typing",
|
||
title: "Type into a small Kotlin file",
|
||
async run(h) {
|
||
await h.openFile(SAMPLE);
|
||
const st = await h.editorState();
|
||
await h.setCursor(Math.floor((st?.length ?? 0) / 2));
|
||
await h.page.keyboard.press("End");
|
||
await h.page.keyboard.press("Enter");
|
||
return typeAndMeasure(h, TYPED, 80);
|
||
},
|
||
};
|
||
|
||
/**
|
||
* The same typing in the middle of a ~20k-line (≈ 700 KB) TypeScript file, once
|
||
* the background parse after opening has settled.
|
||
*/
|
||
const typingLarge: Scenario = {
|
||
id: "typingLarge",
|
||
title: "Type into a 20k-line TypeScript file",
|
||
async run(h) {
|
||
const openMs = await h.openFile(`${ROOT}/src/big/Repositories.ts`, tsSource(20_000, "big"));
|
||
const st = await h.editorState();
|
||
await h.setCursor(Math.floor((st?.length ?? 0) / 2));
|
||
await h.parserIdle();
|
||
await h.page.keyboard.press("End");
|
||
await h.page.keyboard.press("Enter");
|
||
return { openMs, ...(await typeAndMeasure(h, "const total = items.reduce((a, b) => a + b.id, 0);\n", 80)) };
|
||
},
|
||
};
|
||
|
||
/**
|
||
* The same typing in the middle of a ~12k-line Angular template (an
|
||
* `angular.json` above it): its text, attributes and blocks are nested parses.
|
||
*/
|
||
const typingAngular: Scenario = {
|
||
id: "typingAngular",
|
||
title: "Type into a 12k-line Angular template",
|
||
async run(h) {
|
||
await h.seed(`${ROOT}/web/angular.json`, "{}");
|
||
const openMs = await h.openFile(`${ROOT}/web/src/app/big.component.html`, angularTemplate(12_000, "big"));
|
||
const st = await h.editorState();
|
||
await h.setCursor(Math.floor((st?.length ?? 0) / 2));
|
||
await h.parserIdle();
|
||
await h.page.keyboard.press("End");
|
||
await h.page.keyboard.press("Enter");
|
||
return { openMs, ...(await typeAndMeasure(h, "@if (open()) { {{ total + 1 }} items }\n", 80)) };
|
||
},
|
||
};
|
||
|
||
/**
|
||
* Code completion with a language server's 2,000 items: the first letter opens
|
||
* the popup (after CodeMirror's 100 ms typing delay), each further letter
|
||
* narrows it, arrows move the selection.
|
||
*/
|
||
const completion: Scenario = {
|
||
id: "completion",
|
||
title: "Code completion over 2,000 items",
|
||
async run(h) {
|
||
await h.page.evaluate(() =>
|
||
(window as never as { __openideaPerf: { fakeCompletion(n: number): void } }).__openideaPerf.fakeCompletion(2_000),
|
||
);
|
||
try {
|
||
await h.openFile(`${ROOT}/src/completion/Orders.ts`, tsSource(2_000, "completion"));
|
||
const st = await h.editorState();
|
||
await h.setCursor(Math.floor((st?.length ?? 0) / 2));
|
||
await h.parserIdle();
|
||
await h.page.keyboard.press("End");
|
||
await h.page.keyboard.press("Enter");
|
||
const refresh = await h.refreshMs();
|
||
const openMs = await h.measure(() => h.page.keyboard.press("g"), ".cm-tooltip-autocomplete li", "Result<Order>");
|
||
const t0 = await h.now();
|
||
const before = await h.counters();
|
||
const narrow = await h.typeMeasured("etOrd", 150);
|
||
const work = Harness.delta(before, await h.counters());
|
||
const arrows = await h.pressMeasured(Array(20).fill("ArrowDown"), 60);
|
||
await h.page.keyboard.press("Escape");
|
||
return {
|
||
openMs,
|
||
...keySummary(narrow, refresh, "narrow"),
|
||
narrowMainThreadMsPerKey: work.taskMs / 5,
|
||
...keySummary(arrows, refresh, "arrow"),
|
||
tbtMs: totalBlockingTime(await h.longTasksSince(t0)),
|
||
};
|
||
} finally {
|
||
await h.page.evaluate(() =>
|
||
(window as never as { __openideaPerf: { fakeCompletion(n: number): void } }).__openideaPerf.fakeCompletion(0),
|
||
);
|
||
}
|
||
},
|
||
};
|
||
|
||
/**
|
||
* A process printing 50,000 log lines (stack frames among them) at 200 lines a
|
||
* frame (~4 s) into the Run console, while the user types in an editor: the
|
||
* console must keep up and the editor must stay responsive.
|
||
*/
|
||
const consoleOutput: Scenario = {
|
||
id: "consoleOutput",
|
||
title: "Type while the Run console streams 50k lines",
|
||
async run(h) {
|
||
await h.openFile(SAMPLE);
|
||
const st = await h.editorState();
|
||
await h.setCursor(Math.floor((st?.length ?? 0) / 2));
|
||
await h.page.keyboard.press("End");
|
||
await h.page.keyboard.press("Enter");
|
||
const refresh = await h.refreshMs();
|
||
const t0 = await h.now();
|
||
await h.inPage((p) => p.startFrames());
|
||
const streamMs = h.page.evaluate(async () => {
|
||
const w = window as never as { __openideaPerf: { runAction(id: string, arg?: unknown): Promise<boolean> } };
|
||
const started = performance.now();
|
||
await w.__openideaPerf.runAction("run.sampleOutput", { lines: 50_000, perFrame: 200 });
|
||
return performance.now() - started;
|
||
});
|
||
// The console takes focus with it; type back in the editor while it streams.
|
||
await h.page.waitForTimeout(100);
|
||
await h.setCursor((await h.editorState())?.head ?? 0);
|
||
const keys = await h.typeMeasured(TYPED.slice(0, 40), 70);
|
||
const total = await streamMs;
|
||
const frames = await h.inPage((p) => p.stopFrames());
|
||
await h.inPage((p) => p.waitShown("body", "processed order #49998", 5_000));
|
||
return {
|
||
// 250 frames of output at the display rate is the floor.
|
||
streamMs: total,
|
||
...frameSummary(frames, refresh),
|
||
...keySummary(keys, refresh, "key"),
|
||
tbtMs: totalBlockingTime(await h.longTasksSince(t0)),
|
||
};
|
||
},
|
||
};
|
||
|
||
/** Held key: auto-repeat at ~30 Hz must never fall behind. */
|
||
const keyRepeat: Scenario = {
|
||
id: "keyRepeat",
|
||
title: "Auto-repeat a key in a 20k-line file",
|
||
async run(h) {
|
||
await h.openFile(`${ROOT}/src/big/Repeat.ts`, tsSource(20_000, "repeat"));
|
||
const st = await h.editorState();
|
||
await h.setCursor(Math.floor((st?.length ?? 0) / 3));
|
||
await h.parserIdle();
|
||
return typeAndMeasure(h, "x".repeat(90), 33);
|
||
},
|
||
};
|
||
|
||
async function typeAndMeasure(h: Harness, text: string, intervalMs: number): Promise<Metrics> {
|
||
const refresh = await h.refreshMs();
|
||
const t0 = await h.now();
|
||
const before = await h.counters();
|
||
const lat = await h.typeMeasured(text, intervalMs);
|
||
const work = Harness.delta(before, await h.counters());
|
||
const keys = text.length;
|
||
return {
|
||
...keySummary(lat, refresh),
|
||
// Counts look high (a frame re-styles after CodeMirror's measure writes), so budget the time.
|
||
styleLayoutMsPerKey: work.styleLayoutMs / keys,
|
||
scriptMsPerKey: work.scriptMs / keys,
|
||
mainThreadMsPerKey: work.taskMs / keys,
|
||
tbtMs: totalBlockingTime(await h.longTasksSince(t0)),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Wheel-scrolling a large file: frame pacing while CodeMirror renders new lines.
|
||
* `frame*` / `droppedFramesPct`: 120 px per frame (one wheel notch, ≈ 7 200 px/s,
|
||
* a fast trackpad). `fling*`: 600 px per frame, a whole screen each frame — a
|
||
* stress test without a budget.
|
||
*/
|
||
const scroll: Scenario = {
|
||
id: "scroll",
|
||
title: "Wheel-scroll a 20k-line file",
|
||
async run(h) {
|
||
await h.openFile(`${ROOT}/src/big/Scroll.ts`, tsSource(20_000, "scroll"));
|
||
await h.parserIdle();
|
||
const refresh = await h.refreshMs();
|
||
const box = (await h.page.locator(".cm-scroller").first().boundingBox())!;
|
||
await h.page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||
const t0 = await h.now();
|
||
const wheel = async (steps: number, dy: number) => {
|
||
await h.inPage((p) => p.startFrames());
|
||
for (let i = 0; i < steps; i++) {
|
||
await h.page.mouse.wheel(0, i < steps / 2 ? dy : -dy);
|
||
await h.page.waitForTimeout(16);
|
||
}
|
||
await h.page.waitForTimeout(200);
|
||
return h.inPage((p) => p.stopFrames());
|
||
};
|
||
const smooth = frameSummary(await wheel(160, 120), refresh);
|
||
const tbtMs = totalBlockingTime(await h.longTasksSince(t0));
|
||
const fling = frameSummary(await wheel(60, 600), refresh);
|
||
// Jump far: scrollbar drag / Ctrl+End style repositioning.
|
||
const jumpMs = await h.measure(
|
||
() => h.page.keyboard.press("Control+End"),
|
||
".cm-content",
|
||
"export class Repository",
|
||
5_000,
|
||
);
|
||
return {
|
||
...smooth,
|
||
tbtMs,
|
||
flingFrameP95Ms: fling.frameP95Ms,
|
||
flingDroppedFramesPct: fling.droppedFramesPct,
|
||
jumpMs,
|
||
};
|
||
},
|
||
};
|
||
|
||
/**
|
||
* Git Log with 20,000 commits on branching lanes, loaded page by page as the
|
||
* backend does (150 ms a page, like `git log`): showing it, walking the
|
||
* selection with the arrow keys (the details pane follows) and wheel-scrolling
|
||
* through several pages.
|
||
*/
|
||
const gitLog: Scenario = {
|
||
id: "gitLog",
|
||
title: "Show, navigate and scroll a 20k-commit Git Log",
|
||
async run(h) {
|
||
await h.page.evaluate(() =>
|
||
(
|
||
window as never as { __openideaPerf: { fakeGitLog(n: number, latencyMs?: number): void } }
|
||
).__openideaPerf.fakeGitLog(20_000, 150),
|
||
);
|
||
const showMs = await h.measure(
|
||
() =>
|
||
h.page.evaluate(() =>
|
||
(window as never as { __openideaPerf: { runAction(id: string): Promise<boolean> } }).__openideaPerf.runAction(
|
||
"vcs.showLog",
|
||
),
|
||
),
|
||
'[role="listbox"][aria-label="Commits"]',
|
||
"order flow step 0",
|
||
);
|
||
const refresh = await h.refreshMs();
|
||
const list = h.page.getByRole("listbox", { name: "Commits" });
|
||
await list.getByRole("option").first().click();
|
||
const keyLat = await h.pressMeasured(Array(40).fill("ArrowDown"), 50);
|
||
const box = (await list.boundingBox())!;
|
||
await h.page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||
const t0 = await h.now();
|
||
await h.inPage((p) => p.startFrames());
|
||
// Far enough to load several more pages on the way.
|
||
for (let i = 0; i < 160; i++) {
|
||
await h.page.mouse.wheel(0, i < 120 ? 600 : -600);
|
||
await h.page.waitForTimeout(16);
|
||
}
|
||
await h.page.waitForTimeout(200);
|
||
const frames = await h.inPage((p) => p.stopFrames());
|
||
return {
|
||
showMs,
|
||
...frameSummary(frames, refresh),
|
||
tbtMs: totalBlockingTime(await h.longTasksSince(t0)),
|
||
...keySummary(keyLat, refresh, "arrowKey"),
|
||
};
|
||
},
|
||
};
|
||
|
||
/** Scrollbar-sized jumps across a loaded graph, rather than incremental wheel scrolling. */
|
||
const gitLogScrollbar: Scenario = {
|
||
id: "gitLogScrollbar",
|
||
title: "Drag through a loaded 6k-commit Git Log",
|
||
async run(h) {
|
||
await h.page.evaluate(async () => {
|
||
const probe = (
|
||
window as never as {
|
||
__openideaPerf: {
|
||
fakeGitLog(n: number): void;
|
||
runAction(id: string): Promise<boolean>;
|
||
};
|
||
}
|
||
).__openideaPerf;
|
||
probe.fakeGitLog(6000);
|
||
await probe.runAction("vcs.showLog");
|
||
});
|
||
const list = h.page.getByRole("listbox", { name: "Commits" });
|
||
await list.waitFor();
|
||
await list.evaluate(async (element) => {
|
||
for (let i = 0; i < 300 && element.scrollHeight < 6000 * 26; i++) {
|
||
element.scrollTop = element.scrollHeight;
|
||
await new Promise(requestAnimationFrame);
|
||
}
|
||
if (element.scrollHeight < 6000 * 26) throw new Error("Git Log did not finish loading");
|
||
element.scrollTop = 0;
|
||
});
|
||
await h.page.waitForTimeout(200);
|
||
const refresh = await h.refreshMs();
|
||
const t0 = await h.now();
|
||
await h.inPage((p) => p.startFrames());
|
||
await list.evaluate(async (element) => {
|
||
const max = element.scrollHeight - element.clientHeight;
|
||
for (let i = 0; i < 180; i++) {
|
||
element.scrollTop = ((i < 90 ? i : 180 - i) / 90) * max;
|
||
await new Promise(requestAnimationFrame);
|
||
}
|
||
});
|
||
const frames = await h.inPage((p) => p.stopFrames());
|
||
return {
|
||
...frameSummary(frames, refresh),
|
||
tbtMs: totalBlockingTime(await h.longTasksSince(t0)),
|
||
};
|
||
},
|
||
};
|
||
|
||
/**
|
||
* Dragging the splitters with an editor, the Project tree and the Git Log
|
||
* open: every pointer move resizes the islands, so each frame relays them out.
|
||
*/
|
||
const resizePanels: Scenario = {
|
||
id: "resizePanels",
|
||
title: "Drag the tool window splitters",
|
||
async run(h) {
|
||
await h.openFile(SAMPLE);
|
||
await h.page.evaluate(() =>
|
||
(window as never as { __openideaPerf: { fakeGitLog(n: number): void } }).__openideaPerf.fakeGitLog(2_000),
|
||
);
|
||
await h.measure(
|
||
() =>
|
||
h.page.evaluate(() =>
|
||
(window as never as { __openideaPerf: { runAction(id: string): Promise<boolean> } }).__openideaPerf.runAction(
|
||
"vcs.showLog",
|
||
),
|
||
),
|
||
'[role="listbox"][aria-label="Commits"]',
|
||
"order flow step 0",
|
||
);
|
||
const refresh = await h.refreshMs();
|
||
const drag = async (name: string, dx: number, dy: number) => {
|
||
const box = (await h.page.getByRole("separator", { name }).boundingBox())!;
|
||
const x = box.x + box.width / 2;
|
||
const y = box.y + box.height / 2;
|
||
await h.page.mouse.move(x, y);
|
||
await h.page.mouse.down();
|
||
await h.inPage((p) => p.startFrames());
|
||
// Out and back twice, one pointer move a frame.
|
||
for (let i = 0; i < 120; i++) {
|
||
const t = Math.sin((i / 60) * Math.PI);
|
||
await h.page.mouse.move(x + dx * t, y + dy * t);
|
||
await h.page.waitForTimeout(16);
|
||
}
|
||
await h.page.mouse.up();
|
||
await h.page.waitForTimeout(200);
|
||
return frameSummary(await h.inPage((p) => p.stopFrames()), refresh);
|
||
};
|
||
const t0 = await h.now();
|
||
const side = await drag("Resize left tool window", 300, 0);
|
||
const bottom = await drag("Resize bottom tool window", 0, -250);
|
||
return {
|
||
...side,
|
||
tbtMs: totalBlockingTime(await h.longTasksSince(t0)),
|
||
bottomFrameP95Ms: bottom.frameP95Ms,
|
||
bottomDroppedFramesPct: bottom.droppedFramesPct,
|
||
};
|
||
},
|
||
};
|
||
|
||
/** Cold load: navigation → Project tree painted. */
|
||
const startup: Scenario = {
|
||
id: "startup",
|
||
title: "Load the workbench",
|
||
blank: true,
|
||
async run(h) {
|
||
const readyMs = await h.load();
|
||
const paints = await h.page.evaluate(() =>
|
||
Object.fromEntries(performance.getEntriesByType("paint").map((e) => [e.name, e.startTime])),
|
||
);
|
||
await h.gc();
|
||
const nodes = (await h.counters()).Nodes ?? NaN;
|
||
return {
|
||
readyMs,
|
||
fcpMs: paints["first-contentful-paint"] ?? NaN,
|
||
tbtMs: totalBlockingTime(await h.longTasksSince(0)),
|
||
heapMb: await h.heapMb(),
|
||
domNodes: nodes,
|
||
};
|
||
},
|
||
};
|
||
|
||
/** Opening files: small sample, then switching between eight open tabs. */
|
||
const tabs: Scenario = {
|
||
id: "tabs",
|
||
title: "Open files and switch tabs",
|
||
async run(h) {
|
||
const openSmallMs = await h.openFile(SAMPLE);
|
||
const names: string[] = [];
|
||
for (let i = 0; i < 8; i++) {
|
||
const name = `Module${i}.ts`;
|
||
names.push(name);
|
||
await h.openFile(`${ROOT}/src/tabs/${name}`, tsSource(2_000, `tab-${i}`));
|
||
}
|
||
const switches: number[] = [];
|
||
for (let round = 0; round < 3; round++) {
|
||
for (let i = 0; i < names.length; i++) {
|
||
const tab = h.page.getByRole("tab", { name: names[i], exact: true });
|
||
switches.push(await h.measure(() => tab.click(), ".cm-content", `// tab-${i}`));
|
||
}
|
||
}
|
||
return { openSmallMs, ...latencySummary(switches, "switch") };
|
||
},
|
||
};
|
||
|
||
/** Find Action and Search Everywhere: open, then query → first result painted. */
|
||
const popups: Scenario = {
|
||
id: "popups",
|
||
title: "Find Action / Search Everywhere",
|
||
async run(h) {
|
||
await h.openFile(SAMPLE);
|
||
const findActionOpenMs = await h.measure(() => h.pressAction("navigate.findAction"), '[role="dialog"] input');
|
||
const findActionQueryMs = await h.measure(
|
||
() => h.page.keyboard.insertText("memory indicator"),
|
||
'[role="dialog"] [role="option"]',
|
||
"Memory Indicator",
|
||
);
|
||
// Editing the query: Backspace → list re-filtered and painted.
|
||
const refresh = await h.refreshMs();
|
||
const keyLat = await h.typeMeasured("\b\b\b\b\b\b", 80);
|
||
await h.page.keyboard.press("Escape");
|
||
await h.page.locator('[role="dialog"]').waitFor({ state: "detached" });
|
||
|
||
const seOpenMs = await h.measure(
|
||
() => h.pressAction("navigate.searchEverywhere"),
|
||
'[role="dialog"][aria-label="Search Everywhere"] input',
|
||
);
|
||
const seQueryMs = await h.measure(
|
||
() => h.page.keyboard.insertText("language servers"),
|
||
'[role="dialog"] [role="option"]',
|
||
"Language Servers",
|
||
);
|
||
await h.page.keyboard.press("Escape");
|
||
return {
|
||
findActionOpenMs,
|
||
findActionQueryMs,
|
||
...keySummary(keyLat, refresh, "findActionKey"),
|
||
searchEverywhereOpenMs: seOpenMs,
|
||
searchEverywhereQueryMs: seQueryMs,
|
||
};
|
||
},
|
||
};
|
||
|
||
const TOOL_WINDOWS: Array<[string, string]> = [
|
||
["toolwindow.problems", "Problems"],
|
||
["toolwindow.run", "Run"],
|
||
["toolwindow.structure", "Structure"],
|
||
["toolwindow.commit", "Commit"],
|
||
["toolwindow.git", "Git"],
|
||
["toolwindow.terminal", "Terminal"],
|
||
];
|
||
|
||
/** Showing tool windows by shortcut (first show loads and mounts the view). */
|
||
const toolWindows: Scenario = {
|
||
id: "toolWindows",
|
||
title: "Show tool windows",
|
||
async run(h) {
|
||
await h.openFile(SAMPLE);
|
||
const m: Metrics = {};
|
||
const all: number[] = [];
|
||
for (const [action, title] of TOOL_WINDOWS) {
|
||
// Until the view's content is laid out (lazily loaded views render nothing at first).
|
||
const ms = await h.measure(
|
||
() => h.pressAction(action),
|
||
`section[aria-label="${title}"] [data-toolwindow-body] > *`,
|
||
);
|
||
m[`${title.toLowerCase()}Ms`] = ms;
|
||
all.push(ms);
|
||
await h.pressAction(action);
|
||
await h.page.locator(`section[aria-label="${title}"]`).waitFor({ state: "hidden" });
|
||
}
|
||
return { maxMs: Math.max(...all), ...m };
|
||
},
|
||
};
|
||
|
||
/** Project tree: expand a 10 000-entry folder and scroll through it. */
|
||
const projectTree: Scenario = {
|
||
id: "projectTree",
|
||
title: "Expand and scroll a 10k-entry folder",
|
||
async run(h) {
|
||
const generated = h.page.locator('[role="treeitem"][aria-level="2"]', { hasText: "generated" }).first();
|
||
await generated.click();
|
||
const expandMs = await h.measure(
|
||
() => h.page.keyboard.press("ArrowRight"),
|
||
'[role="tree"][aria-label="Project"]',
|
||
"item1.json",
|
||
);
|
||
const refresh = await h.refreshMs();
|
||
// Selection walks into the expanded folder (rows stay rendered: no scroll yet).
|
||
const keyLat = await h.pressMeasured(Array(30).fill("ArrowDown"), 50);
|
||
const box = (await h.page.getByRole("tree", { name: "Project" }).boundingBox())!;
|
||
await h.page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||
const t0 = await h.now();
|
||
await h.inPage((p) => p.startFrames());
|
||
for (let i = 0; i < 60; i++) {
|
||
await h.page.mouse.wheel(0, 500);
|
||
await h.page.waitForTimeout(16);
|
||
}
|
||
await h.page.waitForTimeout(200);
|
||
const frames = await h.inPage((p) => p.stopFrames());
|
||
return {
|
||
expandMs,
|
||
...frameSummary(frames, refresh),
|
||
tbtMs: totalBlockingTime(await h.longTasksSince(t0)),
|
||
...keySummary(keyLat, refresh, "arrowKey"),
|
||
};
|
||
},
|
||
};
|
||
|
||
/** Opening and closing editors repeatedly must not grow the heap or the DOM. */
|
||
const leaks: Scenario = {
|
||
id: "leaks",
|
||
title: "Open / close 10 editors × 4",
|
||
async run(h) {
|
||
const cycle = async (n: number) => {
|
||
for (let i = 0; i < 10; i++)
|
||
await h.openFile(`${ROOT}/src/leak/File${n}_${i}.ts`, tsSource(1_500, `leak-${n}-${i}`));
|
||
await h.page.evaluate(() =>
|
||
(window as never as { __openideaPerf: { runAction(id: string): Promise<boolean> } }).__openideaPerf.runAction(
|
||
"editor.closeAllTabs",
|
||
),
|
||
);
|
||
await h.page.locator(".cm-editor").waitFor({ state: "detached" });
|
||
};
|
||
await cycle(0);
|
||
await h.gc();
|
||
const heap0 = await h.heapMb();
|
||
const c0 = await h.counters();
|
||
for (let n = 1; n <= 3; n++) await cycle(n);
|
||
await h.gc();
|
||
const c1 = await h.counters();
|
||
return {
|
||
heapGrowthMb: (await h.heapMb()) - heap0,
|
||
domNodeGrowth: (c1.Nodes ?? 0) - (c0.Nodes ?? 0),
|
||
listenerGrowth: (c1.JSEventListeners ?? 0) - (c0.JSEventListeners ?? 0),
|
||
};
|
||
},
|
||
};
|
||
|
||
export const SCENARIOS: Scenario[] = [
|
||
startup,
|
||
typing,
|
||
typingLarge,
|
||
typingAngular,
|
||
completion,
|
||
consoleOutput,
|
||
gitLog,
|
||
gitLogScrollbar,
|
||
resizePanels,
|
||
keyRepeat,
|
||
scroll,
|
||
tabs,
|
||
popups,
|
||
toolWindows,
|
||
projectTree,
|
||
leaks,
|
||
];
|