refactor(app): implement canvas-based diff rendering
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFile, unlink, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { type Page } from "@playwright/test";
|
||||
import { type Locator, type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../../src/utils/host-routes";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
@@ -293,6 +293,27 @@ test("changes file actions open below the right-click without a reserved kebab",
|
||||
await expect(page.getByTestId("workspace-tab-file_src/use-mounted-tab-set.ts")).toBeVisible();
|
||||
});
|
||||
|
||||
test("canvas file headers select without toggling for context menu and long press", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const firstFile = page.getByTestId("diff-file-0-toggle");
|
||||
const deletedFile = page.getByTestId("diff-file-1-toggle");
|
||||
await deletedFile.click({ button: "right" });
|
||||
await expect(deletedFile).toHaveAttribute("aria-selected", "true");
|
||||
await expect(deletedFile).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(page.getByText("Copy path")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await longPressFileHeader(page, firstFile);
|
||||
await expect(firstFile).toHaveAttribute("aria-selected", "true");
|
||||
await expect(deletedFile).toHaveAttribute("aria-selected", "false");
|
||||
await expect(firstFile).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
test("changes context menus duplicate files and folders", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
@@ -487,6 +508,10 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
|
||||
await expect(page.getByTestId("changes-primary-cta")).toContainText("Commit");
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
await visiblePanel.getByTestId("diff-file-0-toggle").click();
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).not.toBeVisible();
|
||||
await visiblePanel.getByRole("button", { name: "Collapse all files" }).click();
|
||||
await expect(visiblePanel.getByRole("button", { name: "Expand all files" })).toBeVisible();
|
||||
await visiblePanel.getByRole("button", { name: "Expand all files" }).click();
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
const workingDiffLayoutToggle = visiblePanel.getByRole("button", {
|
||||
name: "Switch to side-by-side diff",
|
||||
@@ -502,7 +527,7 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
|
||||
await visiblePanel.getByRole("button", { name: "Diff options" }).click();
|
||||
await expect(page.getByText("Scroll long lines", { exact: true })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("button", { name: /all files/i })).toHaveCount(0);
|
||||
await expect(visiblePanel.getByRole("button", { name: "Collapse all files" })).toBeVisible();
|
||||
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
|
||||
|
||||
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
|
||||
@@ -611,14 +636,230 @@ test("changes diff applies code size changes to gutter and code typography", asy
|
||||
await useCodeFont(page, 12);
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
const before = await readDiffTypographyGeometry(page);
|
||||
|
||||
await changeCodeFontSizeFromSettings(page, 18);
|
||||
await changeCodeTypographyFromSettings(page, {
|
||||
fontSize: 18,
|
||||
fontFamily: "Courier New, Courier, monospace",
|
||||
});
|
||||
await returnToWorkspaceChanges(page);
|
||||
await expectStoredCodeFontSize(page, 18);
|
||||
await scrollToLowerUnwrappedDiffRows(page);
|
||||
|
||||
await expectDiffCodeFontSize(page, 18);
|
||||
await expectDiffCodeFontFamily(page, "Courier");
|
||||
await expectVisibleDiffRowsShareTypography(page);
|
||||
const after = await readDiffTypographyGeometry(page);
|
||||
expect(after.horizontalExtent).toBeGreaterThan(before.horizontalExtent);
|
||||
expect(after.canvasPixels).not.toEqual(before.canvasPixels);
|
||||
});
|
||||
|
||||
test("canvas diff does not commit geometry before configured fonts are ready", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await holdBrowserFontLoads(page);
|
||||
await useUnwrappedDiffLines(page);
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await openChangesPanel(page);
|
||||
|
||||
await expect(page.getByTestId("git-diff-canvas")).toBeVisible();
|
||||
await expect(page.getByTestId("diff-file-0-body")).toHaveCount(0);
|
||||
await releaseBrowserFontLoads(page);
|
||||
await expectExpandedMountedTabDiff(page);
|
||||
});
|
||||
|
||||
test("canvas diff creates, edits, and deletes an inline review without DOM code rows", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await startReviewOnFirstChangedLine(page);
|
||||
await cancelInlineReview(page);
|
||||
await startReviewOnFirstChangedLine(page);
|
||||
await saveInlineReview(page, "Please keep this branch explicit");
|
||||
await editInlineReview(page, "Please keep this branch named explicitly");
|
||||
await deleteInlineReview(page);
|
||||
|
||||
await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("split canvas creates a review on the changed side and keeps it in that column", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await configureDiffPresentation(page, { layout: "split", wrapLines: false });
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
await startReviewOnFirstChangedLine(page, "right");
|
||||
const [editor, body] = await Promise.all([
|
||||
page.getByTestId("inline-review-editor").boundingBox(),
|
||||
page.getByTestId("diff-file-0-body").boundingBox(),
|
||||
]);
|
||||
expect(editor).not.toBeNull();
|
||||
expect(body).not.toBeNull();
|
||||
expect(editor!.x).toBeGreaterThanOrEqual(body!.x + body!.width / 2);
|
||||
await cancelInlineReview(page);
|
||||
});
|
||||
|
||||
test("scrolling clears the hovered review affordance", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await hoverFirstChangedGutter(page);
|
||||
await expect(page.getByRole("button", { name: "Add review comment" })).toBeVisible();
|
||||
await page.getByTestId("git-diff-scroll").evaluate((element) => {
|
||||
element.scrollTop += 80;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
});
|
||||
|
||||
await expect(page.getByRole("button", { name: "Add review comment" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("canvas diff copies a dragged character selection without opening a review", async ({
|
||||
context,
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openSelectionWorkspaceChanges(page, workspace);
|
||||
|
||||
await dragExactAddedText(page, { startOffset: 2, endOffset: 8 });
|
||||
await page.keyboard.press("ControlOrMeta+C");
|
||||
|
||||
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("CDEFGH");
|
||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("canvas diff copies exact multiline text forwards and backwards", async ({
|
||||
context,
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDE\nFGHIJ");
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openSelectionWorkspaceChanges(page, workspace);
|
||||
for (const [start, end] of [
|
||||
[
|
||||
{ line: 0, offset: 2 },
|
||||
{ line: 1, offset: 3 },
|
||||
],
|
||||
[
|
||||
{ line: 1, offset: 3 },
|
||||
{ line: 0, offset: 2 },
|
||||
],
|
||||
] as const) {
|
||||
await dragAddedTextRange(page, { lines: ["ABCDE", "FGHIJ"], start, end });
|
||||
await page.keyboard.press("ControlOrMeta+C");
|
||||
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("CDE\nFGH");
|
||||
}
|
||||
});
|
||||
|
||||
test("canvas diff copies only the selected split side", async ({ context, page }) => {
|
||||
const lines = ["RIGHT-ONE", "RIGHT-TWO"];
|
||||
const workspace = await createWorkspaceWithExactSelectionDiff(lines.join("\n"));
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await configureDiffPresentation(page, { layout: "split", wrapLines: false });
|
||||
await openSelectionWorkspaceChanges(page, workspace);
|
||||
const before = await readSelectionPaintSamples(page, "right");
|
||||
await dragAddedTextRange(page, {
|
||||
lines,
|
||||
side: "right",
|
||||
start: { line: 0, offset: 1 },
|
||||
end: { line: 1, offset: 5 },
|
||||
});
|
||||
await page.keyboard.press("ControlOrMeta+C");
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
|
||||
.toBe("IGHT-ONE\nRIGHT");
|
||||
const after = await readSelectionPaintSamples(page, "right");
|
||||
expect(after.gutter).toEqual(before.gutter);
|
||||
expect(after.opposite).toEqual(before.opposite);
|
||||
expect(after.code).not.toEqual(before.code);
|
||||
});
|
||||
|
||||
test("canvas diff copies exact wrapped fragments", async ({ context, page }) => {
|
||||
const content = "abcdefghijklmnopqrstuvwxyz".repeat(8);
|
||||
const workspace = await createWorkspaceWithExactSelectionDiff(content);
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await configureDiffPresentation(page, { layout: "unified", wrapLines: true });
|
||||
await openSelectionWorkspaceChanges(page, workspace);
|
||||
await dragAddedTextRange(page, {
|
||||
lines: [content],
|
||||
wrapped: true,
|
||||
start: { line: 0, offset: 35 },
|
||||
end: { line: 0, offset: 95 },
|
||||
});
|
||||
await page.keyboard.press("ControlOrMeta+C");
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
|
||||
.toBe(content.slice(35, 95));
|
||||
});
|
||||
|
||||
test("horizontally scrolled selection copies exactly and does not paint the gutter", async ({
|
||||
context,
|
||||
page,
|
||||
}) => {
|
||||
const content = "0123456789".repeat(50);
|
||||
const workspace = await createWorkspaceWithExactSelectionDiff(content);
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openSelectionWorkspaceChanges(page, workspace);
|
||||
const horizontalOffset = await horizontallyScrollFirstFile(page, 320);
|
||||
const before = await readSelectionPaintSamples(page);
|
||||
await dragAddedTextRange(page, {
|
||||
lines: [content],
|
||||
horizontalOffset,
|
||||
start: { line: 0, offset: 48 },
|
||||
end: { line: 0, offset: 58 },
|
||||
});
|
||||
await page.keyboard.press("ControlOrMeta+C");
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
|
||||
.toBe(content.slice(48, 58));
|
||||
const after = await readSelectionPaintSamples(page);
|
||||
expect(after.gutter).toEqual(before.gutter);
|
||||
expect(after.code).not.toEqual(before.code);
|
||||
});
|
||||
|
||||
test("dragging within one wide grapheme or outside its cell never opens a review", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await createWorkspaceWithExactSelectionDiff("👨👩👧👦tail");
|
||||
await useCodeFont(page, 40);
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openSelectionWorkspaceChanges(page, workspace);
|
||||
|
||||
await dragWithinFirstAddedGrapheme(page);
|
||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||
|
||||
await dragFirstAddedLineIntoHeader(page);
|
||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||
|
||||
const body = await page.getByTestId("diff-file-0-body").boundingBox();
|
||||
if (!body) throw new Error("Expanded diff body has no bounds");
|
||||
await page.mouse.click(body.x + 60, body.y + 90, { button: "right" });
|
||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("collapsing and expanding restores the real horizontal scroll offset", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithExactSelectionDiff("x".repeat(400));
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openSelectionWorkspaceChanges(page, workspace);
|
||||
|
||||
const retainedOffset = await horizontallyScrollFirstFile(page, 320);
|
||||
await page.getByTestId("diff-file-0-toggle").click();
|
||||
await expect(page.getByTestId("diff-file-0-horizontal-scroll")).toHaveCount(0);
|
||||
await page.getByTestId("diff-file-0-toggle").click();
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.getByTestId("diff-file-0-horizontal-scroll").evaluate((element) => element.scrollLeft),
|
||||
)
|
||||
.toBe(retainedOffset);
|
||||
});
|
||||
|
||||
async function useCodeFont(page: Page, codeFontSize: number): Promise<void> {
|
||||
@@ -647,22 +888,60 @@ async function useCodeFont(page: Page, codeFontSize: number): Promise<void> {
|
||||
}
|
||||
|
||||
async function useUnwrappedDiffLines(page: Page): Promise<void> {
|
||||
await configureDiffPresentation(page, { layout: "unified", wrapLines: false });
|
||||
}
|
||||
|
||||
async function configureDiffPresentation(
|
||||
page: Page,
|
||||
requestedPresentation: { layout: "unified" | "split"; wrapLines: boolean },
|
||||
): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ preferencesKey }) => {
|
||||
({ preferencesKey, presentation }) => {
|
||||
localStorage.setItem(
|
||||
preferencesKey,
|
||||
JSON.stringify({
|
||||
layout: "unified",
|
||||
layout: presentation.layout,
|
||||
desktopTreeVisible: false,
|
||||
wrapLines: false,
|
||||
wrapLines: presentation.wrapLines,
|
||||
hideWhitespace: false,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ preferencesKey: CHANGES_PREFERENCES_KEY },
|
||||
{ preferencesKey: CHANGES_PREFERENCES_KEY, presentation: requestedPresentation },
|
||||
);
|
||||
}
|
||||
|
||||
async function holdBrowserFontLoads(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const fontSet = document.fonts;
|
||||
const originalLoad = fontSet.load.bind(fontSet);
|
||||
const pending: Array<() => void> = [];
|
||||
Object.defineProperty(fontSet, "load", {
|
||||
configurable: true,
|
||||
value(font: string, text?: string) {
|
||||
return new Promise<FontFace[]>((resolve, reject) => {
|
||||
pending.push(() => {
|
||||
originalLoad(font, text).then(resolve, reject);
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
Object.assign(window, {
|
||||
__releasePaseoDiffFontLoads() {
|
||||
for (const release of pending.splice(0)) release();
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function releaseBrowserFontLoads(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
(
|
||||
window as typeof window & { __releasePaseoDiffFontLoads: () => void }
|
||||
).__releasePaseoDiffFontLoads();
|
||||
});
|
||||
}
|
||||
|
||||
async function expectFlatFileList(page: Page): Promise<void> {
|
||||
await expect(page.locator('[data-testid^="diff-folder-"]')).toHaveCount(0);
|
||||
await expect(page.getByTestId("diff-file-0")).toContainText("use-mounted-tab-set.ts");
|
||||
@@ -670,79 +949,27 @@ async function expectFlatFileList(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<void> {
|
||||
const visibleCodeText = page
|
||||
.getByTestId(/^diff-code-text-/)
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
const canvas = page.getByTestId("git-diff-canvas");
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return visibleCodeText.evaluate((text) => Number.parseFloat(getComputedStyle(text).fontSize));
|
||||
return canvas.evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize));
|
||||
})
|
||||
.toBe(fontSize);
|
||||
}
|
||||
|
||||
async function expectVisibleDiffRowsShareTypography(page: Page): Promise<void> {
|
||||
const geometry = await readVisibleDiffRowGeometry(page);
|
||||
expect(geometry.mismatchedTypography, JSON.stringify(geometry, null, 2)).toEqual([]);
|
||||
async function expectDiffCodeFontFamily(page: Page, fontFamily: string): Promise<void> {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page
|
||||
.getByTestId("git-diff-canvas")
|
||||
.evaluate((element) => getComputedStyle(element).fontFamily),
|
||||
)
|
||||
.toContain(fontFamily);
|
||||
}
|
||||
|
||||
async function readVisibleDiffRowGeometry(page: Page): Promise<{
|
||||
mismatchedTypography: { index: number; gutterLineHeight: number; codeLineHeight: number }[];
|
||||
rows: {
|
||||
index: number;
|
||||
gutterTop: number;
|
||||
codeTop: number;
|
||||
gutterLineHeight: number;
|
||||
codeLineHeight: number;
|
||||
}[];
|
||||
}> {
|
||||
return page.locator("body").evaluate(({ ownerDocument }) => {
|
||||
const root = ownerDocument.querySelector('[data-testid="diff-file-0-body"]');
|
||||
if (!root) {
|
||||
throw new Error("Expanded diff body is not mounted");
|
||||
}
|
||||
|
||||
const readRows = (prefix: string, textPrefix: string) =>
|
||||
Array.from(root.querySelectorAll<HTMLElement>(`[data-testid^="${prefix}"]`)).map((row) => {
|
||||
const testId = row.getAttribute("data-testid") ?? "";
|
||||
const index = Number(testId.slice(prefix.length));
|
||||
const rect = row.getBoundingClientRect();
|
||||
const text = root.querySelector<HTMLElement>(`[data-testid="${textPrefix}${index}"]`);
|
||||
const lineHeight = text ? Number.parseFloat(getComputedStyle(text).lineHeight) : 0;
|
||||
return { index, top: rect.top, height: rect.height, lineHeight };
|
||||
});
|
||||
|
||||
const gutters = new Map(
|
||||
readRows("diff-gutter-row-", "diff-gutter-text-").map((row) => [row.index, row]),
|
||||
);
|
||||
const codes = readRows("diff-code-row-", "diff-code-text-");
|
||||
const rows = codes
|
||||
.map((code) => {
|
||||
const gutter = gutters.get(code.index);
|
||||
if (!gutter) {
|
||||
throw new Error(`Missing gutter row ${code.index}`);
|
||||
}
|
||||
return {
|
||||
index: code.index,
|
||||
gutterTop: gutter.top,
|
||||
codeTop: code.top,
|
||||
gutterLineHeight: gutter.lineHeight,
|
||||
codeLineHeight: code.lineHeight,
|
||||
};
|
||||
})
|
||||
.filter((row) => row.gutterTop >= 0 && row.codeTop >= 0);
|
||||
|
||||
return {
|
||||
mismatchedTypography: rows
|
||||
.filter((row) => Math.abs(row.gutterLineHeight - row.codeLineHeight) > 0.5)
|
||||
.map((row) => ({
|
||||
index: row.index,
|
||||
gutterLineHeight: row.gutterLineHeight,
|
||||
codeLineHeight: row.codeLineHeight,
|
||||
})),
|
||||
rows,
|
||||
};
|
||||
});
|
||||
async function expectVisibleDiffRowsShareTypography(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("git-diff-canvas")).toBeVisible();
|
||||
await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function createWorkspaceWithMountedTabDiff(
|
||||
@@ -798,12 +1025,37 @@ async function createWorkspaceWithMountedTabDiff(
|
||||
return { id: createdWorkspace.workspace.id, repoPath: repo.path };
|
||||
}
|
||||
|
||||
async function createWorkspaceWithExactSelectionDiff(content: string): Promise<DirtyWorkspace> {
|
||||
const repo = await createTempGitRepo("changes-canvas-selection-", {
|
||||
files: [{ path: "src/selection.ts", content: "" }],
|
||||
});
|
||||
await writeFile(path.join(repo.path, "src/selection.ts"), `${content}\n`);
|
||||
const client = await connectSeedClient();
|
||||
cleanupTasks.push({
|
||||
run: async () => {
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup().catch(() => undefined);
|
||||
},
|
||||
});
|
||||
const created = await client.createWorkspace({ source: { kind: "directory", path: repo.path } });
|
||||
if (!created.workspace) throw new Error(created.error ?? "Failed to create selection workspace");
|
||||
return { id: created.workspace.id, repoPath: repo.path };
|
||||
}
|
||||
|
||||
async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await openChangesInVisibleExplorer(page);
|
||||
await page.getByTestId("diff-file-0").click();
|
||||
await expectExpandedMountedTabDiff(page);
|
||||
}
|
||||
|
||||
async function openSelectionWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await openChangesPanel(page);
|
||||
await expect(page.getByText("selection.ts")).toBeVisible({ timeout: 30_000 });
|
||||
await expectExpandedMountedTabDiff(page);
|
||||
}
|
||||
|
||||
@@ -814,19 +1066,23 @@ async function openChangesInVisibleExplorer(page: Page): Promise<void> {
|
||||
|
||||
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText("function createInitialMountedTabIds")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function changeCodeFontSizeFromSettings(page: Page, codeFontSize: number): Promise<void> {
|
||||
async function changeCodeTypographyFromSettings(
|
||||
page: Page,
|
||||
typography: { fontSize: number; fontFamily: string },
|
||||
): Promise<void> {
|
||||
await page.getByTestId("sidebar-settings").click();
|
||||
await expect(page).toHaveURL(new RegExp(`${buildSettingsSectionRoute("general")}|/settings$`));
|
||||
await page.getByRole("button", { name: "Appearance" }).click();
|
||||
await page.getByLabel("Code font size").fill(String(codeFontSize));
|
||||
await page.getByLabel("Code font family").fill(typography.fontFamily);
|
||||
await page.getByLabel("Code font family").press("Enter");
|
||||
await page.getByLabel("Code font size").fill(String(typography.fontSize));
|
||||
await page.getByLabel("Code font size").press("Enter");
|
||||
await expect(page.getByLabel("Code font size")).toHaveValue(String(codeFontSize));
|
||||
await expectStoredCodeFontSize(page, codeFontSize);
|
||||
await expect(page.getByLabel("Code font family")).toHaveValue(typography.fontFamily);
|
||||
await expect(page.getByLabel("Code font size")).toHaveValue(String(typography.fontSize));
|
||||
await expectStoredCodeFontSize(page, typography.fontSize);
|
||||
}
|
||||
|
||||
async function expectStoredCodeFontSize(page: Page, codeFontSize: number): Promise<void> {
|
||||
@@ -844,6 +1100,245 @@ async function expectStoredCodeFontSize(page: Page, codeFontSize: number): Promi
|
||||
.toBe(codeFontSize);
|
||||
}
|
||||
|
||||
async function startReviewOnFirstChangedLine(
|
||||
page: Page,
|
||||
side: "unified" | "right" = "unified",
|
||||
): Promise<void> {
|
||||
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 columnLeft = side === "right" ? bodyBounds.x + bodyBounds.width / 2 : bodyBounds.x;
|
||||
await page.mouse.click(columnLeft + 20, bodyBounds.y + lineHeight * 1.5);
|
||||
await expect(page.getByTestId("inline-review-editor")).toBeVisible();
|
||||
}
|
||||
|
||||
async function hoverFirstChangedGutter(page: Page): Promise<void> {
|
||||
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 gutterWidth = 2 * Math.ceil(fontSize * 0.62) + 12;
|
||||
await page.mouse.move(bodyBounds.x + gutterWidth, bodyBounds.y + lineHeight * 1.5);
|
||||
}
|
||||
|
||||
async function saveInlineReview(page: Page, body: string): Promise<void> {
|
||||
await page.getByTestId("inline-review-editor-input").fill(body);
|
||||
await page.getByTestId("inline-review-editor-save").click();
|
||||
await expect(page.getByText(body, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function cancelInlineReview(page: Page): Promise<void> {
|
||||
await page.getByTestId("inline-review-editor-cancel").click();
|
||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function editInlineReview(page: Page, body: string): Promise<void> {
|
||||
await page.getByTestId(/^review-comment-edit-/).click();
|
||||
await expect(page.getByTestId("inline-review-editor")).toBeVisible();
|
||||
await page.getByTestId("inline-review-editor-input").fill(body);
|
||||
await page.getByTestId("inline-review-editor-save").click();
|
||||
await expect(page.getByText(body, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function deleteInlineReview(page: Page): Promise<void> {
|
||||
await page.getByTestId(/^review-comment-delete-/).click();
|
||||
await expect(page.getByTestId(/^review-comment-delete-/)).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function dragExactAddedText(
|
||||
page: Page,
|
||||
offsets: { startOffset: number; endOffset: number },
|
||||
): Promise<void> {
|
||||
const body = page.getByTestId("diff-file-0-body");
|
||||
const canvas = page.getByTestId("git-diff-canvas");
|
||||
const [bodyBounds, metrics] = await Promise.all([
|
||||
body.boundingBox(),
|
||||
canvas.evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
const fontSize = Number.parseFloat(style.fontSize);
|
||||
const measurementCanvas = document.createElement("canvas");
|
||||
const context = measurementCanvas.getContext("2d")!;
|
||||
context.font = `${fontSize}px ${style.fontFamily}`;
|
||||
return { fontSize, characterWidth: context.measureText("A").width };
|
||||
}),
|
||||
]);
|
||||
if (!bodyBounds) throw new Error("Expanded diff body has no bounds");
|
||||
const lineHeight = Math.round(metrics.fontSize * 1.5);
|
||||
const gutterWidth = Math.max(2, String(1).length) * Math.ceil(metrics.fontSize * 0.62) + 12;
|
||||
const textLeft = bodyBounds.x + gutterWidth + 8;
|
||||
await page.mouse.move(
|
||||
textLeft + offsets.startOffset * metrics.characterWidth + 1,
|
||||
bodyBounds.y + lineHeight * 1.5,
|
||||
);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(
|
||||
textLeft + offsets.endOffset * metrics.characterWidth - 1,
|
||||
bodyBounds.y + lineHeight * 1.5,
|
||||
{ steps: 8 },
|
||||
);
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
async function dragAddedTextRange(
|
||||
page: Page,
|
||||
input: {
|
||||
lines: string[];
|
||||
start: { line: number; offset: number };
|
||||
end: { line: number; offset: number };
|
||||
side?: "left" | "right";
|
||||
wrapped?: boolean;
|
||||
horizontalOffset?: number;
|
||||
},
|
||||
): Promise<void> {
|
||||
const body = page.getByTestId("diff-file-0-body");
|
||||
const canvas = page.getByTestId("git-diff-canvas");
|
||||
const [bounds, metrics] = await Promise.all([
|
||||
body.boundingBox(),
|
||||
canvas.evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
const fontSize = Number.parseFloat(style.fontSize);
|
||||
const context = document.createElement("canvas").getContext("2d")!;
|
||||
context.font = `${fontSize}px ${style.fontFamily}`;
|
||||
return { fontSize, characterWidth: context.measureText("A").width };
|
||||
}),
|
||||
]);
|
||||
if (!bounds) throw new Error("Expanded diff body has no bounds");
|
||||
const lineHeight = Math.round(metrics.fontSize * 1.5);
|
||||
const gutterWidth = 2 * Math.ceil(metrics.fontSize * 0.62) + 12;
|
||||
const columnWidth = input.side ? bounds.width / 2 : bounds.width;
|
||||
const columnLeft = input.side === "right" ? bounds.x + columnWidth : bounds.x;
|
||||
const availableWidth = columnWidth - gutterWidth - 16;
|
||||
const charactersPerFragment = Math.max(1, Math.floor(availableWidth / metrics.characterWidth));
|
||||
const fragmentsBefore = (line: number) =>
|
||||
input.wrapped
|
||||
? input.lines
|
||||
.slice(0, line)
|
||||
.reduce(
|
||||
(total, text) => total + Math.max(1, Math.ceil(text.length / charactersPerFragment)),
|
||||
0,
|
||||
)
|
||||
: line;
|
||||
const point = ({ line, offset }: { line: number; offset: number }) => {
|
||||
const fragment = input.wrapped ? Math.floor(offset / charactersPerFragment) : 0;
|
||||
const localOffset = input.wrapped ? offset % charactersPerFragment : offset;
|
||||
return {
|
||||
x:
|
||||
columnLeft +
|
||||
gutterWidth +
|
||||
8 +
|
||||
localOffset * metrics.characterWidth -
|
||||
(input.horizontalOffset ?? 0),
|
||||
y: bounds.y + (1 + fragmentsBefore(line) + fragment + 0.5) * lineHeight,
|
||||
};
|
||||
};
|
||||
const start = point(input.start);
|
||||
const end = point(input.end);
|
||||
await page.mouse.move(start.x, start.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(end.x, end.y, { steps: 12 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
async function readSelectionPaintSamples(
|
||||
page: Page,
|
||||
side: "unified" | "right" = "unified",
|
||||
): Promise<{ gutter: number[]; code: number[]; opposite: number[] }> {
|
||||
return page.getByTestId("diff-file-0-body").evaluate((body, selectedSide) => {
|
||||
const canvas = document.querySelector<HTMLCanvasElement>('[data-testid="git-diff-canvas"]')!;
|
||||
const bodyBounds = body.getBoundingClientRect();
|
||||
const canvasBounds = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.width / canvasBounds.width;
|
||||
const scaleY = canvas.height / canvasBounds.height;
|
||||
const context = canvas.getContext("2d")!;
|
||||
const sample = (left: number, top: number, width: number, height: number) =>
|
||||
Array.from(
|
||||
context.getImageData(
|
||||
Math.round((left - canvasBounds.left) * scaleX),
|
||||
Math.round((top - canvasBounds.top) * scaleY),
|
||||
Math.max(1, Math.round(width * scaleX)),
|
||||
Math.max(1, Math.round(height * scaleY)),
|
||||
).data,
|
||||
);
|
||||
const columnLeft =
|
||||
selectedSide === "right" ? bodyBounds.left + bodyBounds.width / 2 : bodyBounds.left;
|
||||
return {
|
||||
gutter: sample(columnLeft + 2, bodyBounds.top + 24, 8, 8),
|
||||
code: sample(columnLeft + 80, bodyBounds.top + 24, 80, 10),
|
||||
opposite: sample(bodyBounds.left + 80, bodyBounds.top + 24, 80, 10),
|
||||
};
|
||||
}, side);
|
||||
}
|
||||
|
||||
async function dragWithinFirstAddedGrapheme(page: Page): Promise<void> {
|
||||
const bodyBounds = await page.getByTestId("diff-file-0-body").boundingBox();
|
||||
const fontSize = await page
|
||||
.getByTestId("git-diff-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 gutterWidth = 2 * Math.ceil(fontSize * 0.62) + 12;
|
||||
const x = bodyBounds.x + gutterWidth + 10;
|
||||
const y = bodyBounds.y + lineHeight * 1.5;
|
||||
await page.mouse.move(x, y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(x + 5, y, { steps: 3 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
async function dragFirstAddedLineIntoHeader(page: Page): Promise<void> {
|
||||
const bodyBounds = await page.getByTestId("diff-file-0-body").boundingBox();
|
||||
const fontSize = await page
|
||||
.getByTestId("git-diff-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 x = bodyBounds.x + 60;
|
||||
await page.mouse.move(x, bodyBounds.y + lineHeight * 1.5);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(x, bodyBounds.y - 10, { steps: 4 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
async function horizontallyScrollFirstFile(page: Page, requestedOffset: number): Promise<number> {
|
||||
const horizontalScroll = page.getByTestId("diff-file-0-horizontal-scroll");
|
||||
const retainedOffset = await horizontalScroll.evaluate((element, offset) => {
|
||||
element.scrollLeft = offset;
|
||||
element.dispatchEvent(new Event("scroll"));
|
||||
return element.scrollLeft;
|
||||
}, requestedOffset);
|
||||
expect(retainedOffset).toBeGreaterThan(0);
|
||||
return retainedOffset;
|
||||
}
|
||||
|
||||
async function longPressFileHeader(page: Page, header: Locator): Promise<void> {
|
||||
const bounds = await header.boundingBox();
|
||||
if (!bounds) throw new Error("File header has no bounds");
|
||||
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.waitForTimeout(600);
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
async function readDiffTypographyGeometry(page: Page): Promise<{
|
||||
horizontalExtent: number;
|
||||
canvasPixels: string;
|
||||
}> {
|
||||
const horizontalExtent = await page
|
||||
.getByTestId("diff-file-0-horizontal-scroll")
|
||||
.evaluate((element) => element.scrollWidth);
|
||||
const canvasPixels = (await page.getByTestId("git-diff-canvas").screenshot()).toString("base64");
|
||||
return { horizontalExtent, canvasPixels };
|
||||
}
|
||||
|
||||
async function returnToWorkspaceChanges(page: Page): Promise<void> {
|
||||
await page.getByTestId("settings-back-to-workspace").click();
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
@@ -852,15 +1347,9 @@ async function returnToWorkspaceChanges(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
async function scrollToLowerUnwrappedDiffRows(page: Page): Promise<void> {
|
||||
const lastRowIndex = await page.getByTestId("diff-file-0-body").evaluate((root) => {
|
||||
const rows = Array.from(root.querySelectorAll<HTMLElement>('[data-testid^="diff-code-row-"]'));
|
||||
if (rows.length === 0) {
|
||||
throw new Error("No unwrapped code rows are mounted");
|
||||
}
|
||||
return Math.max(
|
||||
...rows.map((row) => Number((row.getAttribute("data-testid") ?? "").slice(14))),
|
||||
);
|
||||
await page.getByTestId("git-diff-scroll").evaluate((element) => {
|
||||
element.scrollTop = element.scrollHeight - element.clientHeight;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
});
|
||||
await page.getByTestId(`diff-code-row-${lastRowIndex}`).scrollIntoViewIfNeeded();
|
||||
await expect(page.getByTestId(`diff-code-row-${lastRowIndex}`)).toBeVisible();
|
||||
await expect(page.getByTestId("git-diff-canvas")).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { openChangesPanel } from "../support/helpers/workspace-tabs";
|
||||
|
||||
@@ -51,11 +52,12 @@ test("commit history shows dates and shares diff layout preferences", async ({
|
||||
await expect(panel.getByTestId("commit-diff-toolbar")).toBeVisible({ timeout: 30_000 });
|
||||
const layoutToggle = panel.getByTestId("commit-diff-toggle-layout");
|
||||
await expect(layoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
|
||||
await expect(panel.getByTestId("diff-code-row-0")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(panel.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
|
||||
await expectCommitDiffHeaderGeometry(panel);
|
||||
|
||||
await layoutToggle.click();
|
||||
await expect(layoutToggle).toHaveAccessibleName("Switch to unified diff");
|
||||
await expect(panel.getByTestId("diff-code-row-0")).toHaveCount(0);
|
||||
await expect(panel.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0);
|
||||
await expect(panel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
|
||||
await page.getByTestId(/^workspace-tab-commit_diff_/).hover();
|
||||
@@ -69,7 +71,7 @@ test("commit history shows dates and shares diff layout preferences", async ({
|
||||
|
||||
await page.setViewportSize({ width: 480, height: 900 });
|
||||
await expect(panel.getByTestId("commit-diff-toolbar")).toHaveCount(0);
|
||||
await expect(panel.getByTestId("diff-code-row-0")).toBeVisible();
|
||||
await expect(panel.getByTestId("git-diff-canvas")).toBeVisible();
|
||||
});
|
||||
|
||||
async function createFeatureCommit(repoPath: string): Promise<void> {
|
||||
@@ -86,3 +88,25 @@ async function createFeatureCommit(repoPath: string): Promise<void> {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function expectCommitDiffHeaderGeometry(panel: Locator): Promise<void> {
|
||||
const [header, content, name, stat] = await Promise.all([
|
||||
panel.getByTestId("diff-file-0").boundingBox(),
|
||||
panel.getByTestId("diff-file-0-header-content").boundingBox(),
|
||||
panel.getByTestId("diff-file-0-name").boundingBox(),
|
||||
panel.getByTestId("diff-file-0-stat").boundingBox(),
|
||||
]);
|
||||
expect(header).not.toBeNull();
|
||||
expect(content).not.toBeNull();
|
||||
expect(name).not.toBeNull();
|
||||
expect(stat).not.toBeNull();
|
||||
expect(header!.height).toBeCloseTo(44, 0);
|
||||
expect(content!.x).toBeCloseTo(header!.x, 0);
|
||||
expect(content!.width).toBeCloseTo(header!.width, 0);
|
||||
expect(name!.x).toBeGreaterThan(header!.x);
|
||||
expect(name!.x + name!.width).toBeLessThan(stat!.x);
|
||||
expect(Math.abs(name!.y + name!.height / 2 - (stat!.y + stat!.height / 2))).toBeLessThanOrEqual(
|
||||
1,
|
||||
);
|
||||
expect(stat!.x + stat!.width).toBeLessThan(header!.x + header!.width);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Successful Canvas2D POC run at 6× Chromium CPU slowdown on the checked-in fixture. */
|
||||
export const DIFF_POC_BASELINE = {
|
||||
medianFrameMs: 25,
|
||||
p95FrameMs: 50,
|
||||
} as const;
|
||||
@@ -1,41 +1,38 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { CDPSession } from "@playwright/test";
|
||||
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 { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
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 = 2_400;
|
||||
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 MAX_MOUNTED_CODE_ROWS = 220;
|
||||
const MAX_EXPANSION_MS = 6_000;
|
||||
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 rendering performance", () => {
|
||||
test("large unwrapped diffs keep mounting bounded under CPU slowdown", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(90_000);
|
||||
const repo = await createTempGitRepo("diff-perf-", {
|
||||
files: [{ path: "src/large.ts", content: "export const baseline = 0;\n" }],
|
||||
});
|
||||
diffPerfDescribe("Diff canvas performance", () => {
|
||||
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 generatedLines = Array.from(
|
||||
{ length: LINE_COUNT },
|
||||
(_, index) => `export const generated_${index} = "${index}-${"wide".repeat(16)}";`,
|
||||
).join("\n");
|
||||
await writeFile(
|
||||
path.join(repo.path, "src/large.ts"),
|
||||
`export const baseline = 1;\n${generatedLines}\n`,
|
||||
);
|
||||
const created = await client.createWorkspace({
|
||||
source: { kind: "directory", path: repo.path },
|
||||
});
|
||||
@@ -43,100 +40,89 @@ diffPerfDescribe("Diff rendering performance", () => {
|
||||
throw new Error(created.error ?? "Failed to create diff performance workspace");
|
||||
}
|
||||
|
||||
await page.addInitScript(
|
||||
({ preferencesKey }) => {
|
||||
localStorage.setItem(
|
||||
preferencesKey,
|
||||
JSON.stringify({
|
||||
layout: "unified",
|
||||
viewMode: "flat",
|
||||
wrapLines: false,
|
||||
hideWhitespace: false,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ preferencesKey: CHANGES_PREFERENCES_KEY },
|
||||
);
|
||||
await configureUnwrappedUnifiedDiff(page);
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), created.workspace.id));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await page.getByRole("button", { name: "Open explorer" }).click();
|
||||
const fileHeader = page.getByTestId("diff-file-0");
|
||||
await expect(fileHeader).toContainText("large.ts", { timeout: 30_000 });
|
||||
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 });
|
||||
const expansionStart = await page.evaluate(() => performance.now());
|
||||
await fileHeader.click();
|
||||
await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("diff-code-row-0")).toBeVisible({ timeout: 30_000 });
|
||||
const expansionMs = (await page.evaluate(() => performance.now())) - expansionStart;
|
||||
await startChromiumTrace(cdp);
|
||||
traceStarted = true;
|
||||
await startRendererInstrumentation(page);
|
||||
|
||||
const scroll = page.getByTestId("git-diff-scroll");
|
||||
const initial = await readDiffMountReport(page);
|
||||
expect(initial.mountedCodeRows).toBeLessThanOrEqual(MAX_MOUNTED_CODE_ROWS);
|
||||
expect(initial.mountedGutterRows).toBe(initial.mountedCodeRows);
|
||||
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);
|
||||
await expectFramesMatchStableRender(page, frameImages);
|
||||
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 page.getByTestId("diff-gutter-row-1").hover();
|
||||
await page.getByTestId("diff-gutter-action-1").click();
|
||||
await expect(page.getByTestId("inline-review-editor")).toBeVisible();
|
||||
const withEditor = await readDiffMountReport(page);
|
||||
expect(withEditor.mountedCodeRows).toBeLessThanOrEqual(MAX_MOUNTED_CODE_ROWS);
|
||||
expect(withEditor.mountedGutterRows).toBe(withEditor.mountedCodeRows);
|
||||
expect(await readDiffRowAlignment(page, 2)).toBeCloseTo(0, 0);
|
||||
await expect(page.getByTestId("inline-review-editor-input")).toBeFocused();
|
||||
await page.getByTestId("inline-review-editor-cancel").click();
|
||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||
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 scroll.evaluate((element) => {
|
||||
element.scrollTop = element.scrollHeight - element.clientHeight;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
});
|
||||
await expect
|
||||
.poll(async () => (await readDiffMountReport(page)).highestMountedRow)
|
||||
.toBeGreaterThan(LINE_COUNT - 100);
|
||||
const afterScroll = await readDiffMountReport(page);
|
||||
expect(afterScroll.mountedCodeRows).toBeLessThanOrEqual(MAX_MOUNTED_CODE_ROWS);
|
||||
expect(afterScroll.mountedGutterRows).toBe(afterScroll.mountedCodeRows);
|
||||
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,
|
||||
sourceLines: LINE_COUNT,
|
||||
expansionMs: Math.round(expansionMs),
|
||||
initial,
|
||||
withEditor,
|
||||
afterScroll,
|
||||
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 testInfo.attach("diff-performance", {
|
||||
body: JSON.stringify(report, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
console.log(`[perf] Diff rendering: ${JSON.stringify(report)}`);
|
||||
expect(expansionMs).toBeLessThan(MAX_EXPANSION_MS);
|
||||
await attachPerformanceReport(testInfo, report);
|
||||
await stopChromiumTrace(cdp, testInfo);
|
||||
traceStarted = false;
|
||||
|
||||
await cdp.send("Emulation.setCPUThrottlingRate", { rate: 1 });
|
||||
await scroll.evaluate((element) => {
|
||||
element.scrollTop = 0;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
});
|
||||
await page.getByTestId("changes-toggle-layout").click();
|
||||
const oldLine = page.getByText("export const baseline = 0;", { exact: true });
|
||||
const newLine = page.getByText("export const baseline = 1;", { exact: true });
|
||||
await expect(oldLine).toBeVisible();
|
||||
await expect(newLine).toBeVisible();
|
||||
const [bodyBox, oldBox, newBox] = await Promise.all([
|
||||
page.getByTestId("diff-file-0-body").boundingBox(),
|
||||
oldLine.boundingBox(),
|
||||
newLine.boundingBox(),
|
||||
]);
|
||||
expect(bodyBox).not.toBeNull();
|
||||
expect(oldBox).not.toBeNull();
|
||||
expect(newBox).not.toBeNull();
|
||||
expect(oldBox!.x).toBeLessThan(newBox!.x);
|
||||
expect(oldBox!.x).toBeGreaterThanOrEqual(bodyBox!.x);
|
||||
expect(newBox!.x).toBeGreaterThan(bodyBox!.x + bodyBox!.width / 2);
|
||||
expect(newBox!.x).toBeLessThan(bodyBox!.x + bodyBox!.width);
|
||||
} 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);
|
||||
@@ -145,40 +131,392 @@ diffPerfDescribe("Diff rendering performance", () => {
|
||||
});
|
||||
});
|
||||
|
||||
async function readDiffRowAlignment(
|
||||
page: import("@playwright/test").Page,
|
||||
rowIndex: number,
|
||||
): Promise<number> {
|
||||
const gutterTop = await page
|
||||
.getByTestId(`diff-gutter-row-${rowIndex}`)
|
||||
.evaluate((row) => row.getBoundingClientRect().top);
|
||||
const codeTop = await page
|
||||
.getByTestId(`diff-code-row-${rowIndex}`)
|
||||
.evaluate((row) => row.getBoundingClientRect().top);
|
||||
return gutterTop - codeTop;
|
||||
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);
|
||||
await expectFramesMatchStableRender(page, frames);
|
||||
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 readDiffMountReport(page: import("@playwright/test").Page): Promise<{
|
||||
mountedCodeRows: number;
|
||||
mountedGutterRows: number;
|
||||
highestMountedRow: number;
|
||||
bodyDomElements: number;
|
||||
}> {
|
||||
return page.getByTestId("diff-file-0-body").evaluate((body) => {
|
||||
const codeRows = Array.from(
|
||||
body.querySelectorAll<HTMLElement>('[data-testid^="diff-code-row-"]'),
|
||||
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`,
|
||||
);
|
||||
const gutterRows = body.querySelectorAll('[data-testid^="diff-gutter-row-"]');
|
||||
}
|
||||
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 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),
|
||||
});
|
||||
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 {
|
||||
mountedCodeRows: codeRows.length,
|
||||
mountedGutterRows: gutterRows.length,
|
||||
highestMountedRow: Math.max(
|
||||
-1,
|
||||
...codeRows.map((row) =>
|
||||
Number((row.getAttribute("data-testid") ?? "").slice("diff-code-row-".length)),
|
||||
),
|
||||
),
|
||||
bodyDomElements: body.querySelectorAll("*").length,
|
||||
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(canvas: HTMLCanvasElement): number {
|
||||
const sample = document.createElement("canvas");
|
||||
sample.width = 64;
|
||||
sample.height = 36;
|
||||
const context = sample.getContext("2d")!;
|
||||
context.drawImage(canvas, 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 canvas = page.getByTestId("git-diff-canvas");
|
||||
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 canvas.screenshot() });
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
async function expectFramesMatchStableRender(
|
||||
page: Page,
|
||||
frames: CapturedCanvasFrame[],
|
||||
): Promise<void> {
|
||||
const scroll = page.getByTestId("git-diff-scroll");
|
||||
const canvas = page.getByTestId("git-diff-canvas");
|
||||
for (const frame of frames) {
|
||||
await scroll.evaluate(async (element, scrollTop) => {
|
||||
element.scrollTop = scrollTop;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
await new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
);
|
||||
}, frame.scrollTop);
|
||||
expect(await canvas.screenshot()).toEqual(frame.image);
|
||||
}
|
||||
}
|
||||
|
||||
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")!;
|
||||
context.drawImage(canvas, 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);
|
||||
signatures.push(hash >>> 0);
|
||||
}
|
||||
return signatures;
|
||||
}, 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",
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user