From 83cadf0d43fb865f779c078058855ccabe1fdeb5 Mon Sep 17 00:00:00 2001 From: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Date: Wed, 24 Jun 2026 21:35:02 -0400 Subject: [PATCH] Add desktop GUI latency harness Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- .github/workflows/ci.yml | 49 ++++ Justfile | 4 + desktop/package.json | 1 + desktop/tests/e2e/gui-latency.perf.ts | 255 ++++++++++++++++++++ desktop/tests/e2e/perf/metrics.ts | 75 ++++++ desktop/tests/e2e/scroll-smoothness.perf.ts | 72 ++---- 6 files changed, 407 insertions(+), 49 deletions(-) create mode 100644 desktop/tests/e2e/gui-latency.perf.ts create mode 100644 desktop/tests/e2e/perf/metrics.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c2711df61..3636e8c5a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/Justfile b/Justfile index 1fe413f1b..5f41b28bc 100644 --- a/Justfile +++ b/Justfile @@ -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 diff --git a/desktop/package.json b/desktop/package.json index c1fa94f80..dec1385e1 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -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" }, diff --git a/desktop/tests/e2e/gui-latency.perf.ts b/desktop/tests/e2e/gui-latency.perf.ts new file mode 100644 index 000000000..bab637386 --- /dev/null +++ b/desktop/tests/e2e/gui-latency.perf.ts @@ -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 { + 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); + }); +}); diff --git a/desktop/tests/e2e/perf/metrics.ts b/desktop/tests/e2e/perf/metrics.ts new file mode 100644 index 000000000..a9a5f6fb0 --- /dev/null +++ b/desktop/tests/e2e/perf/metrics.ts @@ -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 = { + metrics: BrowserMetrics; + result: T; + wallMs: number; +}; + +export async function readBrowserMetrics( + client: CDPSession, +): Promise { + 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( + page: Page, + action: () => Promise, +): Promise> { + 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((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, +) { + 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 */ +} diff --git a/desktop/tests/e2e/scroll-smoothness.perf.ts b/desktop/tests/e2e/scroll-smoothness.perf.ts index 06fec8bae..6f57da14d 100644 --- a/desktop/tests/e2e/scroll-smoothness.perf.ts +++ b/desktop/tests/e2e/scroll-smoothness.perf.ts @@ -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 { - 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 => { 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);