* feat(app): let the file panel close its tree rail The file panel's tree rail was permanent on desktop — the changes panel had a folder-tree toggle in its toolbar, the file toolbar had nothing, so the rail was there whether you wanted it or not. The changes panel's private DesktopTreeToggle becomes a shared TreeRailToggle so both toolbars render the same affordance; that also drops the treeToggleStyle prop threaded through ChangesToolbarProps, which recomputed exactly what the component already built internally. The i18n keys move out of workspace.git.diff into workspace.tree, since a shared component reaching into the git namespace would rot. The two rails keep independent flags — the file rail in panel-store, the changes rail in useChangesPreferences — so closing one leaves the other alone. FileTreeToggle wires itself to the store rather than taking a callback: the bar renders several components below FilePanel, where the rail actually lives. Both rails also get narrower (320 -> 260 default, 200 -> 180 minimum). The persist version bump resets treeRailWidth on purpose: nearly every existing install carries the old 320 default, so without the reset the narrower rail would only ever reach new users. Anyone who dragged their rail to a deliberate custom width loses it once. Note that MIN_CONTENT_WIDTH still clamps the rail to `containerWidth - 240`, so in a narrow pane the new default never applies — that floor, not the default, is what sets the width there. * feat(app): make the side panel an empty workspace surface The fixed, hideable side panel keeps supporting context beside an agent without seeding content. Pane-local choices stay local, while implicit supporting opens follow a persisted routing preference. * test(app): exercise side panel policy through its capability boundary The browser rail journey already covers the real tree-toggle behavior. Side-panel policy tests now pass their platform capability through the public boundary instead of replacing a production module. * test(app): expect an empty side panel after upgrade * test(app): expect an empty first split pane * test(app): account for the empty side panel in split retention * feat(app): make workspace panel tabs independent Give explicit New actions fresh tab instances, keep panel state opaque to tab management, preserve File navigation context, and stabilize uncached File transitions. * fix(app): preserve file conflicts during preview refresh Keep the current preview mounted for same-target reads and conversions so the editor retains its conflict observation state. Clear retained previews only when the file target changes.
588 lines
22 KiB
TypeScript
588 lines
22 KiB
TypeScript
import { writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import type { CDPSession, Locator, Page, TestInfo } from "@playwright/test";
|
|
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
|
import { test, expect } from "../support/fixtures";
|
|
import { getServerId } from "../support/helpers/server-id";
|
|
import { connectSeedClient } from "../support/helpers/seed-client";
|
|
import { createTempGitRepo } from "../support/helpers/workspace";
|
|
import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
|
import { DIFF_POC_BASELINE } from "./diff-performance-baseline";
|
|
|
|
const RUN_DIFF_PERF = process.env.PASEO_DIFF_PERF_E2E === "1";
|
|
const diffPerfDescribe = RUN_DIFF_PERF ? test.describe : test.describe.skip;
|
|
const LINE_COUNT_PER_FILE = 1_200;
|
|
const FILE_PATHS = ["src/large-a.ts", "src/large-b.ts"] as const;
|
|
const CPU_SLOWDOWN = Number(process.env.PASEO_DIFF_PERF_CPU_SLOWDOWN ?? 6);
|
|
const CHANGES_PREFERENCES_KEY = "@paseo:changes-preferences";
|
|
const POC_MEDIAN_FRAME_MS = Number(
|
|
process.env.PASEO_DIFF_POC_MEDIAN_FRAME_MS ?? DIFF_POC_BASELINE.medianFrameMs,
|
|
);
|
|
const POC_P95_FRAME_MS = Number(
|
|
process.env.PASEO_DIFF_POC_P95_FRAME_MS ?? DIFF_POC_BASELINE.p95FrameMs,
|
|
);
|
|
const MAX_MEDIAN_FRAME_MS = POC_MEDIAN_FRAME_MS * 1.1;
|
|
const MAX_P95_FRAME_MS = POC_P95_FRAME_MS * 1.1;
|
|
|
|
diffPerfDescribe("Diff canvas performance", () => {
|
|
test("opening a review stays responsive in a large diff", async ({ page }) => {
|
|
test.setTimeout(120_000);
|
|
const repo = await createLargeDiffRepository();
|
|
const client = await connectSeedClient();
|
|
let cdp: CDPSession | null = null;
|
|
|
|
try {
|
|
const created = await client.createWorkspace({
|
|
source: { kind: "directory", path: repo.path },
|
|
});
|
|
if (!created.workspace) {
|
|
throw new Error(created.error ?? "Failed to create diff performance workspace");
|
|
}
|
|
|
|
await configureUnwrappedUnifiedDiff(page);
|
|
await page.setViewportSize({ width: 1400, height: 900 });
|
|
await page.goto(buildHostWorkspaceRoute(getServerId(), created.workspace.id));
|
|
await waitForWorkspaceTabsVisible(page);
|
|
await openChangesPanel(page);
|
|
await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
|
|
|
|
cdp = await page.context().newCDPSession(page);
|
|
await cdp.send("Emulation.setCPUThrottlingRate", { rate: CPU_SLOWDOWN });
|
|
const elapsedMs = await openFirstReviewEditor(page);
|
|
expect(elapsedMs).toBeLessThanOrEqual(200 * CPU_SLOWDOWN);
|
|
} finally {
|
|
await cdp?.send("Emulation.setCPUThrottlingRate", { rate: 1 }).catch(() => undefined);
|
|
await cdp?.detach().catch(() => undefined);
|
|
await client.close().catch(() => undefined);
|
|
await repo.cleanup().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
test("one canvas stays filled through continuous fast scrolling", async ({ page }, testInfo) => {
|
|
test.setTimeout(180_000);
|
|
const repo = await createLargeDiffRepository();
|
|
const client = await connectSeedClient();
|
|
let cdp: CDPSession | null = null;
|
|
let traceStarted = false;
|
|
|
|
try {
|
|
const created = await client.createWorkspace({
|
|
source: { kind: "directory", path: repo.path },
|
|
});
|
|
if (!created.workspace) {
|
|
throw new Error(created.error ?? "Failed to create diff performance workspace");
|
|
}
|
|
|
|
await configureUnwrappedUnifiedDiff(page);
|
|
await page.setViewportSize({ width: 1400, height: 900 });
|
|
await page.goto(buildHostWorkspaceRoute(getServerId(), created.workspace.id));
|
|
await waitForWorkspaceTabsVisible(page);
|
|
await openChangesPanel(page);
|
|
await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
|
|
|
|
await expectSingleCanvasSurface(page);
|
|
cdp = await page.context().newCDPSession(page);
|
|
await cdp.send("Emulation.setCPUThrottlingRate", { rate: CPU_SLOWDOWN });
|
|
await startChromiumTrace(cdp);
|
|
traceStarted = true;
|
|
await startRendererInstrumentation(page);
|
|
|
|
const scrollReports = [];
|
|
const heapUsage = [];
|
|
for (let cycle = 0; cycle < 3; cycle += 1) {
|
|
scrollReports.push(await continuouslyScrollCanvas(page));
|
|
heapUsage.push(await collectHeapUsage(cdp));
|
|
}
|
|
await expectContinuousFramesMatchStableRender(
|
|
page,
|
|
scrollReports.flatMap((report) => report.frameSignatures),
|
|
);
|
|
const frameImages = await captureCanvasAtScrollIntervals(page);
|
|
for (const [frameIndex, frame] of frameImages.entries()) {
|
|
await writeFile(testInfo.outputPath(`diff-canvas-frame-${frameIndex}.png`), frame.image);
|
|
await testInfo.attach(`diff-canvas-frame-${frameIndex}`, {
|
|
body: frame.image,
|
|
contentType: "image/png",
|
|
});
|
|
expect(frame.image.byteLength).toBeGreaterThan(4_000);
|
|
}
|
|
|
|
await expectIndependentHorizontalOffsetsSurviveVerticalScrolling(page);
|
|
const renderer = await readRendererInstrumentation(page);
|
|
const measuredCycles = scrollReports.slice(1);
|
|
expect(Math.max(...measuredCycles.map((cycle) => cycle.medianFrameMs))).toBeLessThanOrEqual(
|
|
MAX_MEDIAN_FRAME_MS,
|
|
);
|
|
expect(Math.max(...measuredCycles.map((cycle) => cycle.p95FrameMs))).toBeLessThanOrEqual(
|
|
MAX_P95_FRAME_MS,
|
|
);
|
|
for (const cycle of scrollReports) {
|
|
expect(Math.abs(cycle.maximumScrollTop - cycle.endScrollTop)).toBeLessThanOrEqual(1);
|
|
}
|
|
expect(heapUsage.at(-1)!.usedSize).toBeLessThanOrEqual(
|
|
heapUsage[0]!.usedSize * 1.1 + 1_000_000,
|
|
);
|
|
expect(renderer.addedElements).toBe(0);
|
|
expect(renderer.removedElements).toBe(0);
|
|
expect(renderer.reactCommits).toBeLessThanOrEqual(2);
|
|
|
|
await reloadDiffPresentation(page, { layout: "unified", wrapLines: true });
|
|
const wrapped = await measureAdditionalPerformanceScenario(page, cdp, testInfo, "wrapped");
|
|
await reloadDiffPresentation(page, { layout: "split", wrapLines: false });
|
|
const split = await measureAdditionalPerformanceScenario(page, cdp, testInfo, "split");
|
|
|
|
const report = {
|
|
cpuSlowdown: CPU_SLOWDOWN,
|
|
pocBaseline: {
|
|
medianFrameMs: POC_MEDIAN_FRAME_MS,
|
|
p95FrameMs: POC_P95_FRAME_MS,
|
|
},
|
|
sourceFiles: FILE_PATHS.length,
|
|
sourceLines: FILE_PATHS.length * LINE_COUNT_PER_FILE,
|
|
scrollReports,
|
|
heapUsage,
|
|
renderer,
|
|
wrapped,
|
|
split,
|
|
canvasPngBytes: frameImages.map((frame) => frame.image.byteLength),
|
|
};
|
|
await attachPerformanceReport(testInfo, report);
|
|
await stopChromiumTrace(cdp, testInfo);
|
|
traceStarted = false;
|
|
|
|
await cdp.send("Emulation.setCPUThrottlingRate", { rate: 1 });
|
|
} finally {
|
|
if (cdp && traceStarted) {
|
|
await stopChromiumTrace(cdp, testInfo).catch(() => undefined);
|
|
}
|
|
await cdp?.send("Emulation.setCPUThrottlingRate", { rate: 1 }).catch(() => undefined);
|
|
await cdp?.detach().catch(() => undefined);
|
|
await client.close().catch(() => undefined);
|
|
await repo.cleanup().catch(() => undefined);
|
|
}
|
|
});
|
|
});
|
|
|
|
async function measureAdditionalPerformanceScenario(
|
|
page: Page,
|
|
cdp: CDPSession,
|
|
testInfo: TestInfo,
|
|
name: "wrapped" | "split",
|
|
) {
|
|
await expectSingleCanvasSurface(page);
|
|
await startRendererInstrumentation(page);
|
|
const scrollReports = [];
|
|
const heapUsage = [];
|
|
for (let cycle = 0; cycle < 3; cycle += 1) {
|
|
scrollReports.push(await continuouslyScrollCanvas(page));
|
|
heapUsage.push(await collectHeapUsage(cdp));
|
|
}
|
|
await expectContinuousFramesMatchStableRender(
|
|
page,
|
|
scrollReports.flatMap((report) => report.frameSignatures),
|
|
);
|
|
const frames = await captureCanvasAtScrollIntervals(page);
|
|
for (const [index, frame] of frames.entries()) {
|
|
await writeFile(testInfo.outputPath(`diff-canvas-${name}-frame-${index}.png`), frame.image);
|
|
await testInfo.attach(`diff-canvas-${name}-frame-${index}`, {
|
|
body: frame.image,
|
|
contentType: "image/png",
|
|
});
|
|
expect(frame.image.byteLength).toBeGreaterThan(4_000);
|
|
}
|
|
const measuredCycles = scrollReports.slice(1);
|
|
expect(Math.max(...measuredCycles.map((cycle) => cycle.medianFrameMs))).toBeLessThanOrEqual(
|
|
MAX_MEDIAN_FRAME_MS,
|
|
);
|
|
expect(Math.max(...measuredCycles.map((cycle) => cycle.p95FrameMs))).toBeLessThanOrEqual(
|
|
MAX_P95_FRAME_MS,
|
|
);
|
|
for (const cycle of scrollReports) {
|
|
expect(Math.abs(cycle.maximumScrollTop - cycle.endScrollTop)).toBeLessThanOrEqual(1);
|
|
}
|
|
expect(heapUsage.at(-1)!.usedSize).toBeLessThanOrEqual(heapUsage[0]!.usedSize * 1.1 + 1_000_000);
|
|
const renderer = await readRendererInstrumentation(page);
|
|
expect(renderer.addedElements).toBe(0);
|
|
expect(renderer.removedElements).toBe(0);
|
|
expect(renderer.reactCommits).toBeLessThanOrEqual(2);
|
|
return {
|
|
scrollReports,
|
|
heapUsage,
|
|
renderer,
|
|
canvasPngBytes: frames.map((frame) => frame.image.byteLength),
|
|
};
|
|
}
|
|
|
|
async function reloadDiffPresentation(
|
|
page: Page,
|
|
requestedPresentation: { layout: "unified" | "split"; wrapLines: boolean },
|
|
): Promise<void> {
|
|
await page.evaluate(
|
|
({ preferencesKey, presentation }) => {
|
|
const current = JSON.parse(localStorage.getItem(preferencesKey) ?? "{}") as object;
|
|
localStorage.setItem(preferencesKey, JSON.stringify({ ...current, ...presentation }));
|
|
},
|
|
{ preferencesKey: CHANGES_PREFERENCES_KEY, presentation: requestedPresentation },
|
|
);
|
|
await page.reload();
|
|
await waitForWorkspaceTabsVisible(page);
|
|
await openChangesPanel(page);
|
|
await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
|
|
}
|
|
|
|
async function createLargeDiffRepository() {
|
|
const repo = await createTempGitRepo("diff-perf-", {
|
|
files: FILE_PATHS.map((filePath, fileIndex) => ({
|
|
path: filePath,
|
|
content: `export const baseline_${fileIndex} = 0;\n`,
|
|
})),
|
|
});
|
|
for (const [fileIndex, filePath] of FILE_PATHS.entries()) {
|
|
const generatedLines = Array.from(
|
|
{ length: LINE_COUNT_PER_FILE },
|
|
(_, lineIndex) =>
|
|
`export const generated_${fileIndex}_${lineIndex} = "${lineIndex}-${"wide".repeat(16)}";`,
|
|
).join("\n");
|
|
await writeFile(
|
|
path.join(repo.path, filePath),
|
|
`export const baseline_${fileIndex} = 1;\n${generatedLines}\n`,
|
|
);
|
|
}
|
|
return repo;
|
|
}
|
|
|
|
async function configureUnwrappedUnifiedDiff(page: Page): Promise<void> {
|
|
await page.addInitScript(
|
|
({ preferencesKey }) => {
|
|
const scope = globalThis as typeof globalThis & {
|
|
__PASEO_DIFF_REACT_STATS__?: { commits: number };
|
|
};
|
|
scope.__PASEO_DIFF_REACT_STATS__ = { commits: 0 };
|
|
if (!localStorage.getItem(preferencesKey)) {
|
|
localStorage.setItem(
|
|
preferencesKey,
|
|
JSON.stringify({
|
|
layout: "unified",
|
|
viewMode: "flat",
|
|
wrapLines: false,
|
|
hideWhitespace: false,
|
|
}),
|
|
);
|
|
}
|
|
},
|
|
{ preferencesKey: CHANGES_PREFERENCES_KEY },
|
|
);
|
|
}
|
|
|
|
async function openFirstReviewEditor(page: Page): Promise<number> {
|
|
const body = page.getByTestId("diff-file-0-body");
|
|
const canvas = page.getByTestId("git-diff-canvas");
|
|
const [bodyBounds, fontSize] = await Promise.all([
|
|
body.boundingBox(),
|
|
canvas.evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize)),
|
|
]);
|
|
if (!bodyBounds) throw new Error("Expanded diff body has no bounds");
|
|
const lineHeight = Math.round(fontSize * 1.5);
|
|
const startedAt = performance.now();
|
|
await page.mouse.click(bodyBounds.x + 120, bodyBounds.y + lineHeight * 1.5);
|
|
await expect(page.getByTestId("inline-review-editor")).toBeVisible();
|
|
return performance.now() - startedAt;
|
|
}
|
|
|
|
async function expectSingleCanvasSurface(page: Page): Promise<void> {
|
|
await expect(page.locator("canvas")).toHaveCount(1);
|
|
await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0);
|
|
const elementCount = await page
|
|
.getByTestId("git-diff-canvas-root")
|
|
.evaluate((root) => root.querySelectorAll("*").length);
|
|
expect(elementCount).toBeLessThan(150);
|
|
}
|
|
|
|
async function continuouslyScrollCanvas(page: Page): Promise<{
|
|
medianFrameMs: number;
|
|
p95FrameMs: number;
|
|
maximumFrameMs: number;
|
|
slowFrames: number;
|
|
endScrollTop: number;
|
|
maximumScrollTop: number;
|
|
frameSignatures: Array<{ scrollTop: number; signature: number }>;
|
|
}> {
|
|
return page.getByTestId("git-diff-scroll").evaluate(async (element) => {
|
|
const scroll = element as HTMLElement;
|
|
const maximumScrollTop = scroll.scrollHeight - scroll.clientHeight;
|
|
scroll.scrollTop = 0;
|
|
const frameDurations: number[] = [];
|
|
const frameSignatures: Array<{ scrollTop: number; signature: number }> = [];
|
|
let previousFrame = performance.now();
|
|
|
|
for (let step = 1; step <= 90; step += 1) {
|
|
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
|
const now = performance.now();
|
|
frameDurations.push(now - previousFrame);
|
|
previousFrame = now;
|
|
scroll.scrollTop = (maximumScrollTop * step) / 90;
|
|
scroll.dispatchEvent(new Event("scroll", { bubbles: false }));
|
|
if (step % 10 === 0) {
|
|
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
|
const canvas = document.querySelector<HTMLCanvasElement>(
|
|
'[data-testid="git-diff-canvas"]',
|
|
)!;
|
|
frameSignatures.push({
|
|
scrollTop: scroll.scrollTop,
|
|
signature: canvasSignature(canvas, scroll),
|
|
});
|
|
previousFrame = performance.now();
|
|
}
|
|
}
|
|
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
|
|
|
const ordered = [...frameDurations].sort((left, right) => left - right);
|
|
const percentile = (fraction: number) =>
|
|
ordered[Math.min(ordered.length - 1, Math.floor(ordered.length * fraction))] ?? 0;
|
|
return {
|
|
medianFrameMs: percentile(0.5),
|
|
p95FrameMs: percentile(0.95),
|
|
maximumFrameMs: Math.max(...frameDurations),
|
|
slowFrames: frameDurations.filter((duration) => duration > 50).length,
|
|
endScrollTop: scroll.scrollTop,
|
|
maximumScrollTop,
|
|
frameSignatures,
|
|
};
|
|
|
|
function canvasSignature(canvasElement: HTMLCanvasElement, scrollElement: HTMLElement): number {
|
|
const sample = document.createElement("canvas");
|
|
sample.width = 64;
|
|
sample.height = 36;
|
|
const context = sample.getContext("2d")!;
|
|
const canvasCssHeight = Number.parseFloat(canvasElement.style.height);
|
|
const canvasTop = Number.parseFloat(canvasElement.style.top);
|
|
const pixelRatio = canvasElement.height / canvasCssHeight;
|
|
const sourceY = (scrollElement.scrollTop - canvasTop) * pixelRatio;
|
|
context.drawImage(
|
|
canvasElement,
|
|
0,
|
|
sourceY,
|
|
canvasElement.width,
|
|
scrollElement.clientHeight * pixelRatio,
|
|
0,
|
|
0,
|
|
sample.width,
|
|
sample.height,
|
|
);
|
|
const pixels = context.getImageData(0, 0, sample.width, sample.height).data;
|
|
let hash = 2166136261;
|
|
for (const value of pixels) hash = Math.imul(hash ^ value, 16777619);
|
|
return hash >>> 0;
|
|
}
|
|
});
|
|
}
|
|
|
|
interface CapturedCanvasFrame {
|
|
scrollTop: number;
|
|
image: Buffer;
|
|
}
|
|
|
|
async function captureCanvasAtScrollIntervals(page: Page): Promise<CapturedCanvasFrame[]> {
|
|
const scroll = page.getByTestId("git-diff-scroll");
|
|
const frames: CapturedCanvasFrame[] = [];
|
|
for (let step = 0; step <= 8; step += 1) {
|
|
const scrollTop = await scroll.evaluate(async (element, progress) => {
|
|
const scrollElement = element as HTMLElement;
|
|
scrollElement.scrollTop =
|
|
(scrollElement.scrollHeight - scrollElement.clientHeight) * Number(progress);
|
|
scrollElement.dispatchEvent(new Event("scroll", { bubbles: false }));
|
|
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
|
return scrollElement.scrollTop;
|
|
}, step / 8);
|
|
frames.push({ scrollTop, image: await scroll.screenshot() });
|
|
}
|
|
return frames;
|
|
}
|
|
|
|
async function expectContinuousFramesMatchStableRender(
|
|
page: Page,
|
|
samples: Array<{ scrollTop: number; signature: number }>,
|
|
): Promise<void> {
|
|
const stable = await page.getByTestId("git-diff-scroll").evaluate(async (element, frames) => {
|
|
const canvas = document.querySelector<HTMLCanvasElement>('[data-testid="git-diff-canvas"]')!;
|
|
const signatures: number[] = [];
|
|
for (const frame of frames) {
|
|
element.scrollTop = frame.scrollTop;
|
|
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
|
await new Promise<void>((resolve) =>
|
|
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
|
);
|
|
const sample = document.createElement("canvas");
|
|
sample.width = 64;
|
|
sample.height = 36;
|
|
const context = sample.getContext("2d")!;
|
|
drawVisibleCanvas(context, canvas, element as HTMLElement, sample.width, sample.height);
|
|
const pixels = context.getImageData(0, 0, sample.width, sample.height).data;
|
|
let hash = 2166136261;
|
|
for (const value of pixels) hash = Math.imul(hash ^ value, 16777619);
|
|
signatures.push(hash >>> 0);
|
|
}
|
|
return signatures;
|
|
|
|
function drawVisibleCanvas(
|
|
context: CanvasRenderingContext2D,
|
|
canvasElement: HTMLCanvasElement,
|
|
scroll: HTMLElement,
|
|
width: number,
|
|
height: number,
|
|
): void {
|
|
const canvasCssHeight = Number.parseFloat(canvasElement.style.height);
|
|
const canvasTop = Number.parseFloat(canvasElement.style.top);
|
|
const pixelRatio = canvasElement.height / canvasCssHeight;
|
|
const sourceY = (scroll.scrollTop - canvasTop) * pixelRatio;
|
|
context.drawImage(
|
|
canvasElement,
|
|
0,
|
|
sourceY,
|
|
canvasElement.width,
|
|
scroll.clientHeight * pixelRatio,
|
|
0,
|
|
0,
|
|
width,
|
|
height,
|
|
);
|
|
}
|
|
}, samples);
|
|
expect(stable).toEqual(samples.map((sample) => sample.signature));
|
|
}
|
|
|
|
async function expectIndependentHorizontalOffsetsSurviveVerticalScrolling(
|
|
page: Page,
|
|
): Promise<void> {
|
|
const first = page.getByTestId("diff-file-0-horizontal-scroll");
|
|
const second = page.getByTestId("diff-file-1-horizontal-scroll");
|
|
const firstOffset = await setHorizontalOffset(first, 240);
|
|
const secondOffset = await setHorizontalOffset(second, 120);
|
|
expect(firstOffset).not.toBe(secondOffset);
|
|
await page.getByTestId("git-diff-scroll").evaluate(async (element) => {
|
|
element.scrollTop = element.scrollHeight - element.clientHeight;
|
|
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
|
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
|
element.scrollTop = 0;
|
|
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
|
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
|
});
|
|
await expect.poll(() => first.evaluate((element) => element.scrollLeft)).toBe(firstOffset);
|
|
await expect.poll(() => second.evaluate((element) => element.scrollLeft)).toBe(secondOffset);
|
|
}
|
|
|
|
async function setHorizontalOffset(locator: Locator, offset: number) {
|
|
return locator.evaluate(async (element, nextOffset) => {
|
|
element.scrollLeft = nextOffset;
|
|
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
|
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
|
return element.scrollLeft;
|
|
}, offset);
|
|
}
|
|
|
|
async function startRendererInstrumentation(page: Page): Promise<void> {
|
|
await page.getByTestId("git-diff-canvas-root").evaluate((root) => {
|
|
const scope = globalThis as typeof globalThis & {
|
|
__PASEO_DIFF_REACT_STATS__?: { commits: number };
|
|
__PASEO_DIFF_RENDERER_STATS__?: {
|
|
initialCommits: number;
|
|
addedElements: number;
|
|
removedElements: number;
|
|
};
|
|
};
|
|
const reactStats = scope.__PASEO_DIFF_REACT_STATS__ ?? { commits: 0, unmounts: 0 };
|
|
const stats = {
|
|
initialCommits: reactStats.commits,
|
|
addedElements: 0,
|
|
removedElements: 0,
|
|
};
|
|
scope.__PASEO_DIFF_RENDERER_STATS__ = stats;
|
|
new MutationObserver((records) => {
|
|
for (const record of records) {
|
|
stats.addedElements += Array.from(record.addedNodes).filter(
|
|
(node) => node.nodeType === Node.ELEMENT_NODE,
|
|
).length;
|
|
stats.removedElements += Array.from(record.removedNodes).filter(
|
|
(node) => node.nodeType === Node.ELEMENT_NODE,
|
|
).length;
|
|
}
|
|
}).observe(root, { childList: true, subtree: true });
|
|
});
|
|
}
|
|
|
|
async function readRendererInstrumentation(page: Page): Promise<{
|
|
reactCommits: number;
|
|
addedElements: number;
|
|
removedElements: number;
|
|
}> {
|
|
return page.evaluate(() => {
|
|
const scope = globalThis as typeof globalThis & {
|
|
__PASEO_DIFF_REACT_STATS__?: { commits: number };
|
|
__PASEO_DIFF_RENDERER_STATS__?: {
|
|
initialCommits: number;
|
|
addedElements: number;
|
|
removedElements: number;
|
|
};
|
|
};
|
|
const react = scope.__PASEO_DIFF_REACT_STATS__!;
|
|
const renderer = scope.__PASEO_DIFF_RENDERER_STATS__!;
|
|
return {
|
|
reactCommits: react.commits - renderer.initialCommits,
|
|
addedElements: renderer.addedElements,
|
|
removedElements: renderer.removedElements,
|
|
};
|
|
});
|
|
}
|
|
|
|
async function collectHeapUsage(cdp: CDPSession): Promise<{ usedSize: number; totalSize: number }> {
|
|
await cdp.send("HeapProfiler.collectGarbage");
|
|
return cdp.send("Runtime.getHeapUsage");
|
|
}
|
|
|
|
async function startChromiumTrace(cdp: CDPSession): Promise<void> {
|
|
await cdp.send("Tracing.start", {
|
|
categories: [
|
|
"devtools.timeline",
|
|
"v8.execute",
|
|
"blink.user_timing",
|
|
"disabled-by-default-v8.cpu_profiler",
|
|
].join(","),
|
|
transferMode: "ReturnAsStream",
|
|
});
|
|
}
|
|
|
|
async function stopChromiumTrace(cdp: CDPSession, testInfo: TestInfo): Promise<void> {
|
|
const completed = new Promise<string>((resolve, reject) => {
|
|
cdp.once("Tracing.tracingComplete", ({ stream }) => {
|
|
if (stream) resolve(stream);
|
|
else reject(new Error("Chromium trace completed without a stream"));
|
|
});
|
|
});
|
|
await cdp.send("Tracing.end");
|
|
const stream = await completed;
|
|
const chunks: string[] = [];
|
|
let eof = false;
|
|
while (!eof) {
|
|
const chunk = await cdp.send("IO.read", { handle: stream });
|
|
chunks.push(chunk.data);
|
|
eof = chunk.eof;
|
|
}
|
|
await cdp.send("IO.close", { handle: stream });
|
|
const trace = chunks.join("");
|
|
await writeFile(testInfo.outputPath("diff-canvas-chromium-trace.json"), trace);
|
|
await testInfo.attach("diff-canvas-chromium-trace", {
|
|
body: trace,
|
|
contentType: "application/json",
|
|
});
|
|
}
|
|
|
|
async function attachPerformanceReport(
|
|
testInfo: TestInfo,
|
|
report: Record<string, unknown>,
|
|
): Promise<void> {
|
|
await testInfo.attach("diff-canvas-performance", {
|
|
body: JSON.stringify(report, null, 2),
|
|
contentType: "application/json",
|
|
});
|
|
}
|