diff --git a/e2e/console/big-fleet.spec.ts b/e2e/console/big-fleet.spec.ts new file mode 100644 index 00000000..205c378a --- /dev/null +++ b/e2e/console/big-fleet.spec.ts @@ -0,0 +1,77 @@ +import type { Request } from "@playwright/test"; +import type { z } from "zod"; + +import type { workerViewSchema } from "../../src/contract/index.js"; +import { expect, NAV, openFromNav, signIn, test } from "./fixtures.js"; +import { answer, leases, pad } from "./stub-answers.js"; + +type WorkerView = z.infer; + +const WORKERS = 50; +const DEVICES_PER_WORKER = 10; +const LEASES = 300; +const WINDOW_MS = 60_000; +const QUIET_MS = 2_000; +const FIRST_READS_MAX_MS = 15_000; +const IDLE_CEILING = 40; + +function fleet(): WorkerView[] { + const now = Date.now(); + return Array.from({ length: WORKERS }, (_, worker) => ({ + id: `wrk_${pad(worker + 1)}`, + label: `worker-${pad(worker + 1)}`, + connection: "connected", + drained: false, + lastSeenAt: now, + health: "running", + devices: Array.from({ length: DEVICES_PER_WORKER }, (_, device) => ({ + id: `dev_${pad(worker * DEVICES_PER_WORKER + device + 1)}`, + physical: false, + servesDefaultMode: true, + spec: { model: "iPhone 16", osVersion: "18.4", physical: false, platform: "ios" }, + state: "ready", + })), + })); +} + +function isCounted(request: Request): boolean { + const { pathname } = new URL(request.url()); + return pathname.startsWith("/v1/") && pathname !== "/v1/events/stream"; +} + +test.describe("big fleet", () => { + test("an idle console on a big fleet stays quiet: for each of the seven pages, in the 60 s after its first reads, at most 40 requests to /v1 other than the event stream", async ({ + gateway, + page, + }) => { + test.setTimeout(10 * 60_000); + const workers = fleet(); + await answer(page, "/v1/workers", () => ({ workers })); + await answer(page, "/v1/leases", () => ({ leases: leases(LEASES) })); + let lastSentAt = Date.now(); + const sent: string[] = []; + page.on("request", (request) => { + if (!isCounted(request)) return; + lastSentAt = Date.now(); + sent.push(new URL(request.url()).pathname); + }); + + await page.goto(gateway.url); + await signIn(page, gateway.tokens.operator); + for (const { label } of NAV) { + if (label !== "Overview") await openFromNav(page, label); + await expect(page.locator("#main h1")).toHaveText(label); + const navigated = Date.now(); + lastSentAt = navigated; + while (Date.now() - navigated < FIRST_READS_MAX_MS && Date.now() - lastSentAt < QUIET_MS) { + await page.waitForTimeout(250); + } + const before = sent.length; + await page.waitForTimeout(WINDOW_MS); + expect( + sent.length - before, + `requests to /v1 on ${label} in ${WINDOW_MS / 1000} s`, + ).toBeLessThanOrEqual(IDLE_CEILING); + } + }); +}); diff --git a/e2e/console/long-lists.spec.ts b/e2e/console/long-lists.spec.ts index d35f47bf..f23ef78f 100644 --- a/e2e/console/long-lists.spec.ts +++ b/e2e/console/long-lists.spec.ts @@ -11,7 +11,7 @@ import { startDaemon, test as base, } from "./fixtures.js"; -import { fetchFull } from "./full-fetch.js"; +import { answer, leases, pad } from "./stub-answers.js"; import { returnToTab } from "./tab.js"; const IOS = { availableOsVersions: ["18.4"], knownModels: ["iPhone 16"] }; @@ -114,20 +114,6 @@ async function open(page: Page, host: RunningDaemon, path: string): Promise` with what `body` returns at the time of the request, on top of the - * daemon's own response, so its headers (its `Date` among them) stay real. - */ -async function answer(page: Page, pathname: string, body: () => unknown): Promise { - await page.route( - (url) => url.pathname === pathname, - async (route: Route) => { - const response = await fetchFull(route); - await route.fulfill({ json: body(), response }); - }, - ); -} - // why: a page before the last hour answers with no events, so the stubbed list ends where it did // before Activity scrolled back. async function answerEvents(page: Page, replay: readonly unknown[]): Promise { @@ -140,26 +126,6 @@ async function answerEvents(page: Page, replay: readonly unknown[]): Promise ({ - deviceId: `dev_${pad(index + 1)}`, - grantedAt: now - 3_600_000 + index * 1_000, - id: `lse_${pad(index + 1)}`, - lastRenewedAt: now - 60_000, - idChosenByRequester: false, - ownerId: "tok_load", - requesterId: "tok_load", - ttlDeadline: now + 600_000, - ttlMs: 900_000, - })); -} - -function pad(n: number): string { - return String(n).padStart(4, "0"); -} - function rowIds(list: Locator, section: string): Promise { return list.getByRole("link").evaluateAll( (links, prefix) => diff --git a/e2e/console/stub-answers.ts b/e2e/console/stub-answers.ts new file mode 100644 index 00000000..e2bcb3b4 --- /dev/null +++ b/e2e/console/stub-answers.ts @@ -0,0 +1,33 @@ +import type { Page, Route } from "@playwright/test"; + +import { fetchFull } from "./full-fetch.js"; + +// why: the daemon's own response is kept so its headers (its `Date` among them) stay real, and it is always fetched in full, so a stub never meets a 304. +export async function answer(page: Page, pathname: string, body: () => unknown): Promise { + await page.route( + (url) => url.pathname === pathname, + async (route: Route) => { + const response = await fetchFull(route); + await route.fulfill({ json: body(), response }); + }, + ); +} + +export function pad(n: number): string { + return String(n).padStart(4, "0"); +} + +export function leases(count: number): unknown[] { + const now = Date.now(); + return Array.from({ length: count }, (_, index) => ({ + deviceId: `dev_${pad(index + 1)}`, + grantedAt: now - 3_600_000 + index * 1_000, + id: `lse_${pad(index + 1)}`, + lastRenewedAt: now - 60_000, + idChosenByRequester: false, + ownerId: "tok_load", + requesterId: "tok_load", + ttlDeadline: now + 600_000, + ttlMs: 900_000, + })); +}