Skip to content

Commit 146f569

Browse files
author
npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta
committed
Add desktop GUI latency harness
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
1 parent 86d7748 commit 146f569

6 files changed

Lines changed: 406 additions & 49 deletions

File tree

.github/workflows/ci.yml

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,54 @@ jobs:
432432
fi
433433
echo "Desktop E2E Integration shards passed"
434434
435+
desktop-e2e-perf:
436+
name: Desktop GUI Latency Harness
437+
runs-on: ubuntu-latest
438+
timeout-minutes: 20
439+
needs: [changes]
440+
if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true'
441+
permissions:
442+
contents: read
443+
steps:
444+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
445+
- uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1
446+
- name: Get pnpm store directory
447+
id: pnpm-cache
448+
run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
449+
- name: Restore pnpm store cache
450+
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
451+
with:
452+
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
453+
key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
454+
restore-keys: pnpm-${{ runner.os }}-
455+
- name: Install desktop dependencies
456+
run: just desktop-install-ci
457+
- name: Get Playwright version
458+
id: pw-version
459+
run: echo "version=$(cd desktop && node -e "console.log(require('@playwright/test/package.json').version)")" >> "$GITHUB_OUTPUT"
460+
- name: Restore Playwright browser cache
461+
id: playwright-cache
462+
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
463+
with:
464+
path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }}
465+
key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }}
466+
- name: Install Playwright Chromium
467+
if: steps.playwright-cache.outputs.cache-hit != 'true'
468+
run: cd desktop && pnpm exec playwright install chromium
469+
- name: Install Playwright system dependencies
470+
run: cd desktop && pnpm exec playwright install-deps chromium
471+
- name: Desktop GUI latency harness
472+
run: just desktop-e2e-perf
473+
- name: Upload desktop latency artifacts
474+
if: failure()
475+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
476+
with:
477+
name: desktop-gui-latency-artifacts
478+
path: |
479+
desktop/playwright-report
480+
desktop/test-results
481+
if-no-files-found: ignore
482+
435483
backend-integration:
436484
name: Backend Integration (relay e2e)
437485
runs-on: ubuntu-latest

Justfile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,10 @@ desktop-e2e-smoke:
216216
desktop-e2e-integration: _ensure-migrations
217217
cd {{desktop_dir}} && pnpm test:e2e:integration
218218

219+
# Run desktop read-only GUI latency harness against the mock bridge
220+
desktop-e2e-perf:
221+
cd {{desktop_dir}} && pnpm test:e2e:perf
222+
219223
# Run all checks suitable for CI / pre-push (no infra needed)
220224
ci: check test-unit desktop-test desktop-build desktop-tauri-check desktop-tauri-test web-build mobile-test
221225

