mirror of
https://github.com/block/buzz.git
synced 2026-11-03 07:51:22 +00:00
test(desktop): high-membership channel-switch perf harness
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 <maxwell@squareup.com>
This commit is contained in:
@@ -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<string, number>;
|
||||
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.`);
|
||||
|
||||
290
desktop/tests/e2e/member-heavy-switch.perf.ts
Normal file
290
desktop/tests/e2e/member-heavy-switch.perf.ts
Normal file
@@ -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<SwitchSample> {
|
||||
return page.evaluate(async (args) => {
|
||||
const store = window as unknown as { __LONGTASKS__: number[] };
|
||||
store.__LONGTASKS__ = [];
|
||||
const link = document.querySelector<HTMLElement>(
|
||||
`[data-testid="${args.targetTestId}"]`,
|
||||
);
|
||||
if (!link) throw new Error(`missing sidebar link ${args.targetTestId}`);
|
||||
|
||||
const start = performance.now();
|
||||
link.click();
|
||||
|
||||
await new Promise<void>((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<SwitchSample[]> {
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
@@ -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<string, number>;
|
||||
feedReadError?: string;
|
||||
canvasReadError?: string;
|
||||
/** Delay (ms) for `apply_workspace`; see e2eBridge mock config. */
|
||||
|
||||
Reference in New Issue
Block a user