Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions e2e/console/big-fleet.spec.ts
Original file line number Diff line number Diff line change
@@ -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<typeof workerViewSchema>;

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);
}
});
});
36 changes: 1 addition & 35 deletions e2e/console/long-lists.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"] };
Expand Down Expand Up @@ -114,20 +114,6 @@ async function open(page: Page, host: RunningDaemon, path: string): Promise<void
await expect(page.locator("#main")).not.toContainText("Loading…");
}

/**
* Answers `GET <pathname>` 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<void> {
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<void> {
Expand All @@ -140,26 +126,6 @@ async function answerEvents(page: Page, replay: readonly unknown[]): Promise<voi
);
}

/** `count` leases on the one host, `lse_0001` granted first. */
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,
}));
}

function pad(n: number): string {
return String(n).padStart(4, "0");
}

function rowIds(list: Locator, section: string): Promise<string[]> {
return list.getByRole("link").evaluateAll(
(links, prefix) =>
Expand Down
33 changes: 33 additions & 0 deletions e2e/console/stub-answers.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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,
}));
}
Loading