desktop/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"test:e2e": "pnpm build && playwright test",
1919
"test:e2e:smoke": "pnpm build && playwright test --project=smoke",
2020
"test:e2e:integration": "pnpm build && playwright test --project=integration",
21+
"test:e2e:perf": "pnpm build && playwright test --config=playwright.perf.config.ts",
2122
"test:e2e:report": "playwright show-report",
2223
"tauri:build": "tauri build"
2324
},
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
import { expect, test, type Page } from "@playwright/test";
2+
3+
import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge";
4+
import { logMeasurement, measureAction } from "./perf/metrics";
5+
6+
const BUSY_ROWS = 220;
7+
const THREAD_REPLIES = 24;
8+
const TYPING_SAMPLE =
9+
"Drafting a latency repro with @alice, a second paragraph, and enough text to exercise wrapping in the composer.";
10+
11+
type MockMessageEvent = { id: string; created_at: number; pubkey: string };
12+
13+
async function waitForMockHooks(page: Page) {
14+
await page.waitForFunction(
15+
() =>
16+
typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" &&
17+
typeof window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__ === "function",
18+
);
19+
}
20+
21+
async function emitMockMessage(
22+
page: Page,
23+
channelName: string,
24+
content: string,
25+
options?: {
26+
createdAt?: number;
27+
parentEventId?: string;
28+
pubkey?: string;
29+
},
30+
): Promise<MockMessageEvent> {
31+
const event = await page.evaluate(
32+
({ channelName: ch, content: body, createdAt, parentEventId, pubkey }) =>
33+
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
34+
channelName: ch,
35+
content: body,
36+
createdAt,
37+
parentEventId,
38+
pubkey,
39+
}),
40+
{
41+
channelName,
42+
content,
43+
createdAt: options?.createdAt,
44+
parentEventId: options?.parentEventId,
45+
pubkey: options?.pubkey ?? TEST_IDENTITIES.alice.pubkey,
46+
},
47+
);
48+
if (!event) throw new Error("Mock message emitter is not installed");
49+
return event;
50+
}
51+
52+
async function seedBusyChannel(
53+
page: Page,
54+
channelName: string,
55+
rows = BUSY_ROWS,
56+
) {
57+
await page.evaluate(
58+
({ channelName: ch, rows }) => {
59+
for (let i = 0; i < rows; i += 1) {
60+
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
61+
channelName: ch,
62+
content: `latency seed ${ch} row ${i}\nsecond line to exercise wrapping`,
63+
});
64+
}
65+
},
66+
{ channelName, rows },
67+
);
68+
}
69+
70+
async function clickChannel(page: Page, channelName: string) {
71+
const channel = page.getByTestId(`channel-${channelName}`);
72+
await expect(page.getByTestId("app-sidebar")).toBeVisible();
73+
await expect(channel).toBeVisible();
74+
75+
// The sidebar can briefly re-render while mock live messages update unread
76+
// state. Keep setup resilient by retrying the locator, which re-resolves on
77+
// each attempt instead of holding a stale DOM node.
78+
let lastError: unknown;
79+
for (let attempt = 0; attempt < 3; attempt += 1) {
80+
try {
81+
await channel.click({ timeout: 5_000 });
82+
return;
83+
} catch (error) {
84+
lastError = error;
85+
await page.waitForTimeout(100);
86+
await expect(channel).toBeVisible();
87+
}
88+
}
89+
90+
throw lastError;
91+
}
92+
93+
async function openChannel(page: Page, channelName: string) {
94+
await clickChannel(page, channelName);
95+
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
96+
await expect(page.getByTestId("message-row").first()).toBeVisible();
97+
}
98+
99+
async function mountedRowCount(page: Page) {
100+
return page.getByTestId("message-row").count();
101+
}
102+
103+
test.describe("Buzz GUI latency harness", () => {
104+
test("MEASURE: composer typing latency in a busy channel", async ({
105+
page,
106+
}) => {
107+
await installMockBridge(page);
108+
await page.goto("/");
109+
await waitForMockHooks(page);
110+
await seedBusyChannel(page, "general");
111+
await openChannel(page, "general");
112+
113+
const input = page.getByTestId("message-input");
114+
await expect(input).toBeVisible();
115+
116+
const measurement = await measureAction(page, async () => {
117+
await input.click();
118+
await page.keyboard.type(TYPING_SAMPLE, { delay: 0 });
119+
await expect(input).toContainText("latency repro");
120+
return {
121+
chars: TYPING_SAMPLE.length,
122+
rows: await mountedRowCount(page),
123+
};
124+
});
125+
126+
logMeasurement("COMPOSER TYPING LATENCY (busy channel, no send)", {
127+
"chars typed": measurement.result.chars,
128+
"rows mounted": measurement.result.rows,
129+
"wall time": `${measurement.wallMs.toFixed(1)}ms`,
130+
"ms / char": (measurement.wallMs / measurement.result.chars).toFixed(2),
131+
"layout time": `${measurement.metrics.layoutMs.toFixed(1)}ms`,
132+
"style recalc": `${measurement.metrics.recalcMs.toFixed(1)}ms`,
133+
"script time": `${measurement.metrics.scriptMs.toFixed(1)}ms`,
134+
"task time": `${measurement.metrics.taskMs.toFixed(1)}ms`,
135+
"layout count": measurement.metrics.layoutCount,
136+
});
137+
138+
expect(measurement.result.chars).toBeGreaterThan(80);
139+
expect(measurement.result.rows).toBeGreaterThan(50);
140+
expect(measurement.wallMs).toBeGreaterThan(0);
141+
});
142+
143+
test("MEASURE: channel switch latency across seeded busy channels", async ({
144+
page,
145+
}) => {
146+
await installMockBridge(page, { historyDelayMs: 120 });
147+
await page.goto("/");
148+
await waitForMockHooks(page);
149+
await seedBusyChannel(page, "general", 160);
150+
await seedBusyChannel(page, "engineering", 160);
151+
await openChannel(page, "general");
152+
153+
const measurement = await measureAction(page, async () => {
154+
await clickChannel(page, "engineering");
155+
await expect(page.getByTestId("chat-title")).toHaveText("engineering");
156+
await expect(page.getByTestId("message-row").first()).toBeVisible();
157+
return { rows: await mountedRowCount(page) };
158+
});
159+
160+
logMeasurement("CHANNEL SWITCH LATENCY (general → engineering)", {
161+
"rows mounted": measurement.result.rows,
162+
"wall time": `${measurement.wallMs.toFixed(1)}ms`,
163+
"layout time": `${measurement.metrics.layoutMs.toFixed(1)}ms`,
164+
"style recalc": `${measurement.metrics.recalcMs.toFixed(1)}ms`,
165+
"script time": `${measurement.metrics.scriptMs.toFixed(1)}ms`,
166+
"task time": `${measurement.metrics.taskMs.toFixed(1)}ms`,
167+
"layout count": measurement.metrics.layoutCount,
168+
});
169+
170+
expect(measurement.result.rows).toBeGreaterThan(20);
171+
expect(measurement.wallMs).toBeGreaterThan(0);
172+
});
173+
174+
test("MEASURE: thread panel open latency with seeded replies", async ({
175+
page,
176+
}) => {
177+
await installMockBridge(page);
178+
await page.goto("/");
179+
await waitForMockHooks(page);
180+
await openChannel(page, "general");
181+
182+
for (let i = 0; i < THREAD_REPLIES; i += 1) {
183+
await emitMockMessage(page, "general", `thread latency reply ${i + 1}`, {
184+
parentEventId: "mock-general-welcome",
185+
pubkey: TEST_IDENTITIES.alice.pubkey,
186+
});
187+
}
188+
189+
const threadSummary = page.getByTestId("message-thread-summary").first();
190+
await expect(threadSummary).toBeVisible();
191+
192+
const measurement = await measureAction(page, async () => {
193+
await threadSummary.click();
194+
const panel = page.getByTestId("message-thread-panel");
195+
await expect(panel).toBeVisible();
196+
await expect(
197+
panel.getByText("thread latency reply 1", { exact: true }),
198+
).toBeVisible();
199+
return {
200+
replies: await panel.getByTestId("message-row").count(),
201+
};
202+
});
203+
204+
logMeasurement("THREAD OPEN LATENCY (seeded replies)", {
205+
"replies rendered": measurement.result.replies,
206+
"wall time": `${measurement.wallMs.toFixed(1)}ms`,
207+
"layout time": `${measurement.metrics.layoutMs.toFixed(1)}ms`,
208+
"style recalc": `${measurement.metrics.recalcMs.toFixed(1)}ms`,
209+
"script time": `${measurement.metrics.scriptMs.toFixed(1)}ms`,
210+
"task time": `${measurement.metrics.taskMs.toFixed(1)}ms`,
211+
"layout count": measurement.metrics.layoutCount,
212+
});
213+
214+
expect(measurement.result.replies).toBeGreaterThan(5);
215+
expect(measurement.wallMs).toBeGreaterThan(0);
216+
});
217+
218+
test("MEASURE: member search latency in add-people sidebar", async ({
219+
page,
220+
}) => {
221+
await installMockBridge(page, { userSearchDelayMs: 180 });
222+
await page.goto("/");
223+
await waitForMockHooks(page);
224+
await openChannel(page, "general");
225+
await page.getByTestId("channel-members-trigger").click();
226+
await expect(page.getByTestId("members-sidebar")).toBeVisible();
227+
228+
const search = page.getByTestId("channel-management-search-users");
229+
await expect(search).toBeVisible();
230+
231+
const measurement = await measureAction(page, async () => {
232+
await search.fill("outsider");
233+
const result = page
234+
.locator('[data-testid^="channel-user-search-result-"]')
235+
.first();
236+
await expect(result).toBeVisible();
237+
return {
238+
resultText: ((await result.textContent()) ?? "").trim(),
239+
};
240+
});
241+
242+
logMeasurement("ADD-PEOPLE SEARCH LATENCY (read-only)", {
243+
"first result": measurement.result.resultText,
244+
"wall time": `${measurement.wallMs.toFixed(1)}ms`,
245+
"layout time": `${measurement.metrics.layoutMs.toFixed(1)}ms`,
246+
"style recalc": `${measurement.metrics.recalcMs.toFixed(1)}ms`,
247+
"script time": `${measurement.metrics.scriptMs.toFixed(1)}ms`,
248+
"task time": `${measurement.metrics.taskMs.toFixed(1)}ms`,
249+
"layout count": measurement.metrics.layoutCount,
250+
});
251+
252+
expect(measurement.result.resultText.toLowerCase()).toContain("outsider");
253+
expect(measurement.wallMs).toBeGreaterThan(0);
254+
});
255+
});

0 commit comments

Comments
 (0)