Add desktop GUI latency harness

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta
2026-06-24 21:42:04 -04:00
co-authored by Tyler Longwell
parent cee2c5f263
commit 83cadf0d43
6 changed files with 407 additions and 49 deletions
+49
View File
@@ -432,6 +432,55 @@ jobs:
fi
echo "Desktop E2E Integration shards passed"
desktop-e2e-perf:
name: Desktop GUI Latency Harness
runs-on: ubuntu-latest
timeout-minutes: 20
continue-on-error: true
needs: [changes]
if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true'
permissions:
contents: read
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1
- name: Get pnpm store directory
id: pnpm-cache
run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: pnpm-${{ runner.os }}-
- name: Install desktop dependencies
run: just desktop-install-ci
- name: Get Playwright version
id: pw-version
run: echo "version=$(cd desktop && node -e "console.log(require('@playwright/test/package.json').version)")" >> "$GITHUB_OUTPUT"
- name: Restore Playwright browser cache
id: playwright-cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }}
key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }}
- name: Install Playwright Chromium
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: cd desktop && pnpm exec playwright install chromium
- name: Install Playwright system dependencies
run: cd desktop && pnpm exec playwright install-deps chromium
- name: Desktop GUI latency harness
run: just desktop-e2e-perf
- name: Upload desktop latency artifacts
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: desktop-gui-latency-artifacts
path: |
desktop/playwright-report
desktop/test-results
if-no-files-found: ignore
backend-integration:
name: Backend Integration (relay e2e)
runs-on: ubuntu-latest
+4
View File
@@ -216,6 +216,10 @@ desktop-e2e-smoke:
desktop-e2e-integration: _ensure-migrations
cd {{desktop_dir}} && pnpm test:e2e:integration
# Run desktop read-only GUI latency harness against the mock bridge
desktop-e2e-perf:
cd {{desktop_dir}} && pnpm test:e2e:perf
# Run all checks suitable for CI / pre-push (no infra needed)
ci: check test-unit desktop-test desktop-build desktop-tauri-check desktop-tauri-test web-build mobile-test
+1
View File
@@ -18,6 +18,7 @@
"test:e2e": "pnpm build && playwright test",
"test:e2e:smoke": "pnpm build && playwright test --project=smoke",
"test:e2e:integration": "pnpm build && playwright test --project=integration",
"test:e2e:perf": "pnpm build && playwright test --config=playwright.perf.config.ts",
"test:e2e:report": "playwright show-report",
"tauri:build": "tauri build"
},
+255
View File
@@ -0,0 +1,255 @@
import { expect, test, type Page } from "@playwright/test";
import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge";
import { logMeasurement, measureAction } from "./perf/metrics";
const BUSY_ROWS = 220;
const THREAD_REPLIES = 24;
const TYPING_SAMPLE =
"Drafting a latency repro with @alice, a second paragraph, and enough text to exercise wrapping in the composer.";
type MockMessageEvent = { id: string; created_at: number; pubkey: string };
async function waitForMockHooks(page: Page) {
await page.waitForFunction(
() =>
typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" &&
typeof window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__ === "function",
);
}
async function emitMockMessage(
page: Page,
channelName: string,
content: string,
options?: {
createdAt?: number;
parentEventId?: string;
pubkey?: string;
},
): Promise<MockMessageEvent> {
const event = await page.evaluate(
({ channelName: ch, content: body, createdAt, parentEventId, pubkey }) =>
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: ch,
content: body,
createdAt,
parentEventId,
pubkey,
}),
{
channelName,
content,
createdAt: options?.createdAt,
parentEventId: options?.parentEventId,
pubkey: options?.pubkey ?? TEST_IDENTITIES.alice.pubkey,
},
);
if (!event) throw new Error("Mock message emitter is not installed");
return event;
}
async function seedBusyChannel(
page: Page,
channelName: string,
rows = BUSY_ROWS,
) {
await page.evaluate(
({ channelName: ch, rows }) => {
for (let i = 0; i < rows; i += 1) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: ch,
content: `latency seed ${ch} row ${i}\nsecond line to exercise wrapping`,
});
}
},
{ channelName, rows },
);
}
async function clickChannel(page: Page, channelName: string) {
const channel = page.getByTestId(`channel-${channelName}`);
await expect(page.getByTestId("app-sidebar")).toBeVisible();
await expect(channel).toBeVisible();
// The sidebar can briefly re-render while mock live messages update unread
// state. Keep setup resilient by retrying the locator, which re-resolves on
// each attempt instead of holding a stale DOM node.
let lastError: unknown;
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
await channel.click({ timeout: 5_000 });
return;
} catch (error) {
lastError = error;
await page.waitForTimeout(100);
await expect(channel).toBeVisible();
}
}
throw lastError;
}
async function openChannel(page: Page, channelName: string) {
await clickChannel(page, channelName);
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
await expect(page.getByTestId("message-row").first()).toBeVisible();
}
async function mountedRowCount(page: Page) {
return page.getByTestId("message-row").count();
}
test.describe("Buzz GUI latency harness", () => {
test("MEASURE: composer typing latency in a busy channel", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await waitForMockHooks(page);
await seedBusyChannel(page, "general");
await openChannel(page, "general");
const input = page.getByTestId("message-input");
await expect(input).toBeVisible();
const measurement = await measureAction(page, async () => {
await input.click();
await page.keyboard.type(TYPING_SAMPLE, { delay: 0 });
await expect(input).toContainText("latency repro");
return {
chars: TYPING_SAMPLE.length,
rows: await mountedRowCount(page),
};
});
logMeasurement("COMPOSER TYPING LATENCY (busy channel, no send)", {
"chars typed": measurement.result.chars,
"rows mounted": measurement.result.rows,
"wall time": `${measurement.wallMs.toFixed(1)}ms`,
"ms / char": (measurement.wallMs / measurement.result.chars).toFixed(2),
"layout time": `${measurement.metrics.layoutMs.toFixed(1)}ms`,
"style recalc": `${measurement.metrics.recalcMs.toFixed(1)}ms`,
"script time": `${measurement.metrics.scriptMs.toFixed(1)}ms`,
"task time": `${measurement.metrics.taskMs.toFixed(1)}ms`,
"layout count": measurement.metrics.layoutCount,
});
expect(measurement.result.chars).toBeGreaterThan(80);
expect(measurement.result.rows).toBeGreaterThan(50);
expect(measurement.wallMs).toBeGreaterThan(0);
});
test("MEASURE: channel switch latency across seeded busy channels", async ({
page,
}) => {
await installMockBridge(page, { historyDelayMs: 120 });
await page.goto("/");
await waitForMockHooks(page);
await seedBusyChannel(page, "general", 160);
await seedBusyChannel(page, "engineering", 160);
await openChannel(page, "general");
const measurement = await measureAction(page, async () => {
await clickChannel(page, "engineering");
await expect(page.getByTestId("chat-title")).toHaveText("engineering");
await expect(page.getByTestId("message-row").first()).toBeVisible();
return { rows: await mountedRowCount(page) };
});
logMeasurement("CHANNEL SWITCH LATENCY (general → engineering)", {
"rows mounted": measurement.result.rows,
"wall time": `${measurement.wallMs.toFixed(1)}ms`,
"layout time": `${measurement.metrics.layoutMs.toFixed(1)}ms`,
"style recalc": `${measurement.metrics.recalcMs.toFixed(1)}ms`,
"script time": `${measurement.metrics.scriptMs.toFixed(1)}ms`,
"task time": `${measurement.metrics.taskMs.toFixed(1)}ms`,
"layout count": measurement.metrics.layoutCount,
});
expect(measurement.result.rows).toBeGreaterThan(20);
expect(measurement.wallMs).toBeGreaterThan(0);
});
test("MEASURE: thread panel open latency with seeded replies", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await waitForMockHooks(page);
await openChannel(page, "general");
for (let i = 0; i < THREAD_REPLIES; i += 1) {
await emitMockMessage(page, "general", `thread latency reply ${i + 1}`, {
parentEventId: "mock-general-welcome",
pubkey: TEST_IDENTITIES.alice.pubkey,
});
}
const threadSummary = page.getByTestId("message-thread-summary").first();
await expect(threadSummary).toBeVisible();
const measurement = await measureAction(page, async () => {
await threadSummary.click();
const panel = page.getByTestId("message-thread-panel");
await expect(panel).toBeVisible();
await expect(
panel.getByText("thread latency reply 1", { exact: true }),
).toBeVisible();
return {
replies: await panel.getByTestId("message-row").count(),
};
});
logMeasurement("THREAD OPEN LATENCY (seeded replies)", {
"replies rendered": measurement.result.replies,
"wall time": `${measurement.wallMs.toFixed(1)}ms`,
"layout time": `${measurement.metrics.layoutMs.toFixed(1)}ms`,
"style recalc": `${measurement.metrics.recalcMs.toFixed(1)}ms`,
"script time": `${measurement.metrics.scriptMs.toFixed(1)}ms`,
"task time": `${measurement.metrics.taskMs.toFixed(1)}ms`,
"layout count": measurement.metrics.layoutCount,
});
expect(measurement.result.replies).toBeGreaterThan(5);
expect(measurement.wallMs).toBeGreaterThan(0);
});
test("MEASURE: member search latency in add-people sidebar", async ({
page,
}) => {
await installMockBridge(page, { userSearchDelayMs: 180 });
await page.goto("/");
await waitForMockHooks(page);
await openChannel(page, "general");
await page.getByTestId("channel-members-trigger").click();
await expect(page.getByTestId("members-sidebar")).toBeVisible();
const search = page.getByTestId("channel-management-search-users");
await expect(search).toBeVisible();
const measurement = await measureAction(page, async () => {
await search.fill("outsider");
const result = page
.locator('[data-testid^="channel-user-search-result-"]')
.first();
await expect(result).toBeVisible();
return {
resultText: ((await result.textContent()) ?? "").trim(),
};
});
logMeasurement("ADD-PEOPLE SEARCH LATENCY (read-only)", {
"first result": measurement.result.resultText,
"wall time": `${measurement.wallMs.toFixed(1)}ms`,
"layout time": `${measurement.metrics.layoutMs.toFixed(1)}ms`,
"style recalc": `${measurement.metrics.recalcMs.toFixed(1)}ms`,
"script time": `${measurement.metrics.scriptMs.toFixed(1)}ms`,
"task time": `${measurement.metrics.taskMs.toFixed(1)}ms`,
"layout count": measurement.metrics.layoutCount,
});
expect(measurement.result.resultText.toLowerCase()).toContain("outsider");
expect(measurement.wallMs).toBeGreaterThan(0);
});
});
+75
View File
@@ -0,0 +1,75 @@
import type { CDPSession, Page } from "@playwright/test";
export type BrowserMetrics = {
layoutMs: number;
recalcMs: number;
layoutCount: number;
scriptMs: number;
taskMs: number;
};
export type ActionMeasurement<T> = {
metrics: BrowserMetrics;
result: T;
wallMs: number;
};
export async function readBrowserMetrics(
client: CDPSession,
): Promise<BrowserMetrics> {
const { metrics } = (await client.send("Performance.getMetrics")) as {
metrics: Array<{ name: string; value: number }>;
};
const m = (name: string) => metrics.find((x) => x.name === name)?.value ?? 0;
return {
// CDP reports durations in seconds; convert to ms.
layoutMs: m("LayoutDuration") * 1000,
recalcMs: m("RecalcStyleDuration") * 1000,
layoutCount: m("LayoutCount"),
scriptMs: m("ScriptDuration") * 1000,
taskMs: m("TaskDuration") * 1000,
};
}
function deltaMetrics(after: BrowserMetrics, before: BrowserMetrics) {
return {
layoutMs: after.layoutMs - before.layoutMs,
recalcMs: after.recalcMs - before.recalcMs,
layoutCount: after.layoutCount - before.layoutCount,
scriptMs: after.scriptMs - before.scriptMs,
taskMs: after.taskMs - before.taskMs,
} satisfies BrowserMetrics;
}
export async function measureAction<T>(
page: Page,
action: () => Promise<T>,
): Promise<ActionMeasurement<T>> {
const client = await page.context().newCDPSession(page);
await client.send("Performance.enable");
const before = await readBrowserMetrics(client);
const start = performance.now();
const result = await action();
await page.evaluate(
() =>
new Promise<void>((resolve) => requestAnimationFrame(() => resolve())),
);
const wallMs = performance.now() - start;
const after = await readBrowserMetrics(client);
await client.send("Performance.disable");
return { metrics: deltaMetrics(after, before), result, wallMs };
}
export function logMeasurement(
title: string,
fields: Record<string, string | number>,
) {
const width = Math.max(...Object.keys(fields).map((key) => key.length));
/* eslint-disable no-console */
console.log(`\n=== ${title} ===`);
for (const [key, value] of Object.entries(fields)) {
console.log(`${key.padEnd(width)}: ${value}`);
}
console.log("========================================\n");
/* eslint-enable no-console */
}
+23 -49
View File
@@ -1,6 +1,7 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import { logMeasurement, readBrowserMetrics } from "./perf/metrics";
/**
* Scroll-smoothness measurement harness.
@@ -38,23 +39,6 @@ type Sample = {
frames: number;
};
type Metrics = { layoutMs: number; recalcMs: number; layoutCount: number };
async function readMetrics(
client: import("@playwright/test").CDPSession,
): Promise<Metrics> {
const { metrics } = (await client.send("Performance.getMetrics")) as {
metrics: Array<{ name: string; value: number }>;
};
const m = (name: string) => metrics.find((x) => x.name === name)?.value ?? 0;
return {
// CDP reports durations in seconds; convert to ms.
layoutMs: m("LayoutDuration") * 1000,
recalcMs: m("RecalcStyleDuration") * 1000,
layoutCount: m("LayoutCount"),
};
}
test("MEASURE: fast-wheel scroll-up layout cost on a busy un-virtualized timeline", async ({
page,
}) => {
@@ -111,7 +95,7 @@ test("MEASURE: fast-wheel scroll-up layout cost on a busy un-virtualized timelin
});
await page.waitForTimeout(100);
const before = await readMetrics(client);
const before = await readBrowserMetrics(client);
const sample = await timeline.evaluate(async (element): Promise<Sample> => {
const el = element as HTMLDivElement;
@@ -154,7 +138,7 @@ test("MEASURE: fast-wheel scroll-up layout cost on a busy un-virtualized timelin
};
});
const after = await readMetrics(client);
const after = await readBrowserMetrics(client);
await client.send("Performance.disable");
const layoutDelta = after.layoutMs - before.layoutMs;
@@ -162,22 +146,16 @@ test("MEASURE: fast-wheel scroll-up layout cost on a busy un-virtualized timelin
const layoutCountDelta = after.layoutCount - before.layoutCount;
const perScroll = sample.frames > 0 ? sample.frames : 1;
/* eslint-disable no-console */
console.log("\n=== SCROLL SMOOTHNESS BASELINE (Chromium layout cost) ===");
console.log(`rows mounted (live DOM): ${sample.rowCount}`);
console.log(
`scroll span covered: ${Math.round(sample.scrollSpan)}px`,
);
console.log(`scroll frames driven: ${sample.frames}`);
console.log(`layout time over burst: ${layoutDelta.toFixed(1)}ms`);
console.log(`style-recalc time over burst: ${recalcDelta.toFixed(1)}ms`);
console.log(`forced layouts (count delta): ${layoutCountDelta}`);
console.log(
`avg layout+recalc per frame: ${((layoutDelta + recalcDelta) / perScroll).toFixed(3)}ms`,
);
console.log("(>~8ms/frame main-thread work is where 120Hz starts to drop)");
console.log("=========================================================\n");
/* eslint-enable no-console */
logMeasurement("SCROLL SMOOTHNESS BASELINE (Chromium layout cost)", {
"rows mounted (live DOM)": sample.rowCount,
"scroll span covered": `${Math.round(sample.scrollSpan)}px`,
"scroll frames driven": sample.frames,
"layout time over burst": `${layoutDelta.toFixed(1)}ms`,
"style-recalc time over burst": `${recalcDelta.toFixed(1)}ms`,
"forced layouts (count delta)": layoutCountDelta,
"avg layout+recalc per frame": `${((layoutDelta + recalcDelta) / perScroll).toFixed(3)}ms`,
note: ">~8ms/frame main-thread work is where 120Hz starts to drop",
});
// Instrument, not a gate — just confirm it exercised the full list.
expect(sample.rowCount).toBeGreaterThan(100);
@@ -257,7 +235,7 @@ test("MEASURE: prepend re-render cost while scrolled up (the untested event-cost
const client = await page.context().newCDPSession(page);
await client.send("Performance.enable");
const before = await readMetrics(client);
const before = await readBrowserMetrics(client);
const scrollTopBefore = await timeline.evaluate(
(el) => (el as HTMLDivElement).scrollTop,
);
@@ -280,7 +258,7 @@ test("MEASURE: prepend re-render cost while scrolled up (the untested event-cost
return performance.now() - t0;
}, 10);
const after = await readMetrics(client);
const after = await readBrowserMetrics(client);
const scrollTopAfter = await timeline.evaluate(
(el) => (el as HTMLDivElement).scrollTop,
);
@@ -293,18 +271,14 @@ test("MEASURE: prepend re-render cost while scrolled up (the untested event-cost
const recalcDelta = after.recalcMs - before.recalcMs;
const anchorDrift = scrollTopAfter - scrollTopBefore;
/* eslint-disable no-console */
console.log("\n=== PREPEND RE-RENDER COST (10 older rows, scrolled up) ===");
console.log(`rows after prepend (live DOM): ${rowCountAfter}`);
console.log(`tick wall-time (commit+layout): ${tickMs.toFixed(2)}ms`);
console.log(`layout time attributed: ${layoutDelta.toFixed(1)}ms`);
console.log(`style-recalc time attributed: ${recalcDelta.toFixed(1)}ms`);
console.log(
`anchor drift (scrollTop delta): ${anchorDrift.toFixed(1)}px (0 = held)`,
);
console.log("(tick > ~8ms during active wheel is where 120Hz stalls)");
console.log("===========================================================\n");
/* eslint-enable no-console */
logMeasurement("PREPEND RE-RENDER COST (10 older rows, scrolled up)", {
"rows after prepend (live DOM)": rowCountAfter,
"tick wall-time (commit+layout)": `${tickMs.toFixed(2)}ms`,
"layout time attributed": `${layoutDelta.toFixed(1)}ms`,
"style-recalc time attributed": `${recalcDelta.toFixed(1)}ms`,
"anchor drift (scrollTop delta)": `${anchorDrift.toFixed(1)}px (0 = held)`,
note: "tick > ~8ms during active wheel is where 120Hz stalls",
});
// Instrument, not a gate — just confirm the prepend actually happened.
expect(rowCountAfter).toBeGreaterThan(50);