From 0cfabee5f74c00d2dc4e974d0fb78b6038f9c448 Mon Sep 17 00:00:00 2001 From: Max Lampert Date: Thu, 20 Aug 2026 13:59:09 -0700 Subject: [PATCH] test(desktop): high-membership channel-switch perf harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an inflateChannelMembers mock-bridge knob (channel name → target member count; synthetic hex-pubkey members appended once, on first channel read) and a member-heavy-switch perf spec that measures warm switch wall time and longtasks for channel↔channel and channel↔Projects at baseline / 2k / 10k members per channel, holding message volume constant at 150 rows. Method mirrors warm-switch-markdown.perf.ts: in-page click + rAF polling, 4x CPU throttle, medians over 16 switches after an untimed warmup round-trip. Run from desktop/: pnpm build:e2e npx playwright test --config=playwright.perf.config.ts member-heavy-switch.perf.ts Signed-off-by: Max Lampert --- desktop/src/testing/e2eBridge.ts | 32 ++ desktop/tests/e2e/member-heavy-switch.perf.ts | 290 ++++++++++++++++++ desktop/tests/helpers/bridge.ts | 2 + 3 files changed, 324 insertions(+) create mode 100644 desktop/tests/e2e/member-heavy-switch.perf.ts diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 30f3b96746..877f3c9a19 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -345,6 +345,10 @@ type E2eConfig = { honorChannelsKnownHash?: boolean; /** Number of seeded rows in the deep-history fixture. Defaults to 600. */ deepHistoryMessageCount?: number; + /** Channel name → target member count. Appends synthetic members until + * each named channel reaches its target; perf specs use this to model + * high-membership channels. Applied once, on first channel read. */ + inflateChannelMembers?: Record; feedReadError?: string; canvasReadError?: string; /** Delay (ms) for `apply_workspace` so e2e tests can observe the @@ -2620,11 +2624,39 @@ function listMockProfiles(): RawProfile[] { .filter((profile): profile is RawProfile => profile !== null); } +let memberInflationApplied = false; + +/** + * One-shot high-membership inflation for perf specs. Reads + * `mock.inflateChannelMembers` (channel name → target member count) and + * appends synthetic hex-pubkey members until each named channel reaches its + * target. Runs lazily on the first channel read so it sees the final config. + */ +function ensureInflatedChannelMembers(): void { + if (memberInflationApplied) return; + const inflation = getConfig()?.mock?.inflateChannelMembers; + if (!inflation) return; + memberInflationApplied = true; + for (const [name, targetCount] of Object.entries(inflation)) { + const channel = mockChannels.find((candidate) => candidate.name === name); + if (!channel) continue; + for (let index = channel.members.length; index < targetCount; index += 1) { + // "ab" prefix + zero-padded hex index: unique, hex-valid, and disjoint + // from every fixture pubkey. + const pubkey = `ab${index.toString(16).padStart(62, "0")}`; + channel.members.push(createMockMember(pubkey, "member", 500)); + } + syncMockChannel(channel); + } +} + function listMockChannels(config?: E2eConfig): RawChannelWithMembership[] { + ensureInflatedChannelMembers(); return mockChannels.map((channel) => toRawChannel(channel, config)); } function getMockChannel(channelId: string): MockChannel { + ensureInflatedChannelMembers(); const channel = mockChannels.find((candidate) => candidate.id === channelId); if (!channel) { throw new Error(`Channel ${channelId} not found.`); diff --git a/desktop/tests/e2e/member-heavy-switch.perf.ts b/desktop/tests/e2e/member-heavy-switch.perf.ts new file mode 100644 index 0000000000..a12720b263 --- /dev/null +++ b/desktop/tests/e2e/member-heavy-switch.perf.ts @@ -0,0 +1,290 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +/** + * High-membership channel-switch benchmark. + * + * Isolates how channel MEMBERSHIP SIZE scales the warm-switch cost, holding + * message volume constant. Every channel object embeds its full + * member-pubkey array, so membership size inflates (a) the get_channels + * payload parsed on every poll, (b) the per-switch get_channel_members + * response, and (c) every render-path pass over `channel.memberPubkeys` and + * the member list (profile merges, agent-flag merges, mention candidates). + * This spec is the instrument for that scaling: same channels, same rows, + * member count is the only variable. + * + * Two scenarios per member count: + * channel<->channel — general <-> deep-history (150 fixed rows). + * channel<->projects — general <-> the Projects overview (preview + * feature). Projects mounts its own query fan + * (project enumeration, work items, repo snapshots, + * activity summaries) on top of the shell, so this + * axis captures the cross-surface switch the felt + * 1-2s report singled out. + * + * Method mirrors warm-switch-markdown.perf.ts: in-page click + rAF polling + * (CDP latency never pollutes samples), longtask capture per switch, 4x CPU + * throttle, medians over repeated switches, untimed warmup round-trip first. + * `deep-history` is pinned to 150 rows so the message-mount cost is fixed + * and comparable across member counts. + * + * Run it (from desktop/): + * pnpm build:e2e + * npx playwright test --config=playwright.perf.config.ts member-heavy-switch.perf.ts + * + * Compare the MEDIAN wall ms / longtask lines across the member-count + * scenarios; a superlinear jump is membership-scaling cost on the switch + * path. + */ + +const MEASURED_SWITCHES = 8; +const THROTTLE_RATE = 4; +const DEEP_HISTORY_ROWS = 150; +const MEMBER_COUNTS = [0, 2_000, 10_000] as const; + +type SwitchSample = { + ms: number; + longtaskTotal: number; + longtaskMax: number; + longtaskCount: number; +}; + +function median(values: number[]): number { + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 + ? (sorted[mid - 1] + sorted[mid]) / 2 + : sorted[mid]; +} + +/** Click the sidebar link and poll — all in-page — until the target channel's + * rows are committed, the deferred snapshot has caught up, and a frame + * painted. Returns wall-clock ms plus the longtasks observed in the window. */ +async function measureSwitch( + page: import("@playwright/test").Page, + input: { + targetTestId: string; + /** When set, chat-title must equal this before the switch counts. */ + targetTitle: string | null; + /** Selector that must be present before the switch counts. */ + readySelector: string; + }, +): Promise { + return page.evaluate(async (args) => { + const store = window as unknown as { __LONGTASKS__: number[] }; + store.__LONGTASKS__ = []; + const link = document.querySelector( + `[data-testid="${args.targetTestId}"]`, + ); + if (!link) throw new Error(`missing sidebar link ${args.targetTestId}`); + + const start = performance.now(); + link.click(); + + await new Promise((resolve, reject) => { + const deadline = start + 30_000; + const check = () => { + const titleReady = + args.targetTitle === null || + document.querySelector('[data-testid="chat-title"]')?.textContent === + args.targetTitle; + const ready = + titleReady && + document.querySelector(args.readySelector) !== null && + document.querySelector('[data-render-pending="true"]') === null; + if (ready) { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + return; + } + if (performance.now() > deadline) { + reject(new Error(`switch to ${args.targetTitle} timed out`)); + return; + } + requestAnimationFrame(check); + }; + requestAnimationFrame(check); + }); + + const elapsed = performance.now() - start; + const tasks = store.__LONGTASKS__ ?? []; + return { + ms: elapsed, + longtaskTotal: tasks.reduce((sum, duration) => sum + duration, 0), + longtaskMax: tasks.length ? Math.max(...tasks) : 0, + longtaskCount: tasks.length, + }; + }, input); +} + +type SwitchTarget = { + targetTestId: string; + targetTitle: string | null; + readySelector: string; +}; + +const GENERAL_TARGET: SwitchTarget = { + targetTestId: "channel-general", + targetTitle: "general", + readySelector: "[data-message-id]", +}; + +const DEEP_HISTORY_TARGET: SwitchTarget = { + targetTestId: "channel-deep-history", + targetTitle: "deep-history", + readySelector: '[data-message-id^="mock-deep-history-"]', +}; + +const PROJECTS_TARGET: SwitchTarget = { + targetTestId: "open-projects-view", + targetTitle: null, + // Rendered by every Projects view mode (Activity intro or section header). + readySelector: '[data-testid="projects-page-header"]', +}; + +async function runScenario( + page: import("@playwright/test").Page, + label: string, + target: SwitchTarget, + back: SwitchTarget, +): Promise { + // Untimed warmup round-trip: caches both surfaces' queries and jits the + // switch code paths. + await measureSwitch(page, target); + await measureSwitch(page, back); + + const samples: SwitchSample[] = []; + for (let run = 0; run < MEASURED_SWITCHES; run += 1) { + samples.push(await measureSwitch(page, target)); + samples.push(await measureSwitch(page, back)); + } + + const times = samples.map((sample) => sample.ms); + const longtaskTotals = samples.map((sample) => sample.longtaskTotal); + /* eslint-disable no-console */ + console.log(`\n=== MEMBER-HEAVY WARM SWITCH: ${label} ===`); + console.log(`CPU throttle: ${THROTTLE_RATE}x`); + console.log( + `per-switch wall ms: [${times.map((v) => v.toFixed(1)).join(", ")}]`, + ); + console.log( + `per-switch longtask ms: [${longtaskTotals.map((v) => v.toFixed(1)).join(", ")}]`, + ); + console.log(`MEDIAN wall ms: ${median(times).toFixed(1)}`); + console.log( + `MEDIAN longtask total: ${median(longtaskTotals).toFixed(1)}ms`, + ); + console.log( + `worst single longtask: ${Math.max(...samples.map((sample) => sample.longtaskMax)).toFixed(1)}ms`, + ); + /* eslint-enable no-console */ + return samples; +} + +for (const memberCount of MEMBER_COUNTS) { + const label = + memberCount === 0 + ? "baseline fixture membership" + : `${memberCount.toLocaleString("en-US")} members per channel`; + + test(`MEASURE: warm switch general<->deep-history and general<->projects with ${label}`, async ({ + page, + }) => { + test.setTimeout(300_000); + // Projects is a preview feature; seed the override BEFORE the bridge + // installs so the shell mounts with it enabled. + await page.addInitScript(() => { + window.localStorage.setItem( + "buzz-feature-overrides-v1", + JSON.stringify({ projects: true }), + ); + }); + await installMockBridge(page, { + deepHistoryMessageCount: DEEP_HISTORY_ROWS, + ...(memberCount > 0 + ? { + inflateChannelMembers: { + general: memberCount, + "deep-history": memberCount, + }, + } + : {}), + }); + await page.goto("/"); + await page.waitForFunction( + () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", + ); + + // Arm the longtask observer; addInitScript applies on next navigation. + await page.addInitScript(() => { + const store = window as unknown as { __LONGTASKS__?: number[] }; + store.__LONGTASKS__ = []; + new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + store.__LONGTASKS__?.push(entry.duration); + } + }).observe({ type: "longtask", buffered: true }); + }); + await page.reload(); + await page.waitForFunction( + () => + typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" && + Array.isArray( + (window as unknown as { __LONGTASKS__?: number[] }).__LONGTASKS__, + ), + ); + + // Verify the inflation actually landed before measuring anything. + if (memberCount > 0) { + await expect + .poll(() => + page.evaluate(async () => { + const invoke = ( + window as unknown as { + __TAURI_INTERNALS__: { + invoke: ( + cmd: string, + args: unknown, + ) => Promise<{ members: unknown[] }>; + }; + } + ).__TAURI_INTERNALS__.invoke; + const response = await invoke("get_channel_members", { + channelId: "feedf00d-0000-4000-8000-000000000007", + }); + return response.members.length; + }), + ) + .toBeGreaterThanOrEqual(memberCount); + } + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const client = await page.context().newCDPSession(page); + await client.send("Emulation.setCPUThrottlingRate", { + rate: THROTTLE_RATE, + }); + + const channelSamples = await runScenario( + page, + `channel<->channel, ${label}`, + DEEP_HISTORY_TARGET, + GENERAL_TARGET, + ); + const projectsSamples = await runScenario( + page, + `channel<->projects, ${label}`, + PROJECTS_TARGET, + GENERAL_TARGET, + ); + + await client.send("Emulation.setCPUThrottlingRate", { rate: 1 }); + + // Instrument, not a gate: assert the harness measured real work. + expect(channelSamples.length).toBe(MEASURED_SWITCHES * 2); + expect(channelSamples.every((sample) => sample.ms > 0)).toBe(true); + expect(projectsSamples.length).toBe(MEASURED_SWITCHES * 2); + expect(projectsSamples.every((sample) => sample.ms > 0)).toBe(true); + }); +} diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 2637b94a80..485cae98ef 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -277,6 +277,8 @@ type MockBridgeOptions = { honorChannelsKnownHash?: boolean; /** Number of seeded rows in the deep-history fixture. Defaults to 600. */ deepHistoryMessageCount?: number; + /** Channel name → target member count for high-membership perf specs. */ + inflateChannelMembers?: Record; feedReadError?: string; canvasReadError?: string; /** Delay (ms) for `apply_workspace`; see e2eBridge mock config. */