diff --git a/docs/internal/DESIGN.md b/docs/internal/DESIGN.md index 6fde08476..5ae304132 100644 --- a/docs/internal/DESIGN.md +++ b/docs/internal/DESIGN.md @@ -133,7 +133,7 @@ put together from them and draws nothing of its own that one of them already dra - **Sidebar.** The console's navigation, on the left of every signed-in page: the "SIMLOCK" wordmark (`--on-accent` on an `--accent` block), one link per page in a fixed order (Overview, - Inbox, then the **Operate** group: Fleet, Leases, Queue; then the **Observe** group: + Inbox, then the **Operate** group: Fleet, Leases, Queue, Baselines; then the **Observe** group: Activity, Usage; each group under its small label, hidden when collapsed), then at the bottom the connection as a status, "Signed in as " (hidden when collapsed, where the Sign out tooltip reads "Sign out ()"), **Sign out** (absent for a proxy person with no sign-out address) and the collapse button. A link is an icon and its label in `--text-muted`; the diff --git a/docs/internal/adr/0061-every-virtual-device-is-a-copy-of-a-source-and-a-baseline-is-a-named-source.md b/docs/internal/adr/0061-every-virtual-device-is-a-copy-of-a-source-and-a-baseline-is-a-named-source.md index 6b5b14eb6..2fe99eb6f 100644 --- a/docs/internal/adr/0061-every-virtual-device-is-a-copy-of-a-source-and-a-baseline-is-a-named-source.md +++ b/docs/internal/adr/0061-every-virtual-device-is-a-copy-of-a-source-and-a-baseline-is-a-named-source.md @@ -1,6 +1,6 @@ # 0061. Every virtual device is a copy of a source, and a baseline is a named source -- **Status:** Accepted — not yet implemented +- **Status:** Accepted - **Date:** 2026-11-02 - **Issue:** [#327](https://github.com/callstackincubator/simlock/issues/327) (request [#1049](https://github.com/callstackincubator/simlock/issues/1049), points 1 and 4) - **Supersedes:** nothing. Narrows [ADR 0001](0001-simlock-owned-device-roots.md) diff --git a/docs/internal/adr/README.md b/docs/internal/adr/README.md index f477bc66d..9e36d472c 100644 --- a/docs/internal/adr/README.md +++ b/docs/internal/adr/README.md @@ -96,4 +96,4 @@ the status is stale. | [0058](0058-through-a-gateway-simlock-adb-and-simctl-send-stdin-only-with-stdin.md) | Through a gateway, `simlock adb` and `simlock simctl` send stdin only with `--stdin` | Accepted; narrows 0005 §19c | | [0059](0059-a-worker-with-real-drivers-holds-a-per-user-lock.md) | A worker with real drivers holds a per-user lock | Accepted; narrows 0034 §1 | | [0060](0060-ios-slim-writes-launchd-overrides-while-the-simulator-is-shut-down.md) | iOS slim writes launchd overrides while the simulator is shut down | Accepted; supersedes 0002 §2, §3, §5, §8, §9 | -| [0061](0061-every-virtual-device-is-a-copy-of-a-source-and-a-baseline-is-a-named-source.md) | Every virtual device is a copy of a source, and a baseline is a named source | Accepted — not yet implemented; narrows 0001 §1, §3, §4, §5, §6, 0007 §6, 0009 §2, §4, §6, 0015 §6–7, 0038 §5 and 0060 §4 | +| [0061](0061-every-virtual-device-is-a-copy-of-a-source-and-a-baseline-is-a-named-source.md) | Every virtual device is a copy of a source, and a baseline is a named source | Accepted; narrows 0001 §1, §3, §4, §5, §6, 0007 §6, 0009 §2, §4, §6, 0015 §6–7, 0038 §5 and 0060 §4 | diff --git a/e2e/console/baselines.spec.ts b/e2e/console/baselines.spec.ts new file mode 100644 index 000000000..1e8826ad5 --- /dev/null +++ b/e2e/console/baselines.spec.ts @@ -0,0 +1,185 @@ +import type { Locator, Page } from "@playwright/test"; + +import { answer } from "./stub-answers.js"; +import { + disposeFleet, + expect, + type RunningDaemon, + signIn, + startDaemon, + startFleet, + test, +} from "./fixtures.js"; + +const IOS = { availableOsVersions: ["18.4"], knownModels: ["iPhone 16"] }; +const LEASE = ["lease", "--platform", "ios", "--device", "iPhone 16", "--os", "18.4"] as const; + +async function leaseId(daemon: RunningDaemon, agent: string, extra: readonly string[] = []) { + const granted = await daemon.cli([...LEASE, "--agent-id", agent, "--detach", ...extra]); + expect(granted.code, granted.stderr).toBe(0); + return (granted.json as { lease: { id: string } }).lease.id; +} + +async function save(daemon: RunningDaemon, name: string, lease: string, note?: string) { + const args = ["baseline", "save", name, "--lease", lease]; + const saved = await daemon.cli(note === undefined ? args : [...args, "--note", note]); + expect(saved.code, saved.stderr).toBe(0); +} + +async function openBaselines(page: Page, daemon: RunningDaemon): Promise { + await page.goto(new URL("/baselines", daemon.url).toString()); + await signIn(page, daemon.tokens.operator); + await expect(page.getByRole("heading", { level: 1, name: "Baselines" })).toBeVisible(); +} + +function row(page: Page, name: string): Locator { + return page.locator("main tbody tr").filter({ hasText: name }); +} + +test.describe("the Baselines page", () => { + test("lists each baseline the worker holds, a save shows its row with no reload", async ({ + page, + }) => { + const host = await startDaemon("worker", { driverScript: { ios: IOS } }); + try { + const lease = await leaseId(host, "saver"); + await openBaselines(page, host); + await expect(page.getByText("No baselines.")).toBeVisible(); + + await save(host, "checkout", lease, "app 4.2"); + + const checkout = row(page, "checkout"); + await expect(checkout).toBeVisible(); + await expect(checkout).toContainText("iOS"); + await expect(checkout).toContainText("iPhone 16"); + await expect(checkout).toContainText("18.4"); + await expect(checkout).toContainText("app 4.2"); + await expect(checkout).toContainText("1 MiB"); + await expect(checkout).toContainText("ago"); + } finally { + await host.dispose(); + } + }); + + test("a second save shows version 2 in place, a lease naming the baseline shows it in the device's drawer, and a removal takes the row away with no reload", async ({ + page, + }) => { + const host = await startDaemon("worker", { driverScript: { ios: IOS } }); + try { + await save(host, "checkout", await leaseId(host, "first"), "app 4.2"); + await openBaselines(page, host); + await expect(row(page, "checkout").getByRole("cell").nth(1)).toHaveText("1"); + + await save(host, "checkout", await leaseId(host, "second")); + await expect(row(page, "checkout").getByRole("cell").nth(1)).toHaveText("2"); + await expect(page.locator("main tbody tr")).toHaveCount(1); + + const copy = await leaseId(host, "copier", ["--baseline", "checkout"]); + const plain = await leaseId(host, "plain"); + const view = await workers(host); + const onCopy = view.leases.find((held) => held.id === copy); + const onPlain = view.leases.find((held) => held.id === plain); + await openDrawer(page, host, view.id, onCopy?.deviceId ?? ""); + await expect(fact(page, "Baseline")).toHaveText("checkout v2"); + await openDrawer(page, host, view.id, onPlain?.deviceId ?? ""); + await expect(page.getByRole("dialog").locator("dt", { hasText: "Runtime" })).toBeVisible(); + await expect(page.getByRole("dialog").locator("dt", { hasText: "Baseline" })).toHaveCount(0); + + await page.goto(new URL("/baselines", host.url).toString()); + for (const id of [copy, plain]) { + const released = await host.cli(["release", id]); + expect(released.code, released.stderr).toBe(0); + } + const removed = await host.cli(["baseline", "remove", "checkout", "--yes"]); + expect(removed.code, removed.stderr).toBe(0); + await expect(page.getByText("No baselines.")).toBeVisible(); + } finally { + await host.dispose(); + } + }); + + test("on a gateway, the Worker column names each worker's baseline and a relayed save shows its row", async ({ + page, + }) => { + const fleet = await startFleet([ + { driverScript: { ios: IOS }, label: "worker-a" }, + { driverScript: { ios: IOS }, label: "worker-b" }, + ]); + try { + const [first, second] = fleet.workers as [RunningDaemon, RunningDaemon]; + await openBaselines(page, fleet.gateway); + + await save(first, "alpha", await leaseId(first, "a")); + await expect(page.getByRole("columnheader", { name: "Worker" })).toBeVisible(); + await expect(row(page, "alpha")).toContainText("worker-a"); + await save(second, "beta", await leaseId(second, "b")); + await expect(row(page, "beta")).toContainText("worker-b"); + await expect(page.locator("main tbody tr")).toHaveCount(2); + } finally { + await disposeFleet(fleet); + } + }); + + test("a stale baseline shows the square, stale and its reason word for word, an entry with no size shows a dash, and 26 entries page at 25", async ({ + page, + }) => { + const host = await startDaemon("worker", { driverScript: { ios: IOS } }); + try { + const reason = "runtime com.apple.CoreSimulator.SimRuntime.iOS-27-0 is not installed"; + const entries = Array.from({ length: 26 }, (_, index) => ({ + mode: "full", + model: "iPhone 16", + name: `base-${String(index).padStart(2, "0")}`, + osVersion: "18.4", + platform: "ios", + savedAt: new Date(Date.now() - 3_600_000).toISOString(), + version: 1, + ...(index === 0 ? { staleReason: reason } : { sizeBytes: 2 ** 20 }), + })); + await answer(page, "/v1/baselines", () => ({ baselines: entries })); + await openBaselines(page, host); + + await expect(page.locator("main tbody tr")).toHaveCount(25); + await expect(row(page, "base-25")).toHaveCount(0); + const stale = row(page, "base-00"); + await expect(stale).toContainText(`stale · ${reason}`); + await expect(stale.locator(".status.status-warn")).toHaveText("stale"); + await expect(stale).toContainText("—"); + await page.getByRole("button", { name: "Next" }).click(); + await expect(row(page, "base-25")).toBeVisible(); + } finally { + await host.dispose(); + } + }); +}); + +interface WorkerFacts { + readonly id: string; + readonly leases: readonly { readonly id: string; readonly deviceId: string }[]; +} + +async function workers(daemon: RunningDaemon): Promise { + const listed = await daemon.cli(["worker", "list", "--json"]); + expect(listed.code, listed.stderr).toBe(0); + const [view] = (listed.json as { workers: WorkerFacts[] }).workers; + if (view === undefined) throw new Error("no worker"); + return view; +} + +async function openDrawer( + page: Page, + daemon: RunningDaemon, + workerId: string, + deviceId: string, +): Promise { + const path = `/fleet/${encodeURIComponent(workerId)}/${encodeURIComponent(deviceId)}?tab=devices`; + await page.goto(new URL(path, daemon.url).toString()); + await expect(page.getByRole("dialog")).toBeVisible(); +} + +function fact(scope: Page, label: string): Locator { + return scope + .getByRole("dialog") + .locator("dt", { hasText: label }) + .locator("xpath=following-sibling::dd[1]"); +} diff --git a/e2e/console/big-fleet.spec.ts b/e2e/console/big-fleet.spec.ts index 205c378af..7ae6ea60d 100644 --- a/e2e/console/big-fleet.spec.ts +++ b/e2e/console/big-fleet.spec.ts @@ -40,7 +40,7 @@ function isCounted(request: Request): boolean { } 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 ({ + test("an idle console on a big fleet stays quiet: for each of the eight pages, in the 60 s after its first reads, at most 40 requests to /v1 other than the event stream", async ({ gateway, page, }) => { diff --git a/e2e/console/fixtures.ts b/e2e/console/fixtures.ts index 44618a63d..976315781 100644 --- a/e2e/console/fixtures.ts +++ b/e2e/console/fixtures.ts @@ -752,12 +752,13 @@ export const NAV = [ { label: "Fleet", path: "/fleet" }, { label: "Leases", path: "/leases" }, { label: "Queue", path: "/queue" }, + { label: "Baselines", path: "/baselines" }, { label: "Activity", path: "/activity" }, { label: "Usage", path: "/usage" }, ] as const; export const NAV_GROUPS = [ - { label: "Operate", links: ["Fleet", "Leases", "Queue"] }, + { label: "Operate", links: ["Fleet", "Leases", "Queue", "Baselines"] }, { label: "Observe", links: ["Activity", "Usage"] }, ] as const; diff --git a/e2e/console/shell.spec.ts b/e2e/console/shell.spec.ts index 227735a9f..8ca22caed 100644 --- a/e2e/console/shell.spec.ts +++ b/e2e/console/shell.spec.ts @@ -165,6 +165,7 @@ test.describe("the shell", () => { "Fleet /fleet", "Leases /leases", "Queue /queue", + "Baselines /baselines", "Activity /activity", "Usage /usage", ]); diff --git a/e2e/helpers/env.test.ts b/e2e/helpers/env.test.ts index a449615af..00ece0a79 100644 --- a/e2e/helpers/env.test.ts +++ b/e2e/helpers/env.test.ts @@ -11,9 +11,13 @@ const execFileAsync = promisify(execFile); // why: teardown after a failure is seen only from a vitest run of its own. Its flows fail as the // slow-lane runs of #1102 did (a shared daemon's beforeAll, as warmUp; a per-test daemon's test) -// and record each socket path, which the daemon's "Daemon started" log line names. +// and record each socket path, which the daemon's "Daemon started" log line names. The shared +// daemon's home is torn down twice, the second time over an empty daemon.log, as in a slow-lane +// run whose suite teardown failed on an emulator still writing into the home and the file's +// afterAll then tore it down again. const FAILING_FLOWS = ` -import { appendFile } from "node:fs/promises"; +import { appendFile, mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; import { withDaemon, withSharedDaemon } from ${JSON.stringify(join(REPO_ROOT, "e2e/helpers/env.ts"))}; const record = (env) => appendFile(process.env.FIXTURE_SOCKETS, env.socketPath + "\\n"); @@ -28,7 +32,10 @@ describe("a suite whose beforeAll fails, as warmUp does", () => { throw new Error("waitFor timed out (2 warm-up devices ready; last observed: shutdown,ready)"); }); afterAll(async () => { - await env?.teardown(); + await env.teardown(); + await mkdir(env.dataDirectory, { recursive: true }); + await writeFile(join(env.dataDirectory, "daemon.log"), ""); + await env.teardown(); }); it("never runs", () => {}); }); @@ -52,7 +59,7 @@ async function filesUnder(directory: string): Promise { } describe("withDaemon and withSharedDaemon teardown", () => { - it("after a run whose flows fail, leaves the log of each daemon it started in SIMLOCK_E2E_LOG_DIR", async () => { + it("after a run whose flows fail, leaves the log of each daemon it started in SIMLOCK_E2E_LOG_DIR, and a second teardown of the same home does not replace it", async () => { const work = await mkdtemp(join(tmpdir(), "simlock-e2e-kept-logs-")); try { const logDirectory = join(work, "daemon-logs"); diff --git a/e2e/helpers/env.ts b/e2e/helpers/env.ts index d140a3501..208419a94 100644 --- a/e2e/helpers/env.ts +++ b/e2e/helpers/env.ts @@ -478,7 +478,9 @@ interface PidDiscoveryResult { /** * why: a failed run's daemon logs are the evidence of why it failed, and teardown deletes the - * home they live in. A failed copy is swallowed so it never hides teardown's own error. + * home they live in. A failed copy is swallowed so it never hides teardown's own error. A copy + * already kept is never replaced: a teardown that runs again (the file's afterAll after a suite's + * failed one) finds only what was written into the home after the first, often an empty log. */ async function keepDaemonLogs(dataDirectory: string, home: string): Promise { const logDirectory = process.env.SIMLOCK_E2E_LOG_DIR; @@ -487,7 +489,9 @@ async function keepDaemonLogs(dataDirectory: string, home: string): Promise { if (discovery.pids.length === 0 && discovery.error === undefined) return undefined; try { + // why: `daemon stop` answers when the stop starts; the daemon first settles the boot of the pool's next device and shuts idle devices down, which takes 15 s on a real simulator. await waitFor(() => !existsSync(socketPath), { - timeout: 5_000, + timeout: 60_000, label: "daemon socket removed during teardown", }); return undefined; diff --git a/e2e/slow-agent-device.test.ts b/e2e/slow-agent-device.test.ts index 9aba0b336..5695486cc 100644 --- a/e2e/slow-agent-device.test.ts +++ b/e2e/slow-agent-device.test.ts @@ -208,7 +208,8 @@ function defineSuite(target: Target): void { const released = await env.cli(["release", "--all", "--yes"], { timeout: 60_000 }); expect(released.code, released.stderr).toBe(0); await waitForFree(env); - }, 300_000); + // why: longer than the cleanup (up to 100 s), the release (60 s) and waitForFree's own DEVICE_READY_MS together, so a device that never turns ready fails on waitForFree's label, which names what it last saw, instead of on a bare hook timeout. + }, DEVICE_READY_MS + 180_000); it( "an agent opens a device, installs the Appduct fixture app as an upload, opens it by its bundle id, takes a snapshot that shows tab-status, clicks tab-status, saves a PNG screenshot and closes; within 240 s the device is free", diff --git a/e2e/slow-android-smoke.test.ts b/e2e/slow-android-smoke.test.ts index d64a4f3cd..7e9d2463b 100644 --- a/e2e/slow-android-smoke.test.ts +++ b/e2e/slow-android-smoke.test.ts @@ -312,8 +312,14 @@ describe.skipIf(!hasAndroidSdk)( { timeout: DEVICE_READY_MS }, ); expect(granted.code, `lease failed: ${granted.stderr}`).toBe(0); - return granted.json as { device: { driverDeviceId: string }; lease: { id: string } }; + return granted.json as { + device: { address?: string; driverDeviceId: string }; + lease: { id: string }; + }; }; + // why: a worker's `simlock adb` has no lease to aim at (`--lease` is a gateway's), and its adb server lists every emulator, so each call names the lease's own serial. + const adbOn = (grant: { device: { address?: string } }, ...args: string[]) => + env.cli(["adb", "-s", grant.device.address ?? "", ...args]); const [first, second] = await Promise.all([lease("seed-one"), lease("seed-two")]); @@ -324,14 +330,7 @@ describe.skipIf(!hasAndroidSdk)( for (const grant of [first, second]) { expect(existsSync(join(root, grant.device.driverDeviceId, "simlock.avd"))).toBe(true); } - const touched = await env.cli([ - "adb", - "--lease", - first.lease.id, - "shell", - "touch", - "/data/local/tmp/t2", - ]); + const touched = await adbOn(first, "shell", "touch", "/data/local/tmp/t2"); expect(touched.code, touched.stderr).toBe(0); for (const grant of [first, second]) { @@ -348,14 +347,7 @@ describe.skipIf(!hasAndroidSdk)( expect([first, second].map((grant) => grant.device.driverDeviceId)).not.toContain( third.device.driverDeviceId, ); - const listed = await env.cli([ - "adb", - "--lease", - third.lease.id, - "shell", - "ls", - "/data/local/tmp/t2", - ]); + const listed = await adbOn(third, "shell", "ls", "/data/local/tmp/t2"); expect(listed.code, "the file written on the first lease is not on a later one").not.toBe( 0, ); diff --git a/e2e/slow-android-tunnel.test.ts b/e2e/slow-android-tunnel.test.ts index abc4a6ed5..e2b059037 100644 --- a/e2e/slow-android-tunnel.test.ts +++ b/e2e/slow-android-tunnel.test.ts @@ -608,11 +608,18 @@ describe.skipIf(!hasAndroidSdk)( .filter((line) => line.startsWith(`${lease.serial} `)) .join("\n"), }; - return devices.code === 0 && forward.code === 0; + // why: adb lists a killed emulator as `offline` for a few seconds, so the read ends when it is gone, not when adb first answers. + return ( + devices.code === 0 && + forward.code === 0 && + left.devices === "" && + left.forward === "" + ); }, { interval: 2_000, - label: "the worker's adb server answering after the emulator was deleted", + label: () => + `the worker's adb server listing nothing of the lease's serial; last read ${JSON.stringify(left)}`, timeout: 120_000, }, ); diff --git a/e2e/slow-baseline.test.ts b/e2e/slow-baseline.test.ts index b89729924..f25fd0a4a 100644 --- a/e2e/slow-baseline.test.ts +++ b/e2e/slow-baseline.test.ts @@ -57,8 +57,9 @@ describe.skipIf(process.platform !== "darwin")( }; const deviceSet = grant.environment.SIMLOCK_IOS_DEVICE_SET as string; const udid = grant.device.driverDeviceId; - const marker = join(deviceSet, udid, "data", "tmp", "simlock-baseline.txt"); - await mkdir(join(deviceSet, udid, "data", "tmp"), { recursive: true }); + // why: CoreSimulator empties `data/tmp` when a simulator shuts down, so the marker sits in `data/Documents`, which a shutdown keeps. + const marker = join(deviceSet, udid, "data", "Documents", "simlock-baseline.txt"); + await mkdir(join(deviceSet, udid, "data", "Documents"), { recursive: true }); await writeFile(marker, "prepared"); const saved = await env.cli(["baseline", "save", "slow-ios", "--lease", grant.lease.id], { @@ -75,7 +76,10 @@ describe.skipIf(process.platform !== "darwin")( const sourcesSets = [join(deviceSet, "sources"), join(env.home, "devices", "ios-sources")]; const sourcesSet = sourcesSets.find((candidate) => existsSync(candidate)); expect(sourcesSet, "no sources set exists after a save").toBeDefined(); - const inSources = await setDevices(sourcesSet as string); + // why: the sources set also holds the seed the next device is copied from; the baseline's clone is the one named `simlock-baseline-`. + const inSources = (await setDevices(sourcesSet as string)).filter((device) => + device.name.startsWith("simlock-baseline-"), + ); expect(inSources).toHaveLength(1); const source = inSources[0]; const inDeviceSet = await setDevices(iosDeviceSet(env.home)); @@ -83,7 +87,7 @@ describe.skipIf(process.platform !== "darwin")( const simctlList = await env.cli(["simctl", "list", "devices"]); expect(simctlList.stdout).not.toContain(source?.udid ?? "no source"); expect( - await readdir(join(sourcesSet as string, source?.udid ?? "", "data", "tmp")), + await readdir(join(sourcesSet as string, source?.udid ?? "", "data", "Documents")), ).toContain("simlock-baseline.txt"); // why: a lease naming the baseline starts from a copy that holds the saved file, and the copy's own changes are gone for the next one. @@ -116,7 +120,7 @@ describe.skipIf(process.platform !== "darwin")( copied.environment.SIMLOCK_IOS_DEVICE_SET as string, copied.device.driverDeviceId, "data", - "tmp", + "Documents", ), leaseId: copied.lease.id, }; @@ -149,22 +153,25 @@ describe.skipIf(!hasAndroidSdk)( { timeout: DEVICE_READY_MS }, ); expect(lease.code, `lease failed: ${lease.stderr}`).toBe(0); - const grant = lease.json as { lease: { id: string } }; - const written = await env.cli([ - "adb", + const grant = lease.json as { device: { address: string }; lease: { id: string } }; + // why: a worker's `simlock adb` has no lease to aim at, and its adb server also lists the pool's spare emulator, so each call names the lease's serial. + const adbOn = (address: string, ...args: string[]) => + env.cli(["adb", "-s", address, ...args]); + const written = await adbOn( + grant.device.address, "shell", "settings", "put", "global", "simlock_baseline", "1", - ]); + ); expect(written.code, `settings put failed: ${written.stderr}`).toBe(0); - const file = await env.cli([ - "adb", + const file = await adbOn( + grant.device.address, "shell", "echo saved > /data/local/tmp/simlock-baseline.txt", - ]); + ); expect(file.code, `writing the file failed: ${file.stderr}`).toBe(0); const saved = await env.cli( @@ -187,7 +194,7 @@ describe.skipIf(!hasAndroidSdk)( ).toBe(true); // why: a lease naming the baseline starts from a copy that holds the saved setting and file, and the copy's own changes are gone for the next one. - const copyLease = async (): Promise => { + const copyLease = async (): Promise<{ address: string; id: string }> => { const copy = await env.cli( [ "lease", @@ -202,49 +209,50 @@ describe.skipIf(!hasAndroidSdk)( { timeout: DEVICE_READY_MS }, ); expect(copy.code, `lease naming the baseline failed: ${copy.stderr}`).toBe(0); - return (copy.json as { lease: { id: string } }).lease.id; + const copied = copy.json as { device: { address: string }; lease: { id: string } }; + return { address: copied.device.address, id: copied.lease.id }; }; - const setting = async (): Promise => { - const read = await env.cli([ - "adb", + const setting = async (address: string): Promise => { + const read = await adbOn( + address, "shell", "settings", "get", "global", "simlock_baseline", - ]); + ); return read.stdout.trim(); }; // why: a missing file prints nothing to stdout, so the read answers an empty string for it. - const fileText = async (name: string): Promise => { - const read = await env.cli(["adb", "shell", `cat /data/local/tmp/${name} 2>/dev/null`]); + const fileText = async (address: string, name: string): Promise => { + const read = await adbOn(address, "shell", `cat /data/local/tmp/${name} 2>/dev/null`); return read.stdout.trim(); }; const copyId = await copyLease(); - expect(await setting()).toBe("1"); - expect(await fileText("simlock-baseline.txt")).toBe("saved"); - const changed = await env.cli([ - "adb", + expect(await setting(copyId.address)).toBe("1"); + expect(await fileText(copyId.address, "simlock-baseline.txt")).toBe("saved"); + const changed = await adbOn( + copyId.address, "shell", "settings", "put", "global", "simlock_baseline", "2", - ]); + ); expect(changed.code, `settings put failed: ${changed.stderr}`).toBe(0); - const left = await env.cli([ - "adb", + const left = await adbOn( + copyId.address, "shell", "echo left > /data/local/tmp/simlock-session.txt", - ]); + ); expect(left.code, `writing the file failed: ${left.stderr}`).toBe(0); - const released = await env.cli(["release", copyId]); + const released = await env.cli(["release", copyId.id]); expect(released.code, `release failed: ${released.stderr}`).toBe(0); - await copyLease(); - expect(await setting()).toBe("1"); - expect(await fileText("simlock-baseline.txt")).toBe("saved"); - expect(await fileText("simlock-session.txt")).toBe(""); + const second = await copyLease(); + expect(await setting(second.address)).toBe("1"); + expect(await fileText(second.address, "simlock-baseline.txt")).toBe("saved"); + expect(await fileText(second.address, "simlock-session.txt")).toBe(""); }, ); }, diff --git a/src/drivers/AGENTS.md b/src/drivers/AGENTS.md index d08735aa9..73bf65658 100644 --- a/src/drivers/AGENTS.md +++ b/src/drivers/AGENTS.md @@ -17,7 +17,7 @@ Manuals: [Android](android/AGENTS.md), [iOS](ios/AGENTS.md). Flows: [device sele | `findComponent`, `installComponent`, `listComponents`, `removeComponent` | Runtimes / system images. Only core's `ComponentInstaller` installs or removes | | `inspectPhysical`, `listPresentPhysical` | Enrollment read and presence of physical devices | | `leaseEnvironment`, `virtualLeaseEnvironment`, `passthrough` | What a holder needs to reach its device | -| `dispose` | Daemon stop. Android reaps its adb server here | +| `dispose` | Daemon stop. Android reaps its adb server here, unless an emulator is still attached | | `screenCapture` (optional) | A device's screen as core's frame envelope (`core/screen.ts`); absent = cannot stream | ## Rules both drivers keep diff --git a/src/drivers/android/AGENTS.md b/src/drivers/android/AGENTS.md index 8667b81ff..3e06c594a 100644 --- a/src/drivers/android/AGENTS.md +++ b/src/drivers/android/AGENTS.md @@ -45,9 +45,9 @@ Simlock's adb server — `adb -P nodaemon server`, env `ADB_EMU=0` (no sc 1. Port listening + live record → adopt. Listening, no record → `occupied`. 2. Record alive, port silent → reap. Then spawn, write the record, wait for the port → else `start-failed`. 3. Reap = its command line (`ProcessSupervisor.commandLine`) must show `adb` or `adb.exe`, `-P `, `nodaemon`; SIGTERM, then SIGKILL. An unreadable one keeps the record. -4. `dispose` reaps it. Emulators are spawned unref'd and outlive the daemon. +4. `dispose` reaps it, unless `adb devices` still lists an emulator: then it leaves the server and its record for the next daemon to adopt, because an emulator whose server is gone starts an adb `fork-server` on the port, which the next start refuses as `occupied`. Emulators are spawned unref'd and outlive the daemon. -Provision — `provision`: every device is a copy of a seed. +Provision — `provision`: every device is a copy of a seed, or of the ready baseline source named by `options.source` (a lease naming a baseline): then steps 1–3 are skipped and step 4 copies that source's folder. 1. Profile from the `resolveSpec` cache, else `DeviceProfileRegistry.resolve`. Image: installed, fitting, at the spec's API level. Order: host-ABI `google_apis` → host ABI → `google_apis` → any. With an image tag: that tag only, host ABI first. 2. Seed for `|`: a `ready` row recomputed on each provision (`emulator -version`, installed images). Stale (`system image … is not installed` / `changed from revision …`, `emulator changed from … to …`, `android.emulator launch settings changed`) → destroyed and made again; the reason is stored by `listSources` and clears when things match. One make per kind at a time (lock); a `making` row of an earlier process, or older than 900 s, is swept (its emulator stopped by its path) at the next listing or make of that kind. diff --git a/src/drivers/android/adb-server.ts b/src/drivers/android/adb-server.ts index d818f837f..027581238 100644 --- a/src/drivers/android/adb-server.ts +++ b/src/drivers/android/adb-server.ts @@ -157,6 +157,16 @@ export class AdbServerSupervisor { await this.#reap(record); } + /** + * why: ends this daemon's hold on the server without ending the server, the way a daemon that + * died leaves it: the record stays on disk, so the next daemon adopts it. A server reaped + * under a running emulator is replaced within seconds by a `fork-server` the emulator starts + * on the same port, which the next start cannot tell from a stranger's (`occupied`). + */ + leave(): void { + this.#record = undefined; + } + #requireUsablePort(): void { const { port } = this.#options; diff --git a/src/drivers/android/index.test.ts b/src/drivers/android/index.test.ts index c03201300..c3adad4c8 100644 --- a/src/drivers/android/index.test.ts +++ b/src/drivers/android/index.test.ts @@ -3883,36 +3883,54 @@ describe("AndroidDriver.create", () => { } }); - it("stops the adb server it adopted when the daemon disposes of it", async () => { - const filesystem = await androidFilesystem(); - const processSupervisor = new FakeProcessSupervisor([adbServerPid]); - processSupervisor.answerCommandLine( - adbServerPid, - `${binaries.adb} -P ${adbServerPort} nodaemon server`, - ); - await recordRunningAdbServer(filesystem); - const driver = await AndroidDriver.create({ - clock: new FakeClock(), - database: await memoryAndroidSourceDatabase(), - driverConfig: {}, - env: { ANDROID_HOME: sdk }, - filesystem, - homeDirectory: home, - hostAbi: "arm64-v8a", - instanceId, - processRunner: new EmulatorExitingRunner([]), - processSupervisor, - simlockHome, - tcpProbe: new FakeTcpProbe([adbServerPort]), - }); - processSupervisor.markDead(adbServerPid); + it.each([ + { + attached: "no emulator is attached", + devices: "List of devices attached\n", + signals: [{ pid: adbServerPid, signal: "SIGTERM" }], + recordKept: false, + }, + { + // why: an emulator that outlives the daemon starts its own `fork-server` on the port once the server is gone, and the next daemon refuses that one as occupied. + attached: "an emulator is still attached", + devices: "List of devices attached\nemulator-5586\tdevice\n", + signals: [], + recordKept: true, + }, + ])( + "when the daemon disposes of the adb server it adopted and $attached, the server gets $signals and the record is kept: $recordKept", + async ({ devices, recordKept, signals }) => { + const filesystem = await androidFilesystem(); + const processSupervisor = new FakeProcessSupervisor([adbServerPid]); + processSupervisor.answerCommandLine( + adbServerPid, + `${binaries.adb} -P ${adbServerPort} nodaemon server`, + ); + await recordRunningAdbServer(filesystem); + const runner = new EmulatorExitingRunner([processResult(binaries.adb, ["devices"], devices)]); + const driver = await AndroidDriver.create({ + clock: new FakeClock(), + database: await memoryAndroidSourceDatabase(), + driverConfig: {}, + env: { ANDROID_HOME: sdk }, + filesystem, + homeDirectory: home, + hostAbi: "arm64-v8a", + instanceId, + processRunner: runner, + processSupervisor, + simlockHome, + tcpProbe: new FakeTcpProbe([adbServerPort]), + }); + processSupervisor.markDead(adbServerPid); - await driver.dispose(); + await driver.dispose(); - // why: By pid, because `ADB_REJECT_KILL_SERVER=1` means `adb kill-server` refuses Simlock too. - expect(processSupervisor.signals).toEqual([{ pid: adbServerPid, signal: "SIGTERM" }]); - await expect(filesystem.exists(adbRecordPath)).resolves.toBe(false); - }); + // why: By pid, because `ADB_REJECT_KILL_SERVER=1` means `adb kill-server` refuses Simlock too. + expect(processSupervisor.signals).toEqual(signals); + await expect(filesystem.exists(adbRecordPath)).resolves.toBe(recordKept); + }, + ); }); describe("AndroidDriver ownership", () => { @@ -6416,6 +6434,25 @@ describe("AndroidDriver image fit", () => { processResult(binaries.adb, ["-s", serial, "shell", "reboot", "-p"]), ]; + it("provisions a device named after a baseline source as a copy of that source's folder, with no seed made and no tool run", async () => { + const harness = await seedHarness([...leasing(true), ...savingSteps, ...sourceBoot()], { + config, + ids: [...ids, "five"], + }); + const ready = await harness.driver.makeReady(await harness.driver.provision(harness.spec)); + await harness.driver.saveSource({ ...ready, physical: false as const }); + await harness.filesystem.writeFileAtomic(`${savedHome}/simlock.avd/marker.txt`, "saved"); + const callsBefore = harness.runner.calls.length; + + const copy = await harness.driver.provision(harness.spec, { source: "baseline_four" }); + + expect(copy.deviceId).toBe("simlock_five"); + await expect( + harness.filesystem.readFile(`${avdDirectory}/simlock_five/simlock.avd/marker.txt`), + ).resolves.toBe("saved"); + expect(harness.runner.calls).toHaveLength(callsBefore); + }); + it("keeps a powered-off copy of the device as a ready baseline source with one boot snapshot, powering the emulator off first, or killing it when it will not leave in 60 s, or polling adb with no process handle", async () => { const kept = async (harness: Awaited>) => { expect(await harness.filesystem.exists(`${savedHome}/simlock.avd/config.ini`)).toBe(true); diff --git a/src/drivers/android/index.ts b/src/drivers/android/index.ts index a29ffd774..ba3beb87f 100644 --- a/src/drivers/android/index.ts +++ b/src/drivers/android/index.ts @@ -992,9 +992,31 @@ export class AndroidDriver implements Driver { * shutdown that skipped this would leave both a server and a daemon that cannot exit. */ async dispose(): Promise { + if (await this.#emulatorAttached()) { + this.#adbServer.leave(); + return; + } await this.#adbServer.stop(); } + /** + * why: an emulator that outlives the daemon starts its own adb `fork-server` on this port + * once the server is gone, and the next daemon refuses that one as `occupied`; so the server + * stays while an emulator is attached and the next daemon adopts it. An unreadable list + * counts as none: the server is reaped as it always was. + */ + async #emulatorAttached(): Promise { + try { + const result = await this.#processRunner.run(this.#sdk.adb, ["devices"], { + env: this.#env(), + timeoutMs: ADB_DEVICES_TIMEOUT_MS, + }); + return result.code === 0 && portsFromAdbDevices(result.stdout).length > 0; + } catch { + return false; + } + } + /** * why: The single insertion point for Android's scoping, the way `--set` is iOS's: every * `adb`, `emulator`, and `avdmanager` invocation this driver makes carries both keys, so @@ -1401,8 +1423,12 @@ export class AndroidDriver implements Driver { ); } - async provision(spec: VirtualSpec): Promise { + async provision( + spec: VirtualSpec, + options: { readonly source?: string } = {}, + ): Promise { this.#assertAndroidSpec(spec); + if (options.source !== undefined) return await this.#provisionFromSource(options.source); const profile = await this.#profileFor(spec.model); const image = await this.#requireImage(profile, spec.osVersion, spec.imageTag); // why: Cosmetic only, and worth saying so: the prefix is a label that names the device's @@ -1443,6 +1469,39 @@ export class AndroidDriver implements Driver { return { address: "", deviceId, driverData }; } + /** + * why: ADR 0061 §4: a lease naming a baseline starts from a copy of that baseline's source, which + * holds the saved disk and its boot snapshot; the kind's seed is not touched, and the spec was + * already checked against the baseline by the coordinator. + */ + async #provisionFromSource(sourceId: string): Promise { + const row = this.#sources.readyRows().find((candidate) => candidate.sourceId === sourceId); + if (row === undefined) { + throw new DriverCrashError(`Android keeps no ready source ${sourceId}`); + } + const deviceId = `simlock_${this.#idGenerator.generate()}`; + const avdHome = join(this.#deviceRoot, deviceId); + await this.#copyAvd( + row.folder, + avdHome, + `copying Android source ${sourceId} to ${basename(avdHome)} failed`, + ); + const driverData: AndroidDriverData = { + avdHome, + avdName: SEED_AVD_NAME, + configHash: row.configHash, + imageIdentity: row.imageIdentity, + port: 0, + serial: "", + }; + this.#devices.set(this.#avdFolder(driverData), { + handle: undefined, + imageIdentity: row.imageIdentity, + port: undefined, + }); + return { address: "", deviceId, driverData }; + } + /** * why: A seed is the source every Android device of one kind is copied from: made once, booted * twice (the second boot starts from a disk that was powered off cleanly), saved as one boot diff --git a/src/ports/filesystem.test.ts b/src/ports/filesystem.test.ts index ac9b51703..61ea824a2 100644 --- a/src/ports/filesystem.test.ts +++ b/src/ports/filesystem.test.ts @@ -1,4 +1,4 @@ -import { stat, symlink, utimes, writeFile } from "node:fs/promises"; +import { lstat, stat, symlink, utimes, writeFile } from "node:fs/promises"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -9,6 +9,12 @@ import { ScriptedProcessRunner, } from "./index.js"; +// why: lets one test make a listed file vanish before it is sized; every other call goes to the real `lstat`. +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, lstat: vi.fn(actual.lstat) }; +}); + // why: Per process: `afterEach` deletes it, so a shared name let two overlapping runs (two suites in // one checkout, Stryker's runners in one sandbox) delete each other's files mid-test. const temporaryDirectory = `${process.cwd()}/.simlock-ports-test-${process.pid}`; @@ -319,6 +325,28 @@ describe.each(implementations)("Filesystem ownership contract: $name", ({ create }); }); +describe("NodeFilesystem.directorySize", () => { + it("counts a file that vanishes between the listing and its stat as 0 bytes, and fails on any other error", async () => { + const filesystem = new NodeFilesystem(); + const root = `${temporaryDirectory}/set`; + await filesystem.mkdirp(root); + await filesystem.writeFileAtomic(`${root}/device.plist`, "abcd"); + await filesystem.writeFileAtomic(`${root}/device.plist.sb-1`, "xy"); + const real = vi.mocked(lstat).getMockImplementation() as typeof lstat; + const failWith = (code: string) => + vi.mocked(lstat).mockImplementation((async (path: string, ...rest: never[]) => { + if (path.endsWith(".sb-1")) throw Object.assign(new Error(code), { code }); + return await real(path, ...rest); + }) as typeof lstat); + + failWith("ENOENT"); + await expect(filesystem.directorySize(root)).resolves.toBe(4); + + failWith("EACCES"); + await expect(filesystem.directorySize(root)).rejects.toMatchObject({ code: "EACCES" }); + }); +}); + describe("MemoryFilesystem", () => { it("resolves symlinked path components, not just the last one", async () => { const filesystem = new MemoryFilesystem(); diff --git a/src/ports/filesystem.ts b/src/ports/filesystem.ts index c18daba09..b44167389 100644 --- a/src/ports/filesystem.ts +++ b/src/ports/filesystem.ts @@ -336,12 +336,25 @@ export class NodeFilesystem implements Filesystem { }; } + /** + * why: a file listed a moment ago may be gone (simctl's atomic `device.plist.sb-*` write), and a + * size that fails on it leaves a saved source with no size; a vanished file counts for 0. + */ + async #sizeOfFile(path: string): Promise { + try { + return (await lstat(path)).size; + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return 0; + throw error; + } + } + async directorySize(path: string): Promise { let total = 0; for (const entry of await readdir(path, { withFileTypes: true })) { const child = join(path, entry.name); if (entry.isDirectory()) total += await this.directorySize(child); - else if (entry.isFile()) total += (await lstat(child)).size; + else if (entry.isFile()) total += await this.#sizeOfFile(child); } return total; } diff --git a/ui/AGENTS.md b/ui/AGENTS.md index cc85c8c79..a16e17ae4 100644 --- a/ui/AGENTS.md +++ b/ui/AGENTS.md @@ -61,7 +61,7 @@ The Inbox is the daemon's: `GET /v1/inbox` (`inbox.get`, rules in `src/contract/ 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']`, `['lease', id]` and `['inbox']` 10 s, `['lease-requests']` 5 s, +`['workers']` and `['baselines']` every 15 s, `['leases']`, `['lease', id]` and `['inbox']` 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 @@ -105,17 +105,18 @@ not (its highlight is an index it resets when the items change), so the listbox ## Pages -| Path | Screen | Layout | -| ---------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `/` | Overview | range chips in the toolbar, five stat tiles, Needs you, capacity, two charts, workers table | -| `/fleet[/[/]]` | Fleet | table; a worker opens as its own page (load, capacity, leases, tabs Devices, Warm pool, Host, Events in `?tab=`); a device opens in a `Drawer` (Details, Screen; `?drawer=screen` holds the tab) | -| `/leases[/]` | Leases | toolbar (filters, search) + table; the lease opens in a `Drawer` (Details, Events) | -| `/queue[/]` | Queue | Wait p95 tile + table; a request opens in a `Drawer` | -| `/inbox[/]` | Inbox | toolbar (group chips) + a panel per group; an item opens in a `Drawer` (what happened, facts, events) | -| `/activity[/]` | Activity | chart, toolbar (type chips, search) + event feed that scrolls back through the history; an event opens in a `Drawer` with the payload | -| `/usage` | Usage | range chips in the toolbar, stat tiles, the Device slots in use chart, panels of tables (workers, requesters page in the URL) | - -The sidebar groups the pages: Overview and Inbox, then Operate (Fleet, Leases, Queue), then +| Path | Screen | Layout | +| ---------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `/` | Overview | range chips in the toolbar, five stat tiles, Needs you, capacity, two charts, workers table | +| `/fleet[/[/]]` | Fleet | table; a worker opens as its own page (load, capacity, leases, tabs Devices, Warm pool, Host, Events in `?tab=`); a device opens in a `Drawer` (Details, Screen; `?drawer=screen` holds the tab) | +| `/leases[/]` | Leases | toolbar (filters, search) + table; the lease opens in a `Drawer` (Details, Events) | +| `/queue[/]` | Queue | Wait p95 tile + table; a request opens in a `Drawer` | +| `/baselines` | Baselines | one table, a row per baseline (a Worker column on a gateway); read-only | +| `/inbox[/]` | Inbox | toolbar (group chips) + a panel per group; an item opens in a `Drawer` (what happened, facts, events) | +| `/activity[/]` | Activity | chart, toolbar (type chips, search) + event feed that scrolls back through the history; an event opens in a `Drawer` with the payload | +| `/usage` | Usage | range chips in the toolbar, stat tiles, the Device slots in use chart, panels of tables (workers, requesters page in the URL) | + +The sidebar groups the pages: Overview and Inbox, then Operate (Fleet, Leases, Queue, Baselines), then Observe (Activity, Usage); a route's `group` in `routes.ts` puts it in one. Leases, Queue, Inbox and Activity show the list alone, and the chosen item opens in a drawer over it diff --git a/ui/src/app/routes.test.ts b/ui/src/app/routes.test.ts index 59f6da8de..bad3b880e 100644 --- a/ui/src/app/routes.test.ts +++ b/ui/src/app/routes.test.ts @@ -23,6 +23,7 @@ describe("the console's routes", () => { "/fleet", "/leases", "/queue", + "/baselines", "/activity", "/usage", ]); diff --git a/ui/src/app/routes.ts b/ui/src/app/routes.ts index 052b11262..53e7a90bf 100644 --- a/ui/src/app/routes.ts +++ b/ui/src/app/routes.ts @@ -6,6 +6,7 @@ import { ActivityScreen } from "../screens/activity"; import { LeasesScreen } from "../screens/leases"; import { OverviewScreen } from "../screens/overview"; import { UsageScreen } from "../screens/usage"; +import { BaselinesScreen } from "../screens/baselines"; import { QueueScreen } from "../screens/queue"; import { FleetScreen } from "../screens/fleet"; @@ -25,6 +26,13 @@ export const ROUTES: readonly Route[] = [ { Screen: FleetScreen, group: "Operate", icon: "workers", label: "Fleet", path: "/fleet" }, { Screen: LeasesScreen, group: "Operate", icon: "leases", label: "Leases", path: "/leases" }, { Screen: QueueScreen, group: "Operate", icon: "waiting", label: "Queue", path: "/queue" }, + { + Screen: BaselinesScreen, + group: "Operate", + icon: "baselines", + label: "Baselines", + path: "/baselines", + }, { Screen: ActivityScreen, group: "Observe", diff --git a/ui/src/ds/icon.tsx b/ui/src/ds/icon.tsx index 2ff484a93..82713cf4c 100644 --- a/ui/src/ds/icon.tsx +++ b/ui/src/ds/icon.tsx @@ -3,6 +3,7 @@ const PATHS = { workers: "M2 3h12v4H2zM2 9h12v4H2zM5 5h.01M5 11h.01", leases: "M10 2a4 4 0 1 1-3.5 6L2 12.5V14h2v-1.5h1.5V11h1.5l1-1", waiting: "M2 4h12M2 8h9M2 12h6", + baselines: "M3 2.5h10v11H3zM6 6h4M6 9h4M6 12h2", inbox: "M2 9l2-6h8l2 6v4H2zM2 9h4l1 2h2l1-2h4", events: "M1.5 8h3l2-5 3 10 2-5h3", usage: "M2 14V2M2 14h12M5 11V8M8 11V5M11 11V7", diff --git a/ui/src/model/baselines.test.ts b/ui/src/model/baselines.test.ts new file mode 100644 index 000000000..9568e23b0 --- /dev/null +++ b/ui/src/model/baselines.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; + +import type { BaselineEntry } from "../../../src/contract/index"; +import { baselineRows } from "./baselines"; +import type { WorkerView } from "./workers"; + +const NOW = Date.parse("2026-11-03T12:00:00Z"); + +function entry(overrides: Partial = {}): BaselineEntry { + return { + mode: "full", + model: "Pixel 9", + name: "checkout", + osVersion: "36", + platform: "android", + savedAt: "2026-11-03T10:00:00Z", + version: 2, + ...overrides, + }; +} + +function worker(id: string, label?: string): WorkerView { + return { + catalog: [], + connection: "connected", + devices: [], + drained: false, + id, + lastSeenAt: NOW, + leases: [], + ...(label === undefined ? {} : { label }), + }; +} + +describe("baselineRows", () => { + it("shows each entry's columns in the console's formats, ordered by name then worker", () => { + const rows = baselineRows( + [ + entry({ + imageTag: "google_apis", + note: "app 4.2, test user", + sizeBytes: 4.5 * 2 ** 30, + workerId: "w2", + }), + entry({ + name: "login", + platform: "ios", + model: "iPhone 17", + osVersion: "27.0", + mode: "slim", + version: 1, + workerId: "w1", + }), + entry({ name: "checkout", workerId: "w1", sizeBytes: 300 * 2 ** 20 }), + entry({ name: "alpha", workerId: "w9" }), + ], + [worker("w1", "mac-mini-2"), worker("w2", "mac-studio-1")], + NOW, + ); + + expect(rows.map((row) => [row.name, row.worker])).toEqual([ + ["alpha", "w9"], + ["checkout", "mac-mini-2"], + ["checkout", "mac-studio-1"], + ["login", "mac-mini-2"], + ]); + expect(rows[2]).toMatchObject({ + mode: "full", + model: "Pixel 9 / google_apis", + note: "app 4.2, test user", + os: "36", + platform: "Android", + saved: "2 h ago", + savedAt: "2026-11-03T10:00:00Z", + size: "4.5 GiB", + version: 2, + }); + expect(rows[1]?.size).toBe("300 MiB"); + expect(rows[3]).toMatchObject({ model: "iPhone 17", platform: "iOS", note: "" }); + }); + + it("leaves the worker out when no entry carries one, and shows an entry with no size as a dash", () => { + const [row] = baselineRows([entry()], [], NOW); + expect(row?.worker).toBeUndefined(); + expect(row?.size).toBe("—"); + }); + + it("marks an entry with a stale reason stale, word for word", () => { + const reason = "runtime com.apple.CoreSimulator.SimRuntime.iOS-27-0 is not installed"; + const rows = baselineRows([entry({ staleReason: reason }), entry({ name: "ok" })], [], NOW); + expect(rows.map((row) => row.stale)).toEqual([reason, undefined]); + }); +}); diff --git a/ui/src/model/baselines.ts b/ui/src/model/baselines.ts new file mode 100644 index 000000000..57bf95b3f --- /dev/null +++ b/ui/src/model/baselines.ts @@ -0,0 +1,60 @@ +import type { BaselineEntry } from "../../../src/contract/index"; +import { platformName } from "../../../src/contract/inbox"; +import { formatBytes } from "./load"; +import { formatDuration } from "./time"; +import { type WorkerView, workerName } from "./workers"; + +export interface BaselineList { + readonly baselines: readonly BaselineEntry[]; +} + +export interface BaselineRow { + readonly key: string; + readonly worker?: string; + readonly stale?: string; + readonly name: string; + readonly version: number; + readonly platform: string; + readonly model: string; + readonly os: string; + readonly mode: string; + readonly note: string; + readonly size: string; + readonly saved: string; + readonly savedAt: string; +} + +export function baselineRows( + entries: readonly BaselineEntry[], + workers: readonly WorkerView[], + now: number, +): readonly BaselineRow[] { + const rows = entries.map((entry) => { + const worker = workerOf(entry, workers); + return { + key: `${worker ?? ""}/${entry.name}`, + mode: entry.mode, + model: entry.imageTag === undefined ? entry.model : `${entry.model} / ${entry.imageTag}`, + name: entry.name, + note: entry.note ?? "", + os: entry.osVersion, + platform: platformName(entry.platform), + saved: `${formatDuration(now - Date.parse(entry.savedAt))} ago`, + savedAt: entry.savedAt, + size: entry.sizeBytes === undefined ? "—" : formatBytes(entry.sizeBytes), + version: entry.version, + ...(worker === undefined ? {} : { worker }), + ...(entry.staleReason === undefined ? {} : { stale: entry.staleReason }), + }; + }); + return rows.sort( + (a, b) => a.name.localeCompare(b.name) || (a.worker ?? "").localeCompare(b.worker ?? ""), + ); +} + +// why: a gateway names the worker by id; its label is shown when the fleet view has the worker, and the id stands when it does not. +function workerOf(entry: BaselineEntry, workers: readonly WorkerView[]): string | undefined { + if (entry.workerId === undefined) return undefined; + const known = workers.find((worker) => worker.id === entry.workerId); + return known === undefined ? entry.workerId : workerName(known); +} diff --git a/ui/src/model/stale-keys.ts b/ui/src/model/stale-keys.ts index 9f52b99f0..5f427d8b4 100644 --- a/ui/src/model/stale-keys.ts +++ b/ui/src/model/stale-keys.ts @@ -5,6 +5,7 @@ export type StaleKey = readonly string[]; const LEASES: readonly StaleKey[] = [["leases"], ["workers"], ["lease"], ["inbox"]]; const REQUESTS: readonly StaleKey[] = [["lease-requests"], ["inbox"]]; const WORKERS: readonly StaleKey[] = [["workers"], ["inbox"]]; +const BASELINES: readonly StaleKey[] = [["baselines"]]; const NONE: readonly StaleKey[] = []; function hasLeaseId(payload: unknown): boolean { @@ -28,6 +29,10 @@ export function staleKeys(name: string, payload: unknown): readonly StaleKey[] { return REQUESTS; case "capacity.changed": return WORKERS; + case "baseline.saved": + case "baseline.removed": + case "baseline.stale": + return BASELINES; default: } if (name.startsWith("request.")) return REQUESTS; diff --git a/ui/src/model/worker-page.ts b/ui/src/model/worker-page.ts index 8ca8a67de..53ea838fc 100644 --- a/ui/src/model/worker-page.ts +++ b/ui/src/model/worker-page.ts @@ -302,6 +302,7 @@ export interface DeviceRow { readonly physical: boolean; readonly mode?: string; readonly imageTag?: string; + readonly baseline?: string; } // why: a worker names a lease by its own id, but a gateway lists it, and opens its page, as @@ -335,6 +336,10 @@ function deviceRow( ...leaseFacts(device, worker, now, tokens, leaseIds), ...defined({ udid: device.physical ? device.driverDeviceId : undefined, + baseline: + device.spec.baseline === undefined + ? undefined + : `${device.spec.baseline.name} v${String(device.spec.baseline.version)}`, imageTag: virtual ? device.spec.imageTag : undefined, mode: virtual && !device.servesDefaultMode ? device.mode : undefined, since: entered === undefined ? undefined : formatDuration(now - entered), diff --git a/ui/src/screens/baselines/index.tsx b/ui/src/screens/baselines/index.tsx new file mode 100644 index 000000000..c7fb9538b --- /dev/null +++ b/ui/src/screens/baselines/index.tsx @@ -0,0 +1,50 @@ +import { type Column, DataTable, Loaded, Page, Status } from "../../ds"; +import { type BaselineRow, useBaselines } from "../../state/baselines"; + +const WORKER: Column = { cell: (row) => row.worker, header: "Worker" }; + +const COLUMNS: readonly Column[] = [ + { cell: (row) => row.name, header: "Name", mono: true }, + { cell: (row) => row.version, header: "Version", mono: true, numeric: true }, + { cell: (row) => row.platform, header: "Platform" }, + { cell: (row) => row.model, header: "Model" }, + { cell: (row) => row.os, header: "OS", mono: true }, + { cell: (row) => row.mode, header: "Mode" }, + { cell: (row) => row.note, header: "Note" }, + { cell: (row) => row.size, header: "Size", mono: true, numeric: true }, + { + cell: (row) => {row.saved}, + header: "Saved", + mono: true, + }, + { + cell: (row) => + row.stale === undefined ? null : ( + <> + stale + {` · ${row.stale}`} + + ), + header: "Stale", + }, +]; + +export function BaselinesScreen() { + const view = useBaselines(); + return ( + + + {(page) => ( + row.key} + empty="No baselines." + paging={page.paging} + /> + )} + + + ); +} diff --git a/ui/src/screens/fleet/device-drawer.tsx b/ui/src/screens/fleet/device-drawer.tsx index 66495c899..0a51022f7 100644 --- a/ui/src/screens/fleet/device-drawer.tsx +++ b/ui/src/screens/fleet/device-drawer.tsx @@ -99,6 +99,9 @@ function FactList({ label: "State", value: {device.status.word}, }, + ...(device.baseline === undefined + ? [] + : [{ label: "Baseline", value: {device.baseline} }]), ...(device.holder === undefined ? [] : [{ label: "Holder", value: device.holder.name }]), ...(device.lease === undefined ? [] diff --git a/ui/src/state/baselines.ts b/ui/src/state/baselines.ts new file mode 100644 index 000000000..f23ef5800 --- /dev/null +++ b/ui/src/state/baselines.ts @@ -0,0 +1,29 @@ +import type { LoadView } from "../ds/loaded"; +import type { Paging } from "../ds/pager"; +import { type BaselineList, type BaselineRow, baselineRows } from "../model/baselines"; +import { TABLE_SIZES } from "../model/paging"; +import type { WorkerList } from "../model/workers"; +import { useDaemonMode, useNow, useRouteQuery } from "./live"; +import { usePaging } from "./paging"; +import { loadView, mapView } from "./resource"; + +export type { BaselineRow } from "../model/baselines"; + +export interface BaselinesPage { + readonly rows: readonly BaselineRow[]; + readonly paging: Paging; + readonly gateway: boolean; +} + +export function useBaselines(): LoadView { + const list = loadView(useRouteQuery("/v1/baselines")); + const workers = useRouteQuery("/v1/workers").data?.workers ?? []; + const now = useNow(); + const gateway = useDaemonMode() === "gateway"; + const rows = mapView(list, (all) => baselineRows(all.baselines, workers, now.server)); + const paging = usePaging({ + sizes: TABLE_SIZES, + total: rows.kind === "ready" ? rows.data.length : undefined, + }); + return mapView(rows, (all) => ({ gateway, paging, rows: all })); +} diff --git a/ui/src/state/queries.test.ts b/ui/src/state/queries.test.ts index 8a910f35a..44c2a0294 100644 --- a/ui/src/state/queries.test.ts +++ b/ui/src/state/queries.test.ts @@ -162,6 +162,7 @@ describe("events mark keys stale", () => { "/v1/inbox", "/v1/stats?since=1h", "/v1/tokens", + "/v1/baselines", ] as const; // why: each row is an event, its payload, and exactly the routes it marks. const rows: readonly (readonly [string, Record, readonly string[]])[] = [ @@ -183,6 +184,9 @@ describe("events mark keys stale", () => { ["capacity.changed", {}, ["/v1/workers", "/v1/inbox"]], ["component.installed", {}, ["/v1/workers", "/v1/inbox"]], ["daemon.update-started", {}, ["/v1/workers", "/v1/inbox"]], + ["baseline.saved", {}, ["/v1/baselines"]], + ["baseline.removed", {}, ["/v1/baselines"]], + ["baseline.stale", {}, ["/v1/baselines"]], ["disk.pressure-detected", {}, []], ["lease.something-new", {}, []], ]; diff --git a/ui/src/state/queries.ts b/ui/src/state/queries.ts index aaaf52493..0f91b8764 100644 --- a/ui/src/state/queries.ts +++ b/ui/src/state/queries.ts @@ -38,6 +38,7 @@ interface Times { } const TIMES: Readonly> = { + baselines: { freshMs: FRESH_MS, pollMs: 15_000 }, inbox: { freshMs: FRESH_MS, pollMs: 10_000 }, lease: { freshMs: FRESH_MS, pollMs: 10_000 }, "lease-requests": { freshMs: FRESH_MS, pollMs: 5_000 }, diff --git a/website/src/content/docs/docs/gateway/fleet-console.mdx b/website/src/content/docs/docs/gateway/fleet-console.mdx index 48fbdab4e..c00add7c7 100644 --- a/website/src/content/docs/docs/gateway/fleet-console.mdx +++ b/website/src/content/docs/docs/gateway/fleet-console.mdx @@ -55,6 +55,10 @@ A disconnected worker stays in the list for 24 hours by default (`gateway.discon **Leases** lists the leases on every worker, both those made through the gateway and those agents on a worker machine took directly from it. The lease's **Worker** fact names and links the worker that holds it. A lease made through the gateway has an id made of the worker's id, a dot, and the worker's own lease id, unless the agent chose the id. +## Baselines + +**Baselines** lists every worker's baselines. On a gateway it adds a **Worker** column that names the worker holding each one. + ## Why a request waits On **Waiting**, a request's **Queue** says where it waits: `the gateway's queue` while the gateway looks for a worker. Under it, the console names each worker and why that worker did not take the request: diff --git a/website/src/content/docs/docs/worker/console.mdx b/website/src/content/docs/docs/worker/console.mdx index 816439706..fe1569756 100644 --- a/website/src/content/docs/docs/worker/console.mdx +++ b/website/src/content/docs/docs/worker/console.mdx @@ -72,7 +72,7 @@ To sign people in through a sign-in proxy instead of tokens, see [Access control ## Find your way around -The sidebar leads to every page: **Overview**, **Workers**, **Leases**, **Waiting**, **Attention**, **Events** and **Usage**. **Attention** shows how many items need you, on every page. At the bottom of the sidebar you see the connection, who you are signed in as, and **Sign out**. On a gateway, when the gateway itself is behind the latest release, a line above the connection says `simlock 0.3.0 · 0.4.0 available`. It is hidden while the sidebar is collapsed, and the console checks again every 10 minutes. +The sidebar leads to every page: **Overview**, **Workers**, **Leases**, **Waiting**, **Baselines**, **Attention**, **Events** and **Usage**. **Attention** shows how many items need you, on every page. At the bottom of the sidebar you see the connection, who you are signed in as, and **Sign out**. On a gateway, when the gateway itself is behind the latest release, a line above the connection says `simlock 0.3.0 · 0.4.0 available`. It is hidden while the sidebar is collapsed, and the console checks again every 10 minutes. Every page but the overview is a list on the left and the item you chose on the right. Lists show 25 rows at a time, with a pager for 50 or 100. @@ -119,7 +119,7 @@ Select the worker to see its CPU, memory and disk, each with a bar, how many lea Select a device on the worker to open its drawer, which has two tabs: -- **Details**: the model, runtime, state, holder, lease and UDID. The drawer opens here. +- **Details**: the model, runtime, state, the baseline and version the device was copied from (a device made from a baseline only), holder, lease and UDID. The drawer opens here. - **Screen**: the device's live picture, whether or not it is leased. The picture starts when you open the tab and stops when you leave it or close the drawer. The **Stream details** button (ⓘ) shows the stream's state, picture size, frames, rate, bandwidth and dropped frames. A device that is not running says why instead: for example "The device is shut down. Its screen shows while it runs." The picture starts by itself once the device is `ready` again. Where screen streaming is off for the worker, the tab says "Streaming is off on" and the worker's name. See [Watching the screen](/simlock/docs/using-devices/watching-the-screen). The address of the Screen tab ends in `?drawer=screen`, so a pasted link opens it. On a gateway the drawer works the same, for a device on any connected worker. @@ -177,6 +177,12 @@ Select a request to see when it was made, its place, its stage and, for a `queue A request leaves the list as soon as it gets its device, fails or is cancelled. To change how many devices the worker runs, see [Capacity and the warm pool](/simlock/docs/worker/capacity). +## Baselines + +**Baselines** lists every baseline the worker holds, one row each: its name, version, platform, model (with the image tag when it has one), OS version, mode, note, size, how long ago it was saved (hover for the exact time) and whether it is stale. A stale baseline shows `stale` and its reason, for example `stale · runtime com.apple.CoreSimulator.SimRuntime.iOS-27-0 is not installed`. Rows are ordered by name, and a baseline saved, removed or gone stale shows within about a second, with no reload. With none, the page says `No baselines.` + +The page only shows. Save and remove baselines from the command line with `simlock baseline save` and `simlock baseline remove`; see [Baselines](/simlock/docs/worker/baselines). + ## Attention **Attention** lists everything on the worker that needs you. Each row is the condition and the worker or device it is about. An item leaves the list as soon as the daemon reports that it cleared, and its page then says "Resolved." The console shows these items; it does not fix them.