Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
5 changes: 4 additions & 1 deletion e2e/console/leases.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
startFleet,
test,
} from "./fixtures.js";
import { returnToTab } from "./tab.js";

/** One lease as `simlock list --leases` prints it, as far as these tests read. */
interface ListedLease {
Expand Down Expand Up @@ -620,7 +621,9 @@ test.describe("the leases page", () => {
),
);

await expect(expiry).toContainText(/(49|50) min/, { timeout: 10_000 });
await returnToTab(page);

await expect(expiry).toContainText(/(49|50) min/);
} finally {
await host.dispose();
}
Expand Down
4 changes: 4 additions & 0 deletions e2e/console/long-lists.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
startDaemon,
test as base,
} from "./fixtures.js";
import { returnToTab } from "./tab.js";

const IOS = { availableOsVersions: ["18.4"], knownModels: ["iPhone 16"] };

Expand Down Expand Up @@ -276,6 +277,7 @@ test.describe("long lists page", () => {
await expect(range).toHaveText("51–60 of 60");

count = 30;
await returnToTab(page);

// why: the list refresh is asynchronous; its final page may take more than one poll cycle.
// The next refresh leaves no third page: the second, now the last, shows.
Expand Down Expand Up @@ -310,6 +312,7 @@ test.describe("long lists page", () => {
// Twice: the daemon answers once more, then the row's countdown moves, so a render follows.
for (let refresh = 0; refresh < 2; refresh += 1) {
const since = served;
await returnToTab(page);
await expect.poll(() => served, { timeout: 3_000 }).toBeGreaterThan(since);
const before = await expires.textContent();
await expect.poll(() => expires.textContent(), { timeout: 3_000 }).not.toBe(before);
Expand Down Expand Up @@ -372,6 +375,7 @@ test.describe("long lists page", () => {

// One row more and the list needs a second page: the pager shows.
count = 26;
await returnToTab(page);
await expect(pager(page, "Leases").locator(".pager-range")).toHaveText("1–25 of 26");
});

Expand Down
8 changes: 7 additions & 1 deletion e2e/console/proxy-sign-in.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { expect, NAV_LABELS, openFromNav, signIn, startProxiedDaemon, test } from "./fixtures.js";
import { returnToTab } from "./tab.js";

const EXPIRED_LINE =
"Your sign-in proxy's identity was refused: it has expired. You can still sign in with an operator token.";
Expand Down Expand Up @@ -96,8 +97,10 @@ test.describe("signing in through a proxy", () => {
await page.goto(proxied.url);
await expect(page.getByText("Signed in as Alice")).toBeVisible();

// why: the console polls every second, so the next request can come before any click.
// why: the console reads again when the operator returns to the tab, so the next request can
// come before any click.
proxied.setMode("expired");
await returnToTab(page);

await expect(page.getByLabel("Operator token")).toBeVisible();
await expect(page.getByRole("alert")).toHaveText(EXPIRED_LINE);
Expand All @@ -112,6 +115,7 @@ test.describe("signing in through a proxy", () => {
await redirected.goto(proxied.url);
await expect(redirected.getByText("Signed in as Alice")).toBeVisible();
proxied.setMode("redirect");
await returnToTab(redirected);
await expect(redirected).toHaveURL(proxied.loginUrl);
await expect(redirected.getByRole("heading", { name: "Proxy login" })).toBeVisible();
await redirected.close();
Expand All @@ -121,6 +125,7 @@ test.describe("signing in through a proxy", () => {
await reloaded.goto(proxied.url);
await expect(reloaded.getByText("Signed in as Alice")).toBeVisible();
proxied.setMode("html");
await returnToTab(reloaded);
await expect(reloaded.getByRole("heading", { name: "Proxy sign-in" })).toBeVisible();

proxied.setMode("signed-in");
Expand All @@ -130,6 +135,7 @@ test.describe("signing in through a proxy", () => {
(window as unknown as { loadedOnce: boolean }).loadedOnce = true;
});
proxied.setMode("html");
await returnToTab(reloaded);

await expect(reloaded.getByText("Your sign-in has ended.")).toBeVisible();
await expect(reloaded.getByRole("button", { name: "Sign in again" })).toBeVisible();
Expand Down
16 changes: 16 additions & 0 deletions e2e/console/tab.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { Page } from "@playwright/test";

// why: the console reads each route on its own interval (leases every 10 s, workers every 15 s,
// stats every 60 s) and again when the operator comes back to the tab, so a spec that changes what
// the daemon answers and wants the page to show it at once hides the tab and shows it again, which
// is that return.
export async function returnToTab(page: Page): Promise<void> {
await page.evaluate(() => {
const show = (state: "hidden" | "visible") => {
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => state });
document.dispatchEvent(new Event("visibilitychange"));
};
show("hidden");
show("visible");
});
}
11 changes: 5 additions & 6 deletions e2e/console/usage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Page } from "@playwright/test";

import { waitFor } from "../helpers/wait.js";
import { expect, type RunningDaemon, settled, signIn, startDaemon, test } from "./fixtures.js";
import { returnToTab } from "./tab.js";

const IOS = { availableOsVersions: ["18.4"], knownModels: ["iPhone 16"] };
const ONE_DEVICE = { limits: { ios: { maxDevices: 1, maxRunning: 1 }, maxRunning: 1 } };
Expand Down Expand Up @@ -201,12 +202,10 @@ test.describe("the Usage page", () => {
const granted = await host.cli([...LEASE, "--agent-id", "late", "--detach"]);
expect(granted.code, granted.stderr).toBe(0);

await expect(tile(page, "Requests").locator(".stat-value")).toHaveText("1", {
timeout: 10_000,
});
await expect(tile(page, "Granted").locator(".stat-value")).toHaveText("1", {
timeout: 10_000,
});
await returnToTab(page);

await expect(tile(page, "Requests").locator(".stat-value")).toHaveText("1");
await expect(tile(page, "Granted").locator(".stat-value")).toHaveText("1");
} finally {
await host.dispose();
}
Expand Down
2 changes: 1 addition & 1 deletion e2e/console/workers-answers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ interface LoadPayload {
memory?: { availableBytes?: number; pressure?: string; totalBytes?: number } | undefined;
}

// why: the page re-reads the list every second, so a re-read can be intercepted just as the test's
// why: the page re-reads the list every 15 seconds, so a re-read can be intercepted just as the test's
// `finally` stops the gateway; the refused connect it gets is the fleet going away, not a failure of
// the test, and Playwright would otherwise charge the rejection to it. Only connect-level errors are
// dropped: any other error is a real fault and must still kill the test.
Expand Down
2 changes: 1 addition & 1 deletion e2e/console/workers-gateway-stops-mid-fetch.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { disposeFleet, expect, type RunningDaemon, signIn, startFleet, test } fr
/**
* why: reproduces #642. The workers page's response helpers (`answerWithoutLoad`, `answerMemory`)
* answer the page's re-reads of `/v1/workers` by fetching them from the gateway through
* `route.fetch()`, and the page re-reads every second. A re-read intercepted but not yet fetched
* `route.fetch()`, and the page re-reads every 15 seconds. A re-read intercepted but not yet fetched
* when the test's `finally` disposes the fleet gets a refused connect — a listener destroyed
* mid-request reads the same way as a socket hang up — and Playwright attributes the handler's
* rejection to the test, so the spec dies on the helper's `route.fetch()` line although every
Expand Down
2 changes: 2 additions & 0 deletions e2e/console/workers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
startFleet,
test,
} from "./fixtures.js";
import { returnToTab } from "./tab.js";

interface WorkerView {
readonly id: string;
Expand Down Expand Up @@ -1049,6 +1050,7 @@ test.describe("the workers page", () => {
[undefined, 50, "neutral"],
] as const) {
claim = { usedPercent, ...(level === undefined ? {} : { pressure: level }) };
await returnToTab(page);
const want = tone === "neutral" ? neutral : tones[TONES.indexOf(tone)];
const at = `${usedPercent}%${level === undefined ? " with no word" : ` and ${level}`}`;
await expect
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
"@readme/openapi-parser": "^9.1.0",
"@stryker-mutator/core": "^10.0.0",
"@stryker-mutator/vitest-runner": "^10.0.0",
"@tanstack/react-query": "^5.104.5",
"@tanstack/react-table": "^9.2.4",
"@tanstack/react-virtual": "^3.14.13",
"@types/node": "^26.1.1",
Expand Down
24 changes: 24 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/daemon/dispatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -635,7 +635,7 @@ describe("Dispatcher: the fleet operations on a worker", () => {
const { clock, dispatcher, driver } = await buildDispatcher();
const catalogReads = vi.spyOn(driver, "listCatalog");

// A console polls every second; every poll up to the last millisecond of the interval
// why: calls arrive every second; every call up to the last millisecond of the interval
// answers from the one read.
await dispatcher.dispatch("worker.list", {}, admin);
for (let elapsed = 1_000; elapsed < WORKER_VIEW_REFRESH_INTERVAL_MS; elapsed += 1_000) {
Expand Down
4 changes: 2 additions & 2 deletions src/daemon/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,11 +255,11 @@ export class Dispatcher {
*/
readonly #handlers: Record<WorkerOperationName, ErasedHandler>;
/**
* `worker.list`'s catalog: the last `catalog.get` answer and when it was read. Kept for
* why: `worker.list`'s catalog: the last `catalog.get` answer and when it was read. Kept for
* `WORKER_VIEW_REFRESH_INTERVAL_MS`, the rhythm a gateway re-reads a worker's catalog at, and
* dropped on `WORKER_VIEW_CATALOG_EVENTS`, the events a gateway re-reads it on. Without it
* every `worker.list` runs each driver's catalog read (`simctl list` on iOS), and the console
* polls that route every second. Holds the promise, so calls that arrive while a read runs
* polls that route. Holds the promise, so calls that arrive while a read runs
* share it; a read that fails is dropped, so the next call reads again.
*/
#viewCatalog: { readonly readAt: number; readonly catalog: Promise<unknown> } | undefined;
Expand Down
18 changes: 17 additions & 1 deletion ui/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Each layer imports only the layers below it. `.dependency-cruiser.mjs` enforces
(`overviewOf` in `overview.ts`). Shared rules live once: `workerHealth`, `workerSlots`
(`fleet.ts`), `eventSummary`, `eventTone` (`activity.ts`), `inboxTone`
(`inbox.ts`), the word → tone tables (`tones.ts`), page paths (`paths.ts`).
2. State: a hook in `src/state/<page>.ts` reads routes with `useLiveResource`, turns each into a
2. State: a hook in `src/state/<page>.ts` reads routes with `useRouteQuery`, turns each into a
`LoadView` (`resource.ts`: `loadView`, `mapView`, `bothViews`), and maps it through the
builder. It re-exports the view-model types the screen needs.
3. Screen: `src/screens/<page>/index.tsx` exports `<Page>Screen`, wraps content in `Loaded`, and
Expand All @@ -55,6 +55,22 @@ a failed repeat keeps the last values, and the timer stops with the live connect
and `useDaemonFacts` (`state/live.tsx`) give them to state and to the sidebar's update notice
(ADR 0037 §4, ADR 0042).

## Reads

A page reads a route only while it is on screen (`useRouteQuery`, `state/live.tsx`). `state/queries.ts`
holds the TanStack `QueryClient` (the dependency rules keep it out of `data/` and `model/`) and the keys and times of ADR 0044:
`['workers']` every 15 s, `['leases']` and `['lease', id]` 10 s, `['lease-requests']` 5 s,
`['stats', range]` 60 s, `['tokens']` never; a read stays fresh 2 s (stats 30 s, tokens 5 min);
data no page uses is dropped after 60 s. A key polls only while a page uses it, the tab is shown and
the connection is up; a hidden tab or a lost connection reads nothing, and coming back reads each key
on screen once. An event marks keys stale through `model/stale-keys.ts`, and the burst window reads
them once, 300 ms after the last event of a burst (at most 1 s after its first), and again 1.5 s later;
a key no page shows is only marked. `LiveConnection` (`data/live/connection.ts`) keeps the stream, the
phase, the health checks and the clock offset, and does one classified GET for a read
(`connection.read`): a refusal is the route's own answer, a timeout while the daemon starts is asked
again, any other failure is the connection lost (`ConnectionLost`), which a page never shows as an
error. A refused stream is asked for again a second later.

## Actions

An action is described once per kind of item in `model/actions.ts` (`leaseActions`, `requestActions`,
Expand Down
Loading
Loading