diff --git a/desktop/src/app/navigation/commitGuardedNavigation.test.mjs b/desktop/src/app/navigation/commitGuardedNavigation.test.mjs new file mode 100644 index 00000000000..b1776fd13ab --- /dev/null +++ b/desktop/src/app/navigation/commitGuardedNavigation.test.mjs @@ -0,0 +1,140 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { commitGuardedNavigation } from "./commitGuardedNavigation.ts"; + +const route = (href) => ({ kind: "route", href }); + +test("an accepted navigation consults the guard before navigating", async () => { + const order = []; + const committed = await commitGuardedNavigation( + { + currentHref: "/channels/aaaa", + nextHref: "/channels/bbbb", + guardedTarget: route("/channels/bbbb"), + navigate: async () => { + order.push("navigate"); + }, + }, + { + allow: () => { + order.push("guard"); + return true; + }, + }, + ); + assert.equal(committed, true); + assert.deepEqual(order, ["guard", "navigate"]); +}); + +test("a same-destination no-op never reaches the guard", async () => { + const order = []; + const committed = await commitGuardedNavigation( + { + currentHref: "/channels/aaaa", + nextHref: "/channels/aaaa", + guardedTarget: route("/channels/aaaa"), + navigate: async () => { + order.push("navigate"); + }, + }, + { + allow: () => { + order.push("guard"); + return true; + }, + }, + ); + assert.equal(committed, false); + assert.deepEqual(order, []); +}); + +test("force overrides the same-destination no-op but still runs the guard first", async () => { + const order = []; + const committed = await commitGuardedNavigation( + { + currentHref: "/channels/aaaa", + nextHref: "/channels/aaaa", + force: true, + guardedTarget: route("/channels/aaaa"), + navigate: async () => { + order.push("navigate"); + }, + }, + { + allow: () => { + order.push("guard"); + return true; + }, + }, + ); + assert.equal(committed, true); + assert.deepEqual(order, ["guard", "navigate"]); +}); + +test("a same-destination navigation carrying router state still commits", async () => { + const order = []; + const committed = await commitGuardedNavigation( + { + currentHref: "/channels/aaaa", + nextHref: "/channels/aaaa", + guardedTarget: route("/channels/aaaa"), + hasStateUpdate: true, + navigate: async () => { + order.push("navigate"); + }, + }, + { + allow: () => { + order.push("guard"); + return true; + }, + }, + ); + assert.equal(committed, true); + assert.deepEqual(order, ["guard", "navigate"]); +}); + +test("a refused navigation does not navigate", async () => { + // The guard's whole purpose: an unsaved thread edit blocks the switch. + // Asserting only that the guard is CONSULTED is not enough — consulting it + // and discarding the answer passes every ordering test in this file. + const order = []; + const committed = await commitGuardedNavigation( + { + currentHref: "/channels/aaaa", + nextHref: "/channels/bbbb", + guardedTarget: route("/channels/bbbb"), + navigate: async () => { + order.push("navigate"); + }, + }, + { + allow: () => { + order.push("guard"); + return false; + }, + }, + ); + assert.equal(committed, false); + assert.deepEqual(order, ["guard"]); +}); + +test("a refused forced navigation does not navigate either", async () => { + // force defeats the same-destination skip, never the guard. + const order = []; + const committed = await commitGuardedNavigation( + { + currentHref: "/channels/aaaa", + nextHref: "/channels/aaaa", + force: true, + guardedTarget: route("/channels/aaaa"), + navigate: async () => { + order.push("navigate"); + }, + }, + { allow: () => false }, + ); + assert.equal(committed, false); + assert.deepEqual(order, []); +}); diff --git a/desktop/src/app/navigation/commitGuardedNavigation.ts b/desktop/src/app/navigation/commitGuardedNavigation.ts new file mode 100644 index 00000000000..a83df6ecc69 --- /dev/null +++ b/desktop/src/app/navigation/commitGuardedNavigation.ts @@ -0,0 +1,40 @@ +import { + allowNavigation, + type GuardedNavigation, +} from "@/app/navigation/navigationGuard"; + +/** + * commitGuardedNavigation runs the shared commit flow for app navigations: + * skip same-destination no-ops, consult the navigation guard, then navigate. + * `force` and `hasStateUpdate` both defeat the no-op skip — a same-href + * navigation that writes router state (setting or clearing the search + * highlight) must commit, or the state never lands. Returns whether the + * navigation was performed. `deps` exists for unit tests. + */ +export async function commitGuardedNavigation( + input: { + currentHref: string; + nextHref: string; + force?: boolean; + guardedTarget: GuardedNavigation; + hasStateUpdate?: boolean; + navigate: () => Promise; + }, + deps: { + allow?: typeof allowNavigation; + } = {}, +): Promise { + const allow = deps.allow ?? allowNavigation; + if ( + input.currentHref === input.nextHref && + !input.force && + !input.hasStateUpdate + ) { + return false; + } + if (!allow(input.guardedTarget)) { + return false; + } + await input.navigate(); + return true; +} diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index ade8c9332c0..083efc4450c 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -6,10 +6,10 @@ import { useRouter, } from "@tanstack/react-router"; +import { commitGuardedNavigation } from "@/app/navigation/commitGuardedNavigation"; import type { SearchHighlightNavigation } from "@/app/navigation/searchHighlightNavigation"; import { openSearchHitWithNavigation } from "@/app/navigation/searchHitNavigation"; import { - allowNavigation, type GuardedNavigation, traverseHistory, } from "@/app/navigation/navigationGuard"; @@ -43,30 +43,22 @@ export function useAppNavigation() { guardedTarget?: GuardedNavigation, ) => { const nextLocation = router.buildLocation(next as never); - const hasStateUpdate = next.state !== undefined; - - if ( - location.href === nextLocation.href && - !behavior.force && - !hasStateUpdate - ) { - return false; - } - - if ( - !allowNavigation( - guardedTarget ?? { kind: "route", href: nextLocation.href }, - ) - ) { - return false; - } - - await navigate({ - ...next, - replace: behavior.replace, - resetScroll: behavior.resetScroll, - } as never); - return true; + return commitGuardedNavigation({ + currentHref: location.href, + force: behavior.force, + guardedTarget: guardedTarget ?? { + kind: "route", + href: nextLocation.href, + }, + hasStateUpdate: next.state !== undefined, + navigate: () => + navigate({ + ...next, + replace: behavior.replace, + resetScroll: behavior.resetScroll, + } as never), + nextHref: nextLocation.href, + }); }, [location.href, navigate, router], ); diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 9c2d25dd7cf..243626ddce2 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -1,6 +1,5 @@ // biome-ignore-all format: line-count ratchet requires compact forwarding in this legacy component import * as React from "react"; -import { useQueryClient } from "@tanstack/react-query"; import { useAppShell } from "@/app/AppShellContext"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useActiveChannelHeader } from "@/features/channels/useActiveChannelHeader"; @@ -49,11 +48,6 @@ import { getThreadReference, isThreadReply, } from "@/features/messages/lib/threading"; -import { hasPersistedHydratedChannel } from "@/features/messages/lib/channelHeadCache"; -import { - resolveTimelineLoadingLatch, - selectTimelineLoadingState, -} from "@/features/messages/lib/timelineLoadingState"; import { useFetchOlderMessages } from "@/features/messages/useFetchOlderMessages"; import { useIndependentThreadPanel } from "@/features/messages/useIndependentThreadPanel"; import { useThreadReplies } from "@/features/messages/useThreadReplies"; @@ -71,6 +65,7 @@ import { useHuddleReadMarker } from "@/features/channels/ui/useHuddleReadMarker" import { useHuddleThreadIsolation } from "@/features/channels/ui/useHuddleThreadIsolation"; import { AgentSessionProvider } from "@/shared/context/AgentSessionContext"; import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext"; +import { useChannelTimelineLoading } from "@/features/channels/useChannelTimelineLoading"; import { useMainInsetRef } from "@/shared/layout/MainInsetContext"; import { channelContentTopPaddingMeasurement } from "@/shared/layout/chromeLayout"; import { useMeasuredCssVariable } from "@/shared/layout/useMeasuredCssVariable"; @@ -104,7 +99,6 @@ export function ChannelScreen({ targetMessageEvents, targetMessageId, ...searchTarget }: ChannelScreenProps) { - const queryClient = useQueryClient(); const { goHome } = useAppNavigation(); const { activeCommunity } = useCommunities(); const { @@ -615,30 +609,10 @@ export function ChannelScreen({ setThreadReplyTargetId, setThreadScrollTargetId, }); - const settledChannelIdRef = React.useRef(null); - const hasSettledThisChannel = - activeChannelId !== null && settledChannelIdRef.current === activeChannelId; - const timelineLoadingNow = - activeChannel !== null && - activeChannel.channelType !== "forum" && - selectTimelineLoadingState( - { - isPending: messagesQuery.isPending, - isFetching: messagesQuery.isFetching, - isPlaceholderData: messagesQuery.isPlaceholderData, - dataLength: messagesQuery.data?.length ?? null, - }, - hasSettledThisChannel || - (activeChannelId !== null && - hasPersistedHydratedChannel(queryClient, activeChannelId)), - ); - const { settledChannelId, isLoading: isTimelineLoading } = - resolveTimelineLoadingLatch( - settledChannelIdRef.current, - activeChannelId, - timelineLoadingNow, - ); - settledChannelIdRef.current = settledChannelId; + const isTimelineLoading = useChannelTimelineLoading( + activeChannel, + messagesQuery, + ); const { welcomeKickoffStage, welcomeKickoffSettingUp } = useWelcomeKickoffStagePresence( activeChannel, diff --git a/desktop/src/features/channels/useChannelTimelineLoading.ts b/desktop/src/features/channels/useChannelTimelineLoading.ts new file mode 100644 index 00000000000..0427c586801 --- /dev/null +++ b/desktop/src/features/channels/useChannelTimelineLoading.ts @@ -0,0 +1,55 @@ +import * as React from "react"; +import { useQueryClient } from "@tanstack/react-query"; + +import { hasPersistedHydratedChannel } from "@/features/messages/lib/channelHeadCache"; +import { + resolveTimelineLoadingLatch, + selectTimelineLoadingState, +} from "@/features/messages/lib/timelineLoadingState"; +import type { Channel } from "@/shared/api/types"; + +/** + * Latches the timeline loading state per channel, so a channel that has + * already settled once does not flash its skeleton again while an + * authoritative refresh is in flight. + */ +export function useChannelTimelineLoading( + activeChannel: Channel | null, + messagesQuery: { + data: readonly unknown[] | undefined; + isFetching: boolean; + isPending: boolean; + isPlaceholderData: boolean; + }, +): boolean { + const queryClient = useQueryClient(); + const activeChannelId = activeChannel?.id ?? null; + const settledChannelIdRef = React.useRef(null); + const hasSettledThisChannel = + activeChannelId !== null && settledChannelIdRef.current === activeChannelId; + const timelineLoadingNow = + activeChannel !== null && + activeChannel.channelType !== "forum" && + selectTimelineLoadingState( + { + isPending: messagesQuery.isPending, + isFetching: messagesQuery.isFetching, + isPlaceholderData: messagesQuery.isPlaceholderData, + dataLength: messagesQuery.data?.length ?? null, + }, + // A persisted head only counts as hydrated when it has rows to paint + // (channelHeadCache.ts), so this bypass never settles onto an empty + // placeholder while the authoritative refresh is still in flight. + hasSettledThisChannel || + (activeChannelId !== null && + hasPersistedHydratedChannel(queryClient, activeChannelId)), + ); + const { settledChannelId, isLoading: isTimelineLoading } = + resolveTimelineLoadingLatch( + settledChannelIdRef.current, + activeChannelId, + timelineLoadingNow, + ); + settledChannelIdRef.current = settledChannelId; + return isTimelineLoading; +} diff --git a/desktop/src/features/projects/ui/ProjectsView.tsx b/desktop/src/features/projects/ui/ProjectsView.tsx index f33659bcfc5..ecf0671e905 100644 --- a/desktop/src/features/projects/ui/ProjectsView.tsx +++ b/desktop/src/features/projects/ui/ProjectsView.tsx @@ -172,6 +172,15 @@ export function ProjectsView() { snapshotProjects, activeCommunity?.reposDir, ); + // Switch-perf hydration marker (shell commits long before the fan loads). + // isLoading: first load only — warm refetches/disabled queries stay false. + const projectsHydrating = + projectsQuery.isLoading || + projectsWorkItemsQuery.isLoading || + repoSnapshotsQuery.isLoading || + activitySummariesQuery.isLoading || + repositoryActivitySummariesQuery.isLoading || + localRepositoriesQuery.isLoading; const memberChannelIds = useMemberChannelIds(); const repositoryUnavailableReasonFor = useRepositoryUnavailableReasonFor( repoSnapshotsQuery.data?.unavailable, @@ -722,6 +731,7 @@ export function ProjectsView() { data-project-context-detached={ isNarrowProjectsLayout ? undefined : "true" } + data-projects-hydrating={projectsHydrating ? "true" : undefined} data-testid="projects-overview-layout" > >(() => new Set()); - const navigate = useNavigate(); + const { goChannel } = useAppNavigation(); const queryClient = useQueryClient(); const replyMutation = usePublishNoteMutation(currentPubkey); const toggleReactionMutation = useToggleReactionMutation(); @@ -154,21 +154,20 @@ export function usePulseNoteActions({ const startDm = React.useCallback( async (pubkey: string) => { + // goChannel, not raw navigate: this is a first-class channel entry and + // must go through the same navigation guard as every other one. try { const directMessage = await openDmMutation.mutateAsync({ pubkeys: [pubkey], }); - await navigate({ - to: "/channels/$channelId", - params: { channelId: directMessage.id }, - }); + await goChannel(directMessage.id); } catch (error) { toast.error( error instanceof Error ? error.message : "Failed to open DM", ); } }, - [navigate, openDmMutation], + [goChannel, openDmMutation], ); return { diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 94f6c1fcc4b..eb74c47d84e 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 00000000000..1fdff110af5 --- /dev/null +++ b/desktop/tests/e2e/member-heavy-switch.perf.ts @@ -0,0 +1,362 @@ +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 every render-path pass + * over `channel.memberPubkeys` and the member list (profile merges, + * agent-flag merges, mention candidates). NOTE: the mock bridge hands the + * app live JS objects — no IPC serialization or JSON parse — so the + * get_channels/get_channel_members parse cost that scales with membership + * in production is NOT exercised here; this instrument measures render-path + * scaling only. Same channels, same rows, member count 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. Readiness requires the + * shell header AND the absence of the surface's + * data-projects-hydrating marker. The marker guards + * cold/invalidated samples whose query fan is still + * on first load; after the untimed warmup the fan is + * cached and renders synchronously, so measured + * samples are warm-switch commits (the marker never + * fires there — that is the warm contract, not a + * gap). + * + * 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. Each direction of a round-trip is + * reported as its own median — the two legs mount different surfaces, and a + * combined median could represent neither and hide a one-leg regression. + * + * 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; +/** general + deep-history — the two channels inflateChannelMembers targets. */ +const INFLATED_CHANNEL_IDS = [ + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50", + "feedf00d-0000-4000-8000-000000000007", +]; + +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; + /** Selector that must be ABSENT before the switch counts. */ + pendingSelector: string | null; + }, +): Promise { + return page.evaluate(async (args) => { + const store = window as unknown as { + __LONGTASKS__: number[]; + __LONGTASK_OBSERVER__?: PerformanceObserver; + }; + // Discard pending deliveries from BEFORE this sample: a longtask + // trailing the previous switch is delivered in a later task and would + // otherwise land in this sample's fresh array. + store.__LONGTASK_OBSERVER__?.takeRecords(); + 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 && + (args.pendingSelector === null || + document.querySelector(args.pendingSelector) === 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; + // Observer callbacks are delivered in a later task; drain the queue so + // a longtask ending just before the resolve frame isn't dropped. + for (const entry of store.__LONGTASK_OBSERVER__?.takeRecords() ?? []) { + store.__LONGTASKS__.push(entry.duration); + } + 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; + pendingSelector: string | null; +}; + +const GENERAL_TARGET: SwitchTarget = { + targetTestId: "channel-general", + targetTitle: "general", + // Prefixed so the previous channel's still-mounted rows can never satisfy + // the gate. + readySelector: '[data-message-id^="mock-general-"]', + pendingSelector: null, +}; + +const DEEP_HISTORY_TARGET: SwitchTarget = { + targetTestId: "channel-deep-history", + targetTitle: "deep-history", + readySelector: '[data-message-id^="mock-deep-history-"]', + pendingSelector: null, +}; + +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"]', + // The header is shell — the query fan (projects, work items, repo + // snapshots, activity summaries) must have settled too. + pendingSelector: '[data-projects-hydrating="true"]', +}; + +function reportDirection(label: string, samples: SwitchSample[]): void { + 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 */ +} + +async function runScenario( + page: import("@playwright/test").Page, + label: string, + target: SwitchTarget, + back: SwitchTarget, +): Promise<{ toTarget: SwitchSample[]; toBack: SwitchSample[] }> { + // Untimed warmup round-trip: caches both surfaces' queries and jits the + // switch code paths. + await measureSwitch(page, target); + await measureSwitch(page, back); + + // The two legs mount different surfaces; report each as its own median so + // a one-leg regression can never hide in a combined number. + const toTarget: SwitchSample[] = []; + const toBack: SwitchSample[] = []; + for (let run = 0; run < MEASURED_SWITCHES; run += 1) { + toTarget.push(await measureSwitch(page, target)); + toBack.push(await measureSwitch(page, back)); + } + + reportDirection( + `${label}, ${back.targetTestId} -> ${target.targetTestId}`, + toTarget, + ); + reportDirection( + `${label}, ${target.targetTestId} -> ${back.targetTestId}`, + toBack, + ); + return { toTarget, toBack }; +} + +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[]; + __LONGTASK_OBSERVER__?: PerformanceObserver; + }; + store.__LONGTASKS__ = []; + const observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + store.__LONGTASKS__?.push(entry.duration); + } + }); + observer.observe({ type: "longtask", buffered: true }); + // Exposed so measureSwitch can drain takeRecords() at sample time. + store.__LONGTASK_OBSERVER__ = observer; + }); + 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 — on BOTH inflated channels. The + // bridge silently skips unknown channel names, so a rename/typo would + // otherwise run baseline membership under a "10,000 members" label. + if (memberCount > 0) { + await expect + .poll(() => + page.evaluate(async (channelIds) => { + const invoke = ( + window as unknown as { + __TAURI_INTERNALS__: { + invoke: ( + cmd: string, + args: unknown, + ) => Promise<{ members: unknown[] }>; + }; + } + ).__TAURI_INTERNALS__.invoke; + const counts = await Promise.all( + channelIds.map(async (channelId) => { + const response = await invoke("get_channel_members", { + channelId, + }); + return response.members.length; + }), + ); + return Math.min(...counts); + }, INFLATED_CHANNEL_IDS), + ) + .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. + for (const samples of [ + channelSamples.toTarget, + channelSamples.toBack, + projectsSamples.toTarget, + projectsSamples.toBack, + ]) { + expect(samples.length).toBe(MEASURED_SWITCHES); + expect(samples.every((sample) => sample.ms > 0)).toBe(true); + } + }); +} diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 2637b94a808..485cae98efc 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. */