From 7009b0697208af4c701fd721be4f688e7f790cd4 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 16:41:06 +0200 Subject: [PATCH 1/2] test(console): an idle console on a 50-worker fleet stays at or under 40 requests a minute on every page (#918) --- e2e/console/big-fleet.spec.ts | 74 ++++++++++++++++++++++++++++++++++ e2e/console/long-lists.spec.ts | 36 +---------------- e2e/console/stub-answers.ts | 33 +++++++++++++++ src/contract/worker-view.ts | 2 +- 4 files changed, 109 insertions(+), 36 deletions(-) create mode 100644 e2e/console/big-fleet.spec.ts create mode 100644 e2e/console/stub-answers.ts diff --git a/e2e/console/big-fleet.spec.ts b/e2e/console/big-fleet.spec.ts new file mode 100644 index 00000000..2de4a119 --- /dev/null +++ b/e2e/console/big-fleet.spec.ts @@ -0,0 +1,74 @@ +import type { Request } from "@playwright/test"; + +import type { WorkerView } from "../../src/contract/index.js"; +import { expect, NAV, openFromNav, signIn, test } from "./fixtures.js"; +import { answer, leases, pad } from "./stub-answers.js"; + +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, + })); +} diff --git a/src/contract/worker-view.ts b/src/contract/worker-view.ts index d96c3b43..68493efe 100644 --- a/src/contract/worker-view.ts +++ b/src/contract/worker-view.ts @@ -35,7 +35,7 @@ export const WORKER_VIEW_CATALOG_EVENTS = ["component.installed"] as const; export const WORKER_VIEW_UPDATE_EVENTS = ["daemon.update-started", "daemon.update-failed"] as const; type Output = z.infer<(typeof OPERATIONS)[Name]["output"]>; -type WorkerView = z.infer; +export type WorkerView = z.infer; /** The answers a view is built from. `catalog` and `config` are optional because a gateway's * per-event refresh re-reads only status and devices; a field left out stays as it was. From 544634cf5eb0811224afddae87fc538fade003e3 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 17:05:15 +0200 Subject: [PATCH 2/2] fix(e2e): derive the worker view type from workerViewSchema, leave src/contract untouched --- e2e/console/big-fleet.spec.ts | 5 ++++- src/contract/worker-view.ts | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/e2e/console/big-fleet.spec.ts b/e2e/console/big-fleet.spec.ts index 2de4a119..205c378a 100644 --- a/e2e/console/big-fleet.spec.ts +++ b/e2e/console/big-fleet.spec.ts @@ -1,9 +1,12 @@ import type { Request } from "@playwright/test"; +import type { z } from "zod"; -import type { WorkerView } from "../../src/contract/index.js"; +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; diff --git a/src/contract/worker-view.ts b/src/contract/worker-view.ts index 68493efe..d96c3b43 100644 --- a/src/contract/worker-view.ts +++ b/src/contract/worker-view.ts @@ -35,7 +35,7 @@ export const WORKER_VIEW_CATALOG_EVENTS = ["component.installed"] as const; export const WORKER_VIEW_UPDATE_EVENTS = ["daemon.update-started", "daemon.update-failed"] as const; type Output = z.infer<(typeof OPERATIONS)[Name]["output"]>; -export type WorkerView = z.infer; +type WorkerView = z.infer; /** The answers a view is built from. `catalog` and `config` are optional because a gateway's * per-event refresh re-reads only status and devices; a field left out stays as it was.