From 045edec75c483c8a8b071ca3bbdeda4a68266b63 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 09:04:01 +0000 Subject: [PATCH 1/2] test(audit): 51 weak tests fixed, 23 fewer tests, since 19a11eb Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_017Ko5ho4DS4VekwSm5GaYkA --- src/agent-device/edge.test.ts | 196 +++---- src/cli/index.test.ts | 209 ++----- src/daemon/main.test.ts | 546 +++++++----------- src/daemon/server.test.ts | 274 ++++----- .../android/input/device-input.test.ts | 18 +- src/drivers/ios/index.test.ts | 326 ++++------- src/gateway/dispatcher.test.ts | 246 +++----- src/http/app.test.ts | 224 +++---- src/leasing/create-leasing.test.ts | 453 ++++++++------- src/spec-lint.test.ts | 11 +- src/updater/index.test.ts | 5 + ui/src/state/queries.test.ts | 7 +- 12 files changed, 978 insertions(+), 1537 deletions(-) diff --git a/src/agent-device/edge.test.ts b/src/agent-device/edge.test.ts index 75b027369..19982dcce 100644 --- a/src/agent-device/edge.test.ts +++ b/src/agent-device/edge.test.ts @@ -1607,6 +1607,15 @@ describe("the agent-device front, through the whole worker daemon", () => { await expect .poll(async () => (await leases(rig)).map((lease) => lease.id)) .not.toContain(hex(4)); + + rig.fake.answerNextAllocateWith("bad gateway"); + rig.fake.failNextRelease({ code: "LEASE_NOT_FOUND", message: "no such lease" }); + const gone = await envelopeOf(await allocate(rig, 5, { ttlMs: 60_000 })); + + expect(gone.error?.data?.code).toBe("COMMAND_FAILED"); + await expect + .poll(async () => (await leases(rig)).map((lease) => lease.id)) + .not.toContain(hex(5)); }); it("the follow-up release after an allocate with no usable reply carries the allocate's runId, leaseProvider and clientId and not its deviceKey", async () => { @@ -1791,18 +1800,6 @@ describe("the agent-device front, through the whole worker daemon", () => { expect((await leases(rig)).map((lease) => lease.id)).toEqual([hex(1)]); }); - it("a release after `simlock release --all` ended the simlock lease answers the daemon's reply", async () => { - const rig = await setUp(); - await granted(rig, 1); - await rig.daemon.dispatch("lease.release-all", {}, adminSession); - expect(await leases(rig)).toEqual([]); - - const answer = await envelopeOf(await release(rig, 1)); - - expect(answer.result).toMatchObject({ ok: true, released: false }); - expect(rig.fake.leases.has(hex(1))).toBe(false); - }); - it("a release from a second token naming the first token's lease ID leaves the first token's simlock lease in place", async () => { const rig = await setUp(); await granted(rig, 1); @@ -2326,7 +2323,8 @@ describe("the agent-device front, through the whole worker daemon", () => { const released = await envelopeOf(await release(rig, 1)); - expect(released.result).toMatchObject({ ok: true }); + expect(released.result).toMatchObject({ ok: true, released: false }); + expect(rig.fake.leases.has(hex(1))).toBe(false); expectEndedBySimlock(await envelopeOf(await heartbeat(rig, 1))); await granted(rig, 2); expect(await listed(rig)).toEqual([hex(2)]); @@ -2423,7 +2421,7 @@ describe("the agent-device front, through the whole worker daemon", () => { expect(await deadlineOf(rig, 1)).toBe(final); }); - it("a command agent-device answers with a 5xx reaches the CLI unchanged, and simlock logs a warning with that status and the start of the body", async () => { + it("a command agent-device answers with a 5xx reaches the CLI unchanged", async () => { const rig = await setUp(); await granted(rig, 1); rig.fake.failNextCommandWithStatus(500, "snapshot runner crashed"); @@ -2432,9 +2430,6 @@ describe("the agent-device front, through the whole worker daemon", () => { expect(response.status).toBe(500); expect(await response.text()).toBe("snapshot runner crashed"); - await expect - .poll(() => rig.logs.records.find((record) => record.message.includes("5xx"))?.fields) - .toEqual({ body: "snapshot runner crashed", status: 500 }); }); it("a command that fails, and one whose CLI hangs up, stop the renewals the same way", async () => { @@ -2601,18 +2596,6 @@ describe("the agent-device front, through the whole worker daemon", () => { expect(await listed(rig)).toEqual([]); }); - it("after a simlock daemon restart on the same data directory and the same `FakeDriver`, so the device keeps running and the test first sees the lease still listed, a heartbeat for a lease made before the restart answers `LEASE_ENDED_BY_HOST` and releases that simlock lease", async () => { - const first = await setUp(); - await granted(first, 1); - const rig = await first.restart(); - expect(await listed(rig)).toEqual([hex(1)]); - - const answer = await envelopeOf(await heartbeat(rig, 1)); - - expectEndedBySimlock(answer); - expect(await listed(rig)).toEqual([]); - }); - it("on a worker, after a simlock restart, a command for the old lease is not adopted and the next heartbeat answers `LEASE_ENDED_BY_HOST`, as today", async () => { const first = await setUp(); await granted(first, 1); @@ -2893,17 +2876,6 @@ describe("simlock ending a simlock lease ends the agent-device session first", ( expect(atLostRelease).toEqual([lostBefore]); }); - it("after release-all, the agent's next heartbeat answers LEASE_ENDED_BY_HOST and makes no new simlock lease", async () => { - const rig = await setUp(); - await granted(rig, 1); - await releaseAll(rig); - await expect.poll(() => rig.fake.leases.has(hex(1))).toBe(false); - - expectEndedBySimlock(await envelopeOf(await heartbeat(rig, 1))); - - expect(await listed(rig)).toEqual([]); - }); - it("the agent's own release, answered before simlock leasing ends the lease, leaves agent-device with no second release", async () => { const rig = await setUp(); await granted(rig, 1); @@ -3084,31 +3056,38 @@ describe("simlock ending a simlock lease ends the agent-device session first", ( expect(rig.fake.requests).toEqual([]); }); - it("agent-device answering the release with an error: the device's reclaim still starts", async () => { - const rig = await setUp(); - await granted(rig, 1); - const before = wipes(rig); - const atRelease = watchWipes(rig); - rig.fake.failNextRelease({ code: "COMMAND_FAILED", message: "release failed" }); - - await releaseAll(rig); - - await expect.poll(() => wipes(rig)).toBeGreaterThan(before); - expect(releasesReceived(rig)).toHaveLength(1); - expect(atRelease).toEqual([before]); - }); - - it("agent-device unreachable for the release: the device's reclaim still starts", async () => { - const rig = await setUp(); - await granted(rig, 1); - const before = wipes(rig); - rig.fake.setUnreachable(true); + it.each([ + { + failure: "an error", + fail: (rig: Rig) => + rig.fake.failNextRelease({ code: "COMMAND_FAILED", message: "release failed" }), + tried: (rig: Rig, atRelease: readonly number[], before: number) => { + expect(releasesReceived(rig)).toHaveLength(1); + expect(atRelease).toEqual([before]); + }, + }, + { + failure: "an unreachable daemon", + fail: (rig: Rig) => rig.fake.setUnreachable(true), + tried: (rig: Rig) => { + expect(rig.fake.refused).toContain("agent_device.lease.release"); + }, + }, + ])( + "agent-device failing the release with $failure: the device's reclaim still starts", + async ({ fail, tried }) => { + const rig = await setUp(); + await granted(rig, 1); + const before = wipes(rig); + const atRelease = watchWipes(rig); + fail(rig); - await releaseAll(rig); + await releaseAll(rig); - await expect.poll(() => wipes(rig)).toBeGreaterThan(before); - expect(rig.fake.refused).toContain("agent_device.lease.release"); - }); + await expect.poll(() => wipes(rig)).toBeGreaterThan(before); + tried(rig, atRelease, before); + }, + ); it("after the agent-device daemon restarts, release-all gets released false from it and the reclaim starts at once", async () => { const rig = await setUp(); @@ -3424,12 +3403,6 @@ describe("the agent-device front's request rules", () => { expect(rig.fake.requests.map((request) => request.path)).toEqual([path]); }); - it("POST /rpc with a token reaches agent-device", async () => { - const rig = await setUp(); - - await forwarded(rig, () => runCommand(rig, "snapshot", undefined)); - }); - it.each([ ["GET", "/admin/leases"], ["POST", "/health"], @@ -3533,32 +3506,24 @@ describe("the agent-device front's request rules", () => { ); }); - it("a batch step whose `runtime.platform` is `web` is refused", async () => { - const rig = await setUp(); - await forwarded(rig, () => - batch(rig, undefined, [{ command: "snapshot", runtime: { platform: "ios" } }]), - ); - - await refused( - rig, - () => batch(rig, undefined, [{ command: "snapshot", runtime: { platform: "web" } }]), - onlyIosAndAndroid, - ); - }); - - it("a batch step with `platform: web` is refused and no step reaches agent-device", async () => { - const rig = await setUp(); - const steps = [{ command: "snapshot", flags: { platform: "ios" } }]; - await forwarded(rig, () => batch(rig, undefined, steps)); + it.each([ + ["runtime.platform", (platform: string) => ({ runtime: { platform } })], + ["flags.platform", (platform: string) => ({ flags: { platform } })], + ] as const)( + "a batch step whose `%s` is `web` is refused and no step after the accepted one reaches agent-device", + async (_field, place) => { + const rig = await setUp(); + const steps = [{ command: "snapshot", ...place("ios") }]; + await forwarded(rig, () => batch(rig, undefined, steps)); - await refused( - rig, - () => - batch(rig, undefined, [...steps, { command: "snapshot", flags: { platform: "web" } }]), - onlyIosAndAndroid, - ); - expect(commandsReceived(rig)).toHaveLength(1); - }); + await refused( + rig, + () => batch(rig, undefined, [...steps, { command: "snapshot", ...place("web") }]), + onlyIosAndAndroid, + ); + expect(commandsReceived(rig)).toHaveLength(1); + }, + ); }); describe("rule 6, device selectors", () => { @@ -4321,28 +4286,28 @@ describe("a worker's capabilities in status.get, through the whole worker daemon expect(await capabilities(off)).toEqual([{ name: "agent-device", state: "off" }]); }); - it("a worker whose agent-device is unavailable answers state unavailable with the same reason its front's notAvailable reply names", async () => { + it.each(["found 0.20.1, need 0.21.28 or later", "starting"])( + "a worker whose agent-device is unavailable with reason %s answers state unavailable with that reason", + async (reason) => { + const rig = await setUp(); + rig.fake.setUnavailable(reason); + + expect(await capabilities(rig)).toEqual([ + { name: "agent-device", reason, state: "unavailable" }, + ]); + }, + ); + + it("a worker whose agent-device is unavailable names the same reason in its front's notAvailable reply", async () => { const rig = await setUp(); const reason = "found 0.20.1, need 0.21.28 or later"; rig.fake.setUnavailable(reason); const health = await (await send(rig, "/health")).text(); - expect(await capabilities(rig)).toEqual([ - { name: "agent-device", reason, state: "unavailable" }, - ]); expect(health).toContain(`${notAvailable}${reason}`); }); - it("a worker whose agent-device is starting answers unavailable with reason starting", async () => { - const rig = await setUp(); - rig.fake.setUnavailable("starting"); - - expect(await capabilities(rig)).toEqual([ - { name: "agent-device", reason: "starting", state: "unavailable" }, - ]); - }); - it("a worker whose agent-device goes from unavailable to running answers available on the next status.get", async () => { const rig = await setUp(); rig.fake.setUnavailable("starting"); @@ -4454,23 +4419,6 @@ describe( expect(rig.fake.artifactBytesProduced).toBeLessThan(1_073_741_824); }); - it("an RPC reply and a preflight reply pass unchanged", async () => { - const rig = await setUp(); - await granted(rig, 1); - - const reply = await command(rig, "snapshot", 1); - const preflight = await send(rig, "/upload/preflight", { body: "{}" }); - - expect(reply.status).toBe(200); - expect((await envelopeOf(reply)).result).toMatchObject({ ok: true }); - expect(preflight.status).toBe(200); - expect( - ((await preflight.json()) as { upload: { url: string } }).upload.url.startsWith( - `${rig.origin}/v1/agent-device/upload/direct/`, - ), - ).toBe(true); - }); - it("a 2147483648-byte upload reaches the fake byte for byte", async () => { const rig = await setUp(); const preflight = await send(rig, "/upload/preflight", { body: "{}" }); diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 44a7e0073..f2ed2df44 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -1185,57 +1185,51 @@ describe("CLI: exit codes", () => { }); describe("CLI: admin credential resolution (ADR 0003 §5)", () => { - it("prefers --token over SIMLOCK_ADMIN_TOKEN and the admin.token file", async () => { - const output = outputCapture(); - let seen: string | undefined; - await runCli( + it.each([ + [ + "--token over env and file", ["--token", "from-flag", "status"], - output.environmentWith({ - adminTokenFromEnv: "from-env", - readAdminTokenFile: async () => "from-file", - connectAdmin: async (resolveCredential) => { - seen = await resolveCredential(); - return fakeClient(); - }, - }), - ); - expect(seen).toBe("from-flag"); - expect(output.stderr).toBe(""); - }); - - it("prefers SIMLOCK_ADMIN_TOKEN over the admin.token file when --token is absent", async () => { - const output = outputCapture(); - let seen: string | undefined; - await runCli( + "from-env", + "from-file", + "from-flag", + true, + ], + [ + "SIMLOCK_ADMIN_TOKEN over the file when --token is absent", ["status"], - output.environmentWith({ - adminTokenFromEnv: "from-env", - readAdminTokenFile: async () => "from-file", - connectAdmin: async (resolveCredential) => { - seen = await resolveCredential(); - return fakeClient(); - }, - }), - ); - expect(seen).toBe("from-env"); - }); - - it("falls back to the admin.token file when neither --token nor the env var is set", async () => { - const output = outputCapture(); - let seen: string | undefined; - await runCli( + "from-env", + "from-file", + "from-env", + false, + ], + [ + "the admin.token file when neither --token nor the env var is set", ["status"], - output.environmentWith({ - readAdminTokenFile: async () => "from-file", - connectAdmin: async (resolveCredential) => { - seen = await resolveCredential(); - return fakeClient(); - }, - }), - ); - expect(seen).toBe("from-file"); - expect(output.stderr).toBe(""); - }); + undefined, + "from-file", + "from-file", + true, + ], + ] as const)( + "resolves the admin credential in priority order: %s", + async (_case, argv, envToken, fileToken, expected, stderrEmpty) => { + const output = outputCapture(); + let seen: string | undefined; + await runCli( + [...argv], + output.environmentWith({ + ...(envToken === undefined ? {} : { adminTokenFromEnv: envToken }), + readAdminTokenFile: async () => fileToken, + connectAdmin: async (resolveCredential) => { + seen = await resolveCredential(); + return fakeClient(); + }, + }), + ); + expect(seen).toBe(expected); + if (stderrEmpty) expect(output.stderr).toBe(""); + }, + ); it("retries a briefly-missing admin.token file before giving up", async () => { const output = outputCapture(); @@ -1343,7 +1337,7 @@ describe("CLI: admin credential resolution (ADR 0003 §5)", () => { }), ); expect(attempts).toBe(1); - expect(exitCode).not.toBe(0); + expect(exitCode).toBe(1); expect(JSON.parse(output.stderr).error.code).toBe("ADMIN_AUTHENTICATION_FAILED"); }); @@ -2345,19 +2339,6 @@ describe("CLI: daemon status (ADR 0003 §11)", () => { expect(launched).toBe(false); }); - it("prints the human view for daemon status when --json is absent", async () => { - const output = outputCapture(); - - await expect( - runCli( - ["daemon", "status"], - output.environmentWith({ connectExistingAdmin: async () => fakeClient() }), - ), - ).resolves.toBe(0); - - expect(output.stdout.split("\n")[0]).toBe("Daemon: running (worker)"); - }); - it("daemon status prints the Service: line and the service field in --json, for a worker or a gateway, running or stopped, and none on an unsupported platform", async () => { const home = "/Users/me"; const node = "/Users/me/.nvm/versions/node/v22.13.0/bin/node"; @@ -2490,7 +2471,7 @@ describe("CLI: daemon status (ADR 0003 §11)", () => { }, }); - await expect(runCli(["daemon", "status"], environment)).resolves.not.toBe(0); + await expect(runCli(["daemon", "status"], environment)).resolves.toBe(1); expect(output.stdout).not.toContain("Daemon stopped"); expect(output.stderr).toContain("launchctl timed out"); @@ -2730,20 +2711,6 @@ describe("CLI: daemon status (ADR 0003 §11)", () => { expect(output.stderr).not.toContain("agent-device"); }); - it("simlock doctor writes no agent-device line when the report has no such finding", async () => { - const output = outputCapture(); - const findings = [{ deviceId: "d-1", kind: "expired-live-lease" as const, leaseId: "l-1" }]; - - await runCli( - ["doctor"], - output.environmentWith({ - connectAdmin: async () => fakeClient({ runDoctor: () => Promise.resolve({ findings }) }), - }), - ); - - expect(output.stderr).not.toContain("agent-device"); - }); - it("keeps --purge-orphans off the wire when --fix is all that was asked for", async () => { const output = outputCapture(); const seen: unknown[] = []; @@ -2858,14 +2825,6 @@ describe("CLI: device add and device remove (ADR 0022 §2)", () => { }); }); - it("device --help prints both forms", async () => { - const result = await runDevice(["device", "--help"]); - - expect(result.code).toBe(0); - expect(result.stdout).toContain("simlock device add --platform [--json]"); - expect(result.stdout).toContain("simlock device remove [--json]"); - }); - it("prints one line for an enrollment, naming the platform's OS, and the JSON with --json", async () => { const line = await runDevice(["device", "add", "--platform", "ios", "UDID-1"]); const json = await runDevice(["device", "add", "--platform", "android", "SER-1", "--json"]); @@ -2878,6 +2837,7 @@ describe("CLI: device add and device remove (ADR 0022 §2)", () => { it.each([ ["device", ["device"]], + ["device --help", ["device", "--help"]], ["device -h", ["device", "-h"]], ["device add --help", ["device", "add", "--help"]], ["device remove -h", ["device", "remove", "-h"]], @@ -2909,7 +2869,9 @@ describe("CLI: device add and device remove (ADR 0022 §2)", () => { expect(await messages(["device", "add", "--platform", "windows", "X"])).toContain( "device add needs --platform ios or --platform android", ); - await messages(["device", "remove", "--platform", "ios", "X"]); + expect(await messages(["device", "remove", "--platform", "ios", "X"])).toContain( + "Unknown option '--platform'", + ); }); it("closes the connection after an enrollment and after a removal", async () => { @@ -3142,47 +3104,6 @@ describe("CLI: component remove (ADR 0010 §8)", () => { }); }); -describe("CLI: config set validates before writing (ADR 0003 §11)", () => { - it("rejects an invalid merged config without writing the file", async () => { - const output = outputCapture(); - let wrote = false; - await expect( - runCli( - ["config", "set", "lease.defaultTtlMs", "not-a-number"], - output.environmentWith({ - readConfigFile: async () => ({}), - writeConfigFile: async () => { - wrote = true; - }, - validateConfig: async () => { - throw new Error("lease.defaultTtlMs must be a number"); - }, - }), - ), - ).resolves.toBe(2); - expect(wrote).toBe(false); - expect(JSON.parse(output.stderr).error.code).toBe("USAGE"); - }); - - it("writes the file once validation passes", async () => { - const output = outputCapture(); - let written: Record | undefined; - await expect( - runCli( - ["config", "set", "downloads.policy", "never"], - output.environmentWith({ - readConfigFile: async () => ({}), - writeConfigFile: async (contents) => { - written = contents; - }, - validateConfig: async () => {}, - }), - ), - ).resolves.toBe(0); - expect(written).toEqual({ downloads: { policy: "never" } }); - }); -}); - describe("CLI: config set validates with the real config loader (ADR 0003 §11, B9)", () => { it.each([ ["an unknown key", "capacty.limits.ios.maxDevices", "2"], @@ -6776,42 +6697,14 @@ describe("CLI: mcp command (ADR 0003 §11 -- lazy module load)", () => { [["--gateway", "mcp"], 0, "gateway"], [["mcp", "--gateway"], 0, "gateway"], [["mcp"], 0, "none"], - [["--gateway=yes", "mcp"], 2, undefined], - ] as const)( - "--gateway is taken from anywhere in the argv and reaches the command as context.daemonFlag; --gateway= is a usage error (%j)", - async (argv, exitCode, gateway) => { - const output = outputCapture(); - const seen: string[] = []; - - const code = await runCli( - argv, - output.environmentWith({ - runMcpStdio: async (options) => { - seen.push(options.daemonFlag ?? "none"); - }, - }), - ); - - expect(code).toBe(exitCode); - if (gateway === undefined) { - expect(JSON.parse(output.stderr)).toMatchObject({ - error: { code: "USAGE", message: expect.stringContaining("--gateway takes no value") }, - }); - expect(seen).toEqual([]); - } else { - expect(seen).toEqual([gateway]); - } - }, - ); - - it.each([ + [["--gateway=yes", "mcp"], 2, "--gateway takes no value"], [["--local", "mcp"], 0, "local"], [["mcp", "--local"], 0, "local"], [["--local=yes", "mcp"], 2, "--local takes no value"], [["--local", "--gateway", "mcp"], 2, "--gateway and --local cannot be used together"], [["mcp", "--gateway", "--local"], 2, "--gateway and --local cannot be used together"], ] as const)( - "--local is taken from anywhere in the argv; --local= and --local with --gateway are usage errors (%j)", + "--gateway and --local are taken from anywhere in the argv and reach the command as context.daemonFlag; = and both together are usage errors (%j)", async (argv, exitCode, expected) => { const output = outputCapture(); const seen: string[] = []; @@ -6988,7 +6881,7 @@ describe("CLI: events history", () => { }), ); - expect(exitCode).not.toBe(0); + expect(exitCode).toBe(1); expect(fileRead).toBe(false); expect(output.stderr).toContain("PROTOCOL_VERSION_UNSUPPORTED"); }); diff --git a/src/daemon/main.test.ts b/src/daemon/main.test.ts index bb17845ec..6b013c273 100644 --- a/src/daemon/main.test.ts +++ b/src/daemon/main.test.ts @@ -70,7 +70,6 @@ import { parseDaemonArgv, processRunnerFor, productionServiceCheck, - serviceCheckFor, startDaemon, type DriverDiscoveryContext, type StartDaemonOptions, @@ -225,22 +224,15 @@ describe("interrupted root assembly", () => { expect([await filesystem.exists(dead), await filesystem.exists(live)]).toEqual([false, true]); }); - it("startup leaves a pid-less legacy staging directory in place and logs it once", async () => { + it("startup leaves a pid-less legacy staging directory in place", async () => { const filesystem = new MemoryFilesystem(); const devices = join(SIMLOCK_HOME, "devices"); await filesystem.mkdirp(devices); const legacy = await stage(filesystem, devices, ".ios.root-id.staging"); - const { sink } = await startWithRoot(filesystem, new FakeProcessSupervisor()); + await startWithRoot(filesystem, new FakeProcessSupervisor()); expect(await filesystem.exists(legacy)).toBe(true); - expect( - sink.records.filter( - (record) => - record.message === "left a legacy staging directory in place" && - (record.fields as { path?: string } | undefined)?.path === legacy, - ), - ).toHaveLength(1); }); it("startup leaves a directory of the user's own that only looks like a staging directory", async () => { @@ -404,17 +396,6 @@ describe("the version a daemon reports", () => { }); describe("the event history", () => { - it("judges retention by the injected clock, so an event younger than the retention on that clock survives the start-up prune", async () => { - const directory = await mkdtemp(join(tmpdir(), "simlock-main-")); - temporaryDirectories.push(directory); - await seedHistory(directory, [500]); - - await start({ dataDirectory: directory }); - - const events = await historyEventsWhen(directory, hasEvent("daemon.started")); - expect(events.map((event) => event.id)).toContain("evt_1"); - }); - it("prunes at start by the configured eventLog.retention, so an event older than it on the injected clock is gone", async () => { const directory = await mkdtemp(join(tmpdir(), "simlock-main-")); temporaryDirectories.push(directory); @@ -683,47 +664,7 @@ describe("startDaemon", () => { } }); - it("serviceCheckFor reads the worker's service state through the manager: the missing paths when broken, none otherwise, and no check on a gateway or without a manager", async () => { - const definition = () => - serviceDefinition({ - role: "worker", - root: "/Users/me/.simlock", - dataDirectory: "/simlock", - home: "/Users/me", - nodePath: "/gone/node", - mainPath: "/gone/main.js", - env: {}, - }); - const name = "com.callstack.simlock.worker"; - const states: readonly { readonly state: ServiceState; readonly missing: readonly string[] }[] = - [ - { state: { state: "not-installed" }, missing: [] }, - { state: { state: "installed", path: "/p" }, missing: [] }, - { state: { state: "not-loaded", path: "/p" }, missing: [] }, - { - state: { state: "broken", path: "/p", missing: ["/gone/node", "/gone/main.js"] }, - missing: ["/gone/node", "/gone/main.js"], - }, - ]; - for (const testCase of states) { - const { serviceCheck } = serviceCheckFor( - "worker", - new FakeServiceManager({ state: testCase.state }), - definition, - ); - expect(serviceCheck?.manager).toBe("launchd"); - expect(serviceCheck?.name).toBe(name); - expect(await serviceCheck?.check(), testCase.state.state).toEqual(testCase.missing); - } - expect( - serviceCheckFor("worker", new FakeServiceManager({ manager: "systemd" }), definition) - .serviceCheck?.manager, - ).toBe("systemd"); - expect(serviceCheckFor("gateway", new FakeServiceManager(), definition)).toEqual({}); - expect(serviceCheckFor("worker", undefined, definition)).toEqual({}); - }); - - it("productionServiceCheck builds the service definition from the process facts and reads its state: the worker's missing paths, and no check on a gateway", async () => { + it("productionServiceCheck builds the service definition from the process facts and reads its state: the missing paths when broken, none otherwise, and no check on a gateway or without a manager", async () => { const seen: ServiceDefinition[] = []; const manager: ServiceManager = { manager: "launchd", @@ -760,6 +701,33 @@ describe("startDaemon", () => { }), ]); expect(productionServiceCheck("gateway", host)).toEqual({}); + expect(productionServiceCheck("worker", { ...host, manager: undefined })).toEqual({}); + + const states: readonly { readonly state: ServiceState; readonly missing: readonly string[] }[] = + [ + { state: { state: "not-installed" }, missing: [] }, + { state: { state: "installed", path: "/p" }, missing: [] }, + { state: { state: "not-loaded", path: "/p" }, missing: [] }, + { + state: { state: "broken", path: "/p", missing: ["/gone/node", "/gone/main.js"] }, + missing: ["/gone/node", "/gone/main.js"], + }, + ]; + for (const testCase of states) { + const checked = productionServiceCheck("worker", { + ...host, + manager: new FakeServiceManager({ state: testCase.state }), + }); + expect(checked.serviceCheck?.manager).toBe("launchd"); + expect(checked.serviceCheck?.name).toBe("com.callstack.simlock.worker"); + expect(await checked.serviceCheck?.check(), testCase.state.state).toEqual(testCase.missing); + } + expect( + productionServiceCheck("worker", { + ...host, + manager: new FakeServiceManager({ manager: "systemd" }), + }).serviceCheck?.manager, + ).toBe("systemd"); const systemd = productionServiceCheck("worker", { ...host, @@ -963,11 +931,11 @@ describe("startDaemon", () => { expect(reads).toBe(1); }); - it("logs a driver whose component listing fails under daemon.components, and still answers component.list", async () => { + it("still answers component.list when a driver's component listing fails", async () => { const clock = new FakeClock(1_000); const ios = new FakeDriver({ availableOsVersions: ["26.5"], clock, platform: "ios" }); ios.failOn("listComponents", 1, new Error("simctl runtime list failed")); - const { daemon, sink } = await start({ drivers: [ios] }); + const { daemon } = await start({ drivers: [ios] }); const listed = await daemon.dispatch( "component.list", @@ -981,14 +949,6 @@ describe("startDaemon", () => { ); expect(listed).toEqual({ components: [] }); - expect(sink.records).toContainEqual( - expect.objectContaining({ - fields: { error: "Error: simctl runtime list failed", platform: "ios" }, - level: "warn", - message: "A driver could not list its installed components", - module: "daemon.components", - }), - ); }); }); @@ -1415,20 +1375,6 @@ describe("startDaemon wires core and leasing together", () => { }); } }); - - it("shows its message to an admin session and only its kind to an agent session", async () => { - const daemon = await quarantinedAfterFailedPurge(); - - const forAdmin = await devicesOf(daemon, admin); - const forAgent = await devicesOf(daemon, agent); - - expect(forAdmin[0]?.quarantineCause).toEqual({ - kind: "purge-failed", - message: "DriverCrashError: purge exploded", - }); - expect(forAgent[0]).toMatchObject({ state: "quarantined" }); - expect(forAgent[0]?.quarantineCause).toEqual({ kind: "purge-failed" }); - }); }); it("probes a leased device again as time passes once startup is done", async () => { @@ -2068,7 +2014,7 @@ describe("startDaemon with HTTP off", () => { }); describe("startDaemon HTTP gateway bind failure", () => { - it("rejects startDaemon() when the configured HTTP port is already in use, without emitting a started record after a stopping one", async () => { + it("rejects startDaemon() when the configured HTTP port is already in use, without emitting a started record", async () => { const directory = await mkdtemp(join(tmpdir(), "simlock-main-http-bind-")); temporaryDirectories.push(directory); const clock = new FakeClock(1_000); @@ -2099,9 +2045,7 @@ describe("startDaemon HTTP gateway bind failure", () => { const startedIndex = sink.records.findIndex((record) => record.message === "Daemon started"); const stoppingIndex = sink.records.findIndex((record) => record.message === "Daemon stopping"); expect(stoppingIndex).toBeGreaterThanOrEqual(0); - if (startedIndex >= 0) { - expect(startedIndex).toBeLessThan(stoppingIndex); - } + expect(startedIndex).toBe(-1); }); // why: the bind is held until convergence has had every chance to finish, then fails, so a start that does not wait for the listener (either role's convergence) would already have logged "Daemon started". @@ -2229,9 +2173,12 @@ describe("startDaemon HTTP status for a quarantined device", () => { }); describe("discoverDrivers", () => { - it("logs a skip when the Android SDK cannot be found, without throwing", async () => { - const sink = new MemoryLogSink(); - const logger = new JsonLinesLogger({ clock: new FakeClock(), level: "debug", sink }); + it("builds no Android driver when the SDK cannot be found", async () => { + const logger = new JsonLinesLogger({ + clock: new FakeClock(), + level: "debug", + sink: new MemoryLogSink(), + }); const filesystem = new MemoryFilesystem(); const { drivers } = await discoverDrivers({ @@ -2251,13 +2198,6 @@ describe("discoverDrivers", () => { }); expect(drivers.some((driver) => driver.platform === "android")).toBe(false); - expect(sink.records).toContainEqual( - expect.objectContaining({ - level: "warn", - message: "Skipped Android driver: SDK missing", - module: "daemon.driver-discovery", - }), - ); }); it("does not look for simulators on a host that has no simctl", async () => { @@ -2294,29 +2234,6 @@ describe("discoverDrivers", () => { expect(drivers.find((driver) => driver.platform === "ios")?.deviceRoot).toBe(IOS_ROOT); }); - it("names the daemon's pid in the staging directory of the iOS root it creates", async () => { - const filesystem = new MemoryFilesystem(); - const rename = vi.spyOn(filesystem, "rename"); - - await discoverIos({ filesystem }); - - expect(rename).toHaveBeenCalledWith( - expect.stringMatching(new RegExp(`/devices/\\.ios\\.${process.pid}\\.[^/]+\\.staging$`)), - IOS_ROOT, - ); - }); - - it("keeps the daemon up when the iOS device root is refused, reporting the platform instead", async () => { - const filesystem = await foreignOwnedRoot(); - - const { drivers, rejections } = await discoverIos({ filesystem }); - - // why: Safety rule 9: the platform goes, the daemon stays -- a daemon that will not start - // is a daemon that cannot tell anyone why. - expect(drivers.some((driver) => driver.platform === "ios")).toBe(false); - expect(rejections.map((rejection) => rejection.platform)).toEqual(["ios"]); - }); - it("publishes the refusal as the payload docs/internal/EVENTS.md documents for driver.root-rejected", async () => { const filesystem = await foreignOwnedRoot(); const refusal = await refusedRoot(filesystem); @@ -2398,23 +2315,42 @@ describe("discoverDrivers on a host with an Android SDK", () => { ); }); - it("names the daemon's pid in the staging directory of the Android root it creates", async () => { - const filesystem = await androidSdk(); - const probe = new FakeTcpProbe([5038]); - await filesystem.mkdirp(SIMLOCK_HOME); - await filesystem.writeFileAtomic( - join(SIMLOCK_HOME, "adb-server.json"), - JSON.stringify({ pid: 4242, port: 5038, startedAt: 1 }), - ); - const rename = vi.spyOn(filesystem, "rename"); + it.each([ + { + platform: "ios", + root: IOS_ROOT, + discover: async (filesystem: MemoryFilesystem) => { + await discoverIos({ filesystem }); + }, + }, + { + platform: "android", + root: join(SIMLOCK_HOME, "devices", "android"), + discover: async (filesystem: MemoryFilesystem) => { + await filesystem.mkdirp(SIMLOCK_HOME); + await filesystem.writeFileAtomic( + join(SIMLOCK_HOME, "adb-server.json"), + JSON.stringify({ pid: 4242, port: 5038, startedAt: 1 }), + ); + await discoverAndroid(filesystem, new FakeTcpProbe([5038]), [4242]); + }, + }, + ])( + "names the daemon's pid in the staging directory of the $platform root it creates", + async ({ platform, root, discover }) => { + const filesystem = platform === "android" ? await androidSdk() : new MemoryFilesystem(); + const rename = vi.spyOn(filesystem, "rename"); - await discoverAndroid(filesystem, probe, [4242]); + await discover(filesystem); - expect(rename).toHaveBeenCalledWith( - expect.stringMatching(new RegExp(`/devices/\\.android\\.${process.pid}\\.[^/]+\\.staging$`)), - join(SIMLOCK_HOME, "devices", "android"), - ); - }); + expect(rename).toHaveBeenCalledWith( + expect.stringMatching( + new RegExp(`/devices/\\.${platform}\\.${process.pid}\\.[^/]+\\.staging$`), + ), + root, + ); + }, + ); it("launches the discovered Android driver's emulators with the android.emulator options it was handed", async () => { const filesystem = await androidSdk(); @@ -2891,7 +2827,7 @@ describe("startDaemon event history", () => { expect(ids).toContain("evt_from-the-given-generator"); }); - it("writes no daemon log line for component.installed and device.slimmed", async () => { + it("keeps component.installed and device.slimmed in the event history", async () => { const directory = await mkdtemp(join(tmpdir(), "simlock-main-event-file-")); temporaryDirectories.push(directory); process.env.SIMLOCK_DRIVERS_MODULE = await writeModule( @@ -2905,19 +2841,12 @@ describe("startDaemon event history", () => { }`, ); - const { sink } = await start({ dataDirectory: directory, drivers: undefined }); + await start({ dataDirectory: directory, drivers: undefined }); const written = (await historyEventsWhen(directory, hasEvent("device.slimmed"))).map( (event) => event.event, ); expect(written).toEqual(expect.arrayContaining(["component.installed", "device.slimmed"])); - expect( - sink.records.filter((record) => - JSON.stringify(record).match( - /component\.installed|device\.slimmed|Component installed|Device slimmed/, - ), - ), - ).toEqual([]); }); }); @@ -2951,7 +2880,7 @@ describe("discoverDrivers with SIMLOCK_DRIVERS_MODULE", () => { }); } - it("substitutes discovery with the module's createDrivers(context), logging the substitution", async () => { + it("substitutes discovery with the module's createDrivers(context)", async () => { process.env.SIMLOCK_DRIVERS_MODULE = await writeModule( `export function createDrivers(context) { return [{ platform: "ios", fromModule: true, sawContext: typeof context.logger === "object" }]; @@ -2963,21 +2892,6 @@ describe("discoverDrivers with SIMLOCK_DRIVERS_MODULE", () => { expect(drivers).toEqual([{ platform: "ios", fromModule: true, sawContext: true }]); expect(rejections).toEqual([]); - expect(sink.records).toContainEqual( - expect.objectContaining({ - level: "info", - message: "Substituting driver discovery via SIMLOCK_DRIVERS_MODULE", - module: "daemon.driver-discovery", - }), - ); - expect(sink.records).toContainEqual( - expect.objectContaining({ - level: "info", - message: "Loaded drivers from SIMLOCK_DRIVERS_MODULE", - module: "daemon.driver-discovery", - fields: expect.objectContaining({ count: 1 }), - }), - ); }); it("takes the module's prerequisiteChecks export in place of the real checks", async () => { @@ -3010,7 +2924,7 @@ describe("discoverDrivers with SIMLOCK_DRIVERS_MODULE", () => { } }); - it("a throwing event subscriber writes one JSON line through the logger and nothing through console.error", async () => { + it("a throwing event subscriber writes nothing through console.error", async () => { process.env.SIMLOCK_DRIVERS_MODULE = await writeModule( `export function createDrivers(context) { context.eventBus.subscribe("daemon.started", () => { @@ -3022,21 +2936,9 @@ describe("discoverDrivers with SIMLOCK_DRIVERS_MODULE", () => { const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); try { - const { sink } = await start({ drivers: undefined }); + await start({ drivers: undefined }); expect(consoleError).not.toHaveBeenCalled(); - expect(sink.records.filter((record) => record.message === "Event handler failed")).toEqual([ - expect.objectContaining({ - level: "error", - module: "daemon.bus", - fields: expect.objectContaining({ - event: "daemon.started", - seq: expect.any(Number), - message: "subscriber broke", - stack: expect.stringContaining("subscriber broke"), - }), - }), - ]); } finally { consoleError.mockRestore(); } @@ -3242,9 +3144,9 @@ describe("startDaemon logger wiring", () => { ]); }); - it("startDaemon hands the gateway dispatcher its error classifier: a typed refusal is logged with its code at info, not as INTERNAL at error", async () => { + it("a gateway rejects worker.drain for an unknown worker with UNKNOWN_WORKER", async () => { // why: A gateway serves HTTP; on its default port two overlapping runs of this file collide. - const { daemon, sink } = await start({ + const { daemon } = await start({ configOverrides: { http: { enabled: true, host: "127.0.0.1", port: await freeLoopbackPort() }, }, @@ -3263,18 +3165,6 @@ describe("startDaemon logger wiring", () => { }, ), ).rejects.toMatchObject({ code: "UNKNOWN_WORKER" }); - - expect( - sink.records.filter( - (record) => record.message === "operation" && record.fields?.operation === "worker.drain", - ), - ).toMatchObject([ - { - level: "info", - module: "daemon.gateway.dispatch", - fields: { code: "UNKNOWN_WORKER", principal: "test-operator" }, - }, - ]); }); }); @@ -3637,28 +3527,43 @@ describe("startDaemon with the agent-device edge", () => { expect(runner.calls).toEqual([]); }); - it("builds no supervisor in gateway mode: no ps and no agent-device process run, and no signal is sent", async () => { - const setup = await setUp( - { + // why: the supervisor retries on clock timers, so a ps it would defer never runs under real time; the fake clock is moved past its retry. + const SUPERVISOR_RETRY_MS = 30_000; + + it.each([ + { + role: "gateway", + config: async () => ({ capabilities: { "agent-device": { command, enabled: true } }, http: { enabled: true, host: "127.0.0.1", port: await freeLoopbackPort() }, - }, - { record: { pid: 900 } }, - ); - const runner = new ScriptedProcessRunner([]); - const processSupervisor = new FakeProcessSupervisor([900]); - - await startEdge(setup, { + }), drivers: [], - processRunner: runner, - processSupervisor, - role: "gateway", - }); - await pollUntil(() => false, 100); + }, + { + role: "worker", + config: async () => ({ capabilities: { "agent-device": { command, enabled: false } } }), + drivers: undefined, + }, + ] as const)( + "a $role with no agent-device supervisor runs no ps and sends no signal, even with a recorded daemon on disk", + async ({ role, config, drivers }) => { + const setup = await setUp(await config(), { record: { pid: 900 } }); + const runner = new ScriptedProcessRunner([]); + const processSupervisor = new FakeProcessSupervisor([900]); + + const { clock } = await startEdge(setup, { + ...(drivers === undefined ? {} : { drivers: [...drivers] }), + processRunner: runner, + processSupervisor, + ...(role === "gateway" ? { role } : {}), + }); + clock.advance(SUPERVISOR_RETRY_MS); + await pollUntil(() => false, 100); - expect(runner.calls).toEqual([]); - expect(processSupervisor.signals).toEqual([]); - }); + expect(runner.calls).toEqual([]); + expect(processSupervisor.signals).toEqual([]); + }, + ); it('a worker with no capabilities["agent-device"] section, one with enabled false, and one with only the old agentDevice key answer status.get with { name: agent-device, state: off }', async () => { const admin = { @@ -3712,21 +3617,6 @@ describe("startDaemon with the agent-device edge", () => { expect(loads).toBe(1); }); - it("a worker with agent-device off starts no supervisor: no ps runs and no signal is sent, even with a recorded daemon on disk", async () => { - const setup = await setUp( - { capabilities: { "agent-device": { command, enabled: false } } }, - { record: { pid: 900 } }, - ); - const runner = new ScriptedProcessRunner([]); - const processSupervisor = new FakeProcessSupervisor([900]); - - await startEdge(setup, { processRunner: runner, processSupervisor }); - await pollUntil(() => false, 100); - - expect(runner.calls).toEqual([]); - expect(processSupervisor.signals).toEqual([]); - }); - it("runs no ps and sends no signal in a second daemon on the same home that loses the socket claim", async () => { const first = await setUp({ capabilities: { "agent-device": { enabled: false } } }); await startEdge(first, { @@ -3739,10 +3629,12 @@ describe("startDaemon with the agent-device edge", () => { ); const runner = new ScriptedProcessRunner([]); const processSupervisor = new FakeProcessSupervisor([900]); + const clock = new FakeClock(1_000); await expect( - startEdge(second, { processRunner: runner, processSupervisor }), + startEdge(second, { clock, processRunner: runner, processSupervisor }), ).rejects.toMatchObject({ name: "DaemonAlreadyRunningError" }); + clock.advance(SUPERVISOR_RETRY_MS); await pollUntil(() => false, 100); expect(runner.calls).toEqual([]); @@ -3984,39 +3876,75 @@ describe("startDaemon with the agent-device edge", () => { }); }); -describe("startDaemon host load", () => { - const COUNTERS: HostLoadCounters = { - cpu: { busyTime: 100, cores: 4, limited: false, totalTime: 1_000 }, - disk: { freeBytes: 120, totalBytes: 500 }, - memory: { - availableBytes: 4_000, - limited: false, - pressure: "normal", - swapUsedBytes: 100, - totalBytes: 16_000, +const COUNTERS: HostLoadCounters = { + cpu: { busyTime: 100, cores: 4, limited: false, totalTime: 1_000 }, + disk: { freeBytes: 120, totalBytes: 500 }, + memory: { + availableBytes: 4_000, + limited: false, + pressure: "normal", + swapUsedBytes: 100, + totalBytes: 16_000, + }, +}; + +async function startOnClock(overrides: Partial = {}) { + const directory = await mkdtemp(join(tmpdir(), "simlock-main-load-")); + temporaryDirectories.push(directory); + const clock = new FakeClock(1_000); + const daemon = await startDaemon({ + configOverrides: STANDALONE_OFF, + deviceDataTables: fakeDeviceDataTables, + clock, + dataDirectory: directory, + drivers: [new FakeDriver({ availableOsVersions: ["26.5"], clock, platform: "ios" })], + filesystem: new MemoryFilesystem(), + hostInfo: new FakeHostInfo({ arch: "arm64", os: "macOS", osVersion: "15.5" }), + logger: new JsonLinesLogger({ clock, level: "debug", sink: new MemoryLogSink() }), + version: "1.2.3", + ...overrides, + } as StartDaemonOptions); + runningDaemons.push(daemon); + return { clock, daemon, directory }; +} + +async function startGateway( + overrides: Partial & { readonly directory?: string } = {}, +) { + const { directory: given, ...rest } = overrides; + const directory = given ?? (await mkdtemp(join(tmpdir(), "simlock-main-gw-load-"))); + if (given === undefined) temporaryDirectories.push(directory); + const clock = new FakeClock(1_000); + const daemon = await startDaemon({ + clock, + configOverrides: { + http: { enabled: true, host: "127.0.0.1", port: await freeLoopbackPort() }, }, - }; + role: "gateway", + dataDirectory: directory, + drivers: [], + filesystem: new MemoryFilesystem(), + logger: new JsonLinesLogger({ clock, level: "debug", sink: new MemoryLogSink() }), + version: "1.2.3", + ...rest, + } as StartDaemonOptions); + runningDaemons.push(daemon); + return { clock, daemon, directory }; +} - async function startOnClock(overrides: Partial = {}) { - const directory = await mkdtemp(join(tmpdir(), "simlock-main-load-")); - temporaryDirectories.push(directory); - const clock = new FakeClock(1_000); - const daemon = await startDaemon({ - configOverrides: STANDALONE_OFF, - deviceDataTables: fakeDeviceDataTables, - clock, - dataDirectory: directory, - drivers: [new FakeDriver({ availableOsVersions: ["26.5"], clock, platform: "ios" })], - filesystem: new MemoryFilesystem(), - hostInfo: new FakeHostInfo({ arch: "arm64", os: "macOS", osVersion: "15.5" }), - logger: new JsonLinesLogger({ clock, level: "debug", sink: new MemoryLogSink() }), - version: "1.2.3", - ...overrides, - } as StartDaemonOptions); - runningDaemons.push(daemon); - return { clock, daemon, directory }; - } +const hostLoadAdmin = { + manageEventSubscription: () => undefined, + via: "socket", + principal: "test-operator", + role: "admin", +} as const; + +const hostLoadRoles = [ + { role: "worker", start: startOnClock }, + { role: "gateway", start: startGateway }, +] as const; +describe("startDaemon host load", () => { async function workerStatus(daemon: DaemonServer): Promise> { return (await daemon.dispatch( "status.get", @@ -4139,17 +4067,14 @@ describe("startDaemon host load", () => { expect(load?.ageMs).toBe(86_400_000); }); - it("with hostLoadProbe: null answers the same as with a probe, apart from load, and arms no host load timer", async () => { + it("with hostLoadProbe: null answers the same as with a probe, apart from load", async () => { const withProbe = await startOnClock({ hostLoadProbe: new FakeHostLoadProbe(COUNTERS) }); const without = await startOnClock({ hostLoadProbe: null }); await expect.poll(async () => (await workerStatus(withProbe.daemon)).load).toBeDefined(); const { load: _load, ...probed } = await workerStatus(withProbe.daemon); - const bare = await workerStatus(without.daemon); - expect(bare).not.toHaveProperty("load"); - expect(probed).toEqual(bare); - expect(withProbe.clock.pendingTimerCount).toBeGreaterThan(without.clock.pendingTimerCount); + expect(probed).toEqual(await workerStatus(without.daemon)); }); it("starts the sampler only after the daemon holds its socket", async () => { @@ -4210,32 +4135,9 @@ describe("startDaemon host load", () => { expect(probe.reads).toBe(1); expect(withProbe).toBe(without); }); - - it("leaves no host load timer pending after a stop", async () => { - const probe = new FakeHostLoadProbe(COUNTERS); - const withProbe = await startOnClock({ hostLoadProbe: probe }); - const without = await startOnClock({ hostLoadProbe: null }); - expect(withProbe.clock.pendingTimerCount).toBeGreaterThan(without.clock.pendingTimerCount); - - await withProbe.daemon.stop("test"); - await without.daemon.stop("test"); - - expect(withProbe.clock.pendingTimerCount).toBe(without.clock.pendingTimerCount); - }); }); describe("startDaemon in gateway mode host load", () => { - const COUNTERS: HostLoadCounters = { - cpu: { busyTime: 100, cores: 4, limited: false, totalTime: 1_000 }, - disk: { freeBytes: 120, totalBytes: 500 }, - memory: { - availableBytes: 4_000, - limited: false, - pressure: "normal", - swapUsedBytes: 100, - totalBytes: 16_000, - }, - }; const operator = { manageEventSubscription: () => undefined, via: "socket", @@ -4243,30 +4145,6 @@ describe("startDaemon in gateway mode host load", () => { role: "admin", } as const; - async function startGateway( - overrides: Partial & { readonly directory?: string } = {}, - ) { - const { directory: given, ...rest } = overrides; - const directory = given ?? (await mkdtemp(join(tmpdir(), "simlock-main-gw-load-"))); - if (given === undefined) temporaryDirectories.push(directory); - const clock = new FakeClock(1_000); - const daemon = await startDaemon({ - clock, - configOverrides: { - http: { enabled: true, host: "127.0.0.1", port: await freeLoopbackPort() }, - }, - role: "gateway", - dataDirectory: directory, - drivers: [], - filesystem: new MemoryFilesystem(), - logger: new JsonLinesLogger({ clock, level: "debug", sink: new MemoryLogSink() }), - version: "1.2.3", - ...rest, - } as StartDaemonOptions); - runningDaemons.push(daemon); - return { clock, daemon, directory }; - } - const statusOf = async (daemon: DaemonServer) => (await daemon.dispatch("status.get", {}, operator)) as Record; @@ -4280,16 +4158,6 @@ describe("startDaemon in gateway mode host load", () => { expect(probe.reads).toBe(1); }); - it("a gateway started with hostLoadProbe: null answers status.get without load, and arms no host load timer", async () => { - const withProbe = await startGateway({ hostLoadProbe: new FakeHostLoadProbe(COUNTERS) }); - const without = await startGateway({ hostLoadProbe: null }); - - await expect.poll(async () => (await statusOf(withProbe.daemon)).load).toBeDefined(); - - expect(await statusOf(without.daemon)).not.toHaveProperty("load"); - expect(withProbe.clock.pendingTimerCount).toBeGreaterThan(without.clock.pendingTimerCount); - }); - it("a gateway started with no hostLoadProbe builds the production probe, the same factory a worker uses", async () => { const { daemon } = await startGateway({}); @@ -4300,17 +4168,7 @@ describe("startDaemon in gateway mode host load", () => { .toMatchObject({ totalBytes: expect.any(Number) }); }); - it("a stopped gateway, and one that lost the start race, leave no host load timer pending", async () => { - const probe = new FakeHostLoadProbe(COUNTERS); - const withProbe = await startGateway({ hostLoadProbe: probe }); - const without = await startGateway({ hostLoadProbe: null }); - expect(withProbe.clock.pendingTimerCount).toBeGreaterThan(without.clock.pendingTimerCount); - - await withProbe.daemon.stop("test"); - await without.daemon.stop("test"); - - expect(withProbe.clock.pendingTimerCount).toBe(without.clock.pendingTimerCount); - + it("a gateway that lost the start race leaves no host load timer pending", async () => { const first = await startGateway({ hostLoadProbe: null }); const lost = async (hostLoadProbe: FakeHostLoadProbe | null) => { const clock = new FakeClock(1_000); @@ -4342,6 +4200,38 @@ describe("startDaemon in gateway mode host load", () => { }); }); +describe("startDaemon host load by role", () => { + const statusOf = async (daemon: DaemonServer) => + (await daemon.dispatch("status.get", {}, hostLoadAdmin)) as Record; + + it.each(hostLoadRoles)( + "a $role started with hostLoadProbe: null answers status.get without load, and arms no host load timer", + async ({ start }) => { + const withProbe = await start({ hostLoadProbe: new FakeHostLoadProbe(COUNTERS) }); + const without = await start({ hostLoadProbe: null }); + + await expect.poll(async () => (await statusOf(withProbe.daemon)).load).toBeDefined(); + + expect(await statusOf(without.daemon)).not.toHaveProperty("load"); + expect(withProbe.clock.pendingTimerCount).toBeGreaterThan(without.clock.pendingTimerCount); + }, + ); + + it.each(hostLoadRoles)( + "a $role leaves no host load timer pending after a stop", + async ({ start }) => { + const withProbe = await start({ hostLoadProbe: new FakeHostLoadProbe(COUNTERS) }); + const without = await start({ hostLoadProbe: null }); + expect(withProbe.clock.pendingTimerCount).toBeGreaterThan(without.clock.pendingTimerCount); + + await withProbe.daemon.stop("test"); + await without.daemon.stop("test"); + + expect(withProbe.clock.pendingTimerCount).toBe(without.clock.pendingTimerCount); + }, + ); +}); + describe("daemon role (ADR 0034)", () => { it("startDaemon with role gateway and no data directory uses /gateway", async () => { const root = await mkdtemp(join(tmpdir(), "simlock-main-root-")); diff --git a/src/daemon/server.test.ts b/src/daemon/server.test.ts index 928f19fab..c4c08bfd7 100644 --- a/src/daemon/server.test.ts +++ b/src/daemon/server.test.ts @@ -48,7 +48,6 @@ import { } from "../ports/index.js"; import { DAEMON_PROTOCOL_VERSION } from "../daemon-protocol/index.js"; import { DaemonEndpointHost, type ConnectionHost } from "./connection-host.js"; -import { Dispatcher } from "./dispatcher.js"; import { describeLeaseRequestFailure } from "./error-code.js"; import { AdminAuthenticationFailedError, @@ -398,7 +397,7 @@ describe("DaemonServer", () => { }); }); - it("maps status, cleanup, events, config, and detached lease commands to daemon-owned core state", async () => { + it("answers status.get with the capacity, answers list.get, config.get, cleanup.run and events.replay as ok, pushes subscribed events, and renews and releases a lease", async () => { const harness = await createHarness(); const client = await createClient(harness.socketPath); await hello(client); @@ -1052,26 +1051,6 @@ describe("DaemonServer", () => { await releaser.close(); }); - it("does not push a redundant lease-lost notification back to a connection that explicitly released its own lease", async () => { - const harness = await createHarness(); - const holder = await createClient(harness.socketPath); - await hello(holder); - const grant = await holder.request("lease.request", { - requesterId: "holder", - model: "iPhone 16", - osVersion: "26.5", - platform: "ios", - }); - const leaseId = leaseIdOf(grant); - - await expect(holder.request("lease.release", { leaseId })).resolves.toMatchObject({ - ok: true, - }); - await expect(holder.request("status.get", {})).resolves.toMatchObject({ ok: true }); - expect(holder.frames().filter((frame) => frame.push === "lease-lost")).toEqual([]); - await holder.close(); - }); - it("drops a lease-lost push to a connection whose peer has hung up instead of leaving its failed write unhandled", async () => { const connection = new BlockingConnection(); const harness = await createHarness({ host: new SingleConnectionHost(connection) }); @@ -1396,7 +1375,7 @@ describe("DaemonServer startup readiness", () => { }); }); - it("does not arm live machinery when a stop completes while convergence is still running", async () => { + it("emits daemon.stopping and no daemon.started when a stop completes during convergence", async () => { const converge = deferred(); const harness = await createHarness({ converge: () => converge.promise, start: false }); const emitted: string[] = []; @@ -1807,19 +1786,35 @@ describe("DaemonServer driver rejections", () => { reason: "wrong-instance", summary: "Refusing the ios device root /Devices: it belongs to another instance", }, + { + event: "driver.adb-server-rejected", + payload: { port: 5038, reason: "occupied" }, + platform: "android", + reason: "occupied", + summary: "Refusing the Android driver: port 5038 is held by an adb server we do not own", + }, ], }); const events = harness.eventBus.replay(); - const rejected = events.filter((event) => event.event === "driver.root-rejected"); + const rejected = events.filter( + (event) => + event.event === "driver.root-rejected" || event.event === "driver.adb-server-rejected", + ); expect(rejected).toEqual([ expect.objectContaining({ + event: "driver.root-rejected", module: "daemon", payload: { platform: "ios", reason: "wrong-instance", root: "/Devices" }, }), + expect.objectContaining({ + event: "driver.adb-server-rejected", + module: "daemon", + payload: { port: 5038, reason: "occupied" }, + }), ]); const started = events.find((event) => event.event === "daemon.started"); - expect(started?.seq).toBeLessThan(rejected[0]?.seq ?? 0); + for (const event of rejected) expect(started?.seq).toBeLessThan(event.seq); }); it("names the refusal when a lease asks for a platform whose driver did not start", async () => { @@ -1872,62 +1867,51 @@ describe("DaemonServer driver rejections", () => { expect(response.error?.message).toBe("No driver registered for platform: android"); }); - it("names the refusal when a passthrough asks for the tool whose driver did not start", async () => { - const harness = await createHarness({ - driverRejections: [ - rejectionNaming( - { - event: "driver.adb-server-rejected", - payload: { port: 5038, reason: "occupied" }, - platform: "android", - reason: "occupied", - summary: - "Refusing the Android driver: port 5038 is held by an adb server we do not own", - }, - ["adb"], - ), - ], - }); - const client = await createClient(harness.socketPath); - await hello(client); - - const response = await client.request("driver.passthrough", { - args: ["devices"], - tool: "adb", - }); - - expect(response.error?.code).toBe("UNKNOWN_PASSTHROUGH_TOOL"); - expect(response.error?.message).toContain("port 5038 is held by an adb server we do not own"); - }); - - it("names the iOS refusal when a devicectl passthrough asks for the tool whose driver did not start", async () => { - const harness = await createHarness({ - driverRejections: [ - rejectionNaming( - { - event: "driver.root-rejected", - payload: { platform: "ios", reason: "symlink", root: "/Devices" }, - platform: "ios", - reason: "symlink", - summary: "Refusing the ios device root /Devices: it is a symlink", - }, - ["simctl", "devicectl"], - ), - ], - }); - const client = await createClient(harness.socketPath); - await hello(client); + it.each([ + { + name: "adb", + rejection: { + event: "driver.adb-server-rejected", + payload: { port: 5038, reason: "occupied" }, + platform: "android", + reason: "occupied", + summary: "Refusing the Android driver: port 5038 is held by an adb server we do not own", + }, + tools: ["adb"], + request: { args: ["devices"], tool: "adb" }, + text: "port 5038 is held by an adb server we do not own", + }, + { + name: "devicectl", + rejection: { + event: "driver.root-rejected", + payload: { platform: "ios", reason: "symlink", root: "/Devices" }, + platform: "ios", + reason: "symlink", + summary: "Refusing the ios device root /Devices: it is a symlink", + }, + tools: ["simctl", "devicectl"], + request: { args: ["list", "devices"], tool: "devicectl" }, + text: "Refusing the ios device root /Devices: it is a symlink", + }, + ] as const)( + "names the refusal when a $name passthrough asks for the tool whose driver did not start", + async ({ rejection, request, text, tools }) => { + const harness = await createHarness({ + driverRejections: [rejectionNaming(rejection, tools)], + }); + const client = await createClient(harness.socketPath); + await hello(client); - const response = await client.request("driver.passthrough", { - args: ["list", "devices"], - tool: "devicectl", - }); + const response = await client.request("driver.passthrough", { + args: [...request.args], + tool: request.tool, + }); - expect(response.error?.code).toBe("UNKNOWN_PASSTHROUGH_TOOL"); - expect(response.error?.message).toContain( - "Refusing the ios device root /Devices: it is a symlink", - ); - }); + expect(response.error?.code).toBe("UNKNOWN_PASSTHROUGH_TOOL"); + expect(response.error?.message).toContain(text); + }, + ); it("leaves an unknown passthrough tool unexplained when no driver claimed it", async () => { const harness = await createHarness({ @@ -2011,28 +1995,6 @@ describe("DaemonServer lease liveness (ADR 0004)", () => { await holder.close(); }); - it("never pushes anything to a connection to prove liveness", async () => { - const harness = await createHarness({ lease: { defaultTtlMs: 40 } }); - const holder = await createClient(harness.socketPath); - await hello(holder); - const grant = await holder.request("lease.request", { - requesterId: "agent-1", - model: "iPhone 16", - osVersion: "26.5", - platform: "ios", - }); - const leaseId = leaseIdOf(grant); - - harness.clock.advance(40); - await expect.poll(() => harness.registry.snapshot.leases).toEqual([]); - expect(holder.frames().filter((frame) => frame.push === "lease.heartbeat")).toEqual([]); - await expect(holder.nextFrame((frame) => frame.push === "lease-lost")).resolves.toMatchObject({ - payload: { leaseId, reason: "expired" }, - push: "lease-lost", - }); - await holder.close(); - }); - it("no longer answers lease.heartbeat at all", async () => { const harness = await createHarness(); const client = await createClient(harness.socketPath); @@ -2089,13 +2051,30 @@ describe("DaemonServer lease liveness (ADR 0004)", () => { }); it("disposes the worker's dispatcher, ending its bus subscriptions, when the daemon stops", async () => { - const harness = await createHarness({ resolveRole: { resolve: () => "admin" } }); - const dispose = vi.spyOn(Dispatcher.prototype, "dispose"); + const live = new Map(); + const subscribe = EventBus.prototype.subscribe; + const spy = vi + .spyOn(EventBus.prototype, "subscribe") + .mockImplementation(function (this: EventBus, event, handler) { + const unsubscribe = subscribe.call(this, event, handler); + live.set(event, (live.get(event) ?? 0) + 1); + let ended = false; + return () => { + if (!ended) live.set(event, (live.get(event) ?? 0) - 1); + ended = true; + unsubscribe(); + }; + }); + try { + const harness = await createHarness({ resolveRole: { resolve: () => "admin" } }); + expect(live.get("component.installed")).toBeGreaterThan(0); - await harness.daemon.stop("test"); + await harness.daemon.stop("test"); - expect(dispose).toHaveBeenCalledTimes(1); - dispose.mockRestore(); + expect(live.get("component.installed")).toBe(0); + } finally { + spy.mockRestore(); + } }); it("answers worker.install-component and worker.update with UNSUPPORTED_IN_WORKER_MODE: a worker has no workers to install on or update", async () => { @@ -2370,17 +2349,6 @@ describe("DaemonServer decorations", () => { expect(order).toEqual(["beginStop", "stopAuxiliary"]); }); - - it("reports health via the public accessor across the startup/stop lifecycle", async () => { - const harness = await createHarness({ start: false }); - expect(harness.daemon.health).toBe("starting"); - - await harness.daemon.start(); - expect(harness.daemon.health).toBe("running"); - - await harness.daemon.stop("test-health"); - expect(harness.daemon.health).toBe("running"); - }); }); }); @@ -2408,7 +2376,7 @@ describe("DaemonServer download policy", () => { await client.close(); }); - it("still answers a lease request whose download names a component past the progress push's bound, sending no downloading push for it and logging that", async () => { + it("still answers a lease request whose download names a component past the progress push's bound, sending no downloading push for it", async () => { const clock = new FakeClock(1_000); const driver = new FakeDriver({ availableOsVersions: ["26.5"], clock, platform: "ios" }); // why: the driver, not the request, names the component: a request's exact osVersion is held @@ -2420,12 +2388,7 @@ describe("DaemonServer download policy", () => { new RuntimeMissingError("ios", "26.5", { component: "9".repeat(65) }), ); } - const sink = new MemoryLogSink(); - const harness = await createHarness({ - clock, - driver, - logger: new JsonLinesLogger({ clock, level: "debug", sink }), - }); + const harness = await createHarness({ clock, driver }); const client = await createClient(harness.socketPath); await hello(client); @@ -2444,13 +2407,6 @@ describe("DaemonServer download policy", () => { .map((frame) => (frame.payload as { progress: { stage: string } }).progress.stage); expect(stages).toContain("provisioning"); expect(stages).not.toContain("downloading"); - expect(sink.records).toContainEqual( - expect.objectContaining({ - fields: { stage: "downloading" }, - level: "warn", - message: "A lease progress push was not sent", - }), - ); await client.close(); }); @@ -2502,6 +2458,22 @@ describe("DaemonServer download policy", () => { expect(response.ok).toBe(false); expect(response.error).toMatchObject({ code: "RUNTIME_MISSING" }); expect(response.error?.message).not.toContain("downloads.policy"); + expect(driver.calls.map((call) => call.operation)).not.toContain("installComponent"); + + const flagged = await client.request("lease.request", { + allowDownload: true, + requesterId: "agent-1", + model: "iPhone 16", + osVersion: "26.5", + platform: "ios", + }); + + expect(flagged.ok).toBe(true); + expect( + driver.calls + .filter((call) => call.operation === "installComponent") + .map((call) => call.arguments), + ).toEqual([["26.5"]]); await client.close(); }); @@ -2647,18 +2619,6 @@ describe("DaemonServer roles and ownership (ADR 0003 §2-4)", () => { await client.close(); }); - it("lets an admin connection stop the daemon", async () => { - const harness = await createHarness({ resolveRole: { resolve: () => "admin" } }); - const client = await createClient(harness.socketPath); - await hello(client); - - await expect(client.request("daemon.stop", {})).resolves.toMatchObject({ - ok: true, - payload: { stopping: true }, - }); - await expect.poll(() => harness.daemon.health).toBe("running"); - }); - it("still fails the handshake outright on a wrong credential, and daemon.stop never runs on that connection", async () => { const harness = await createHarness({ resolveRole: { @@ -2684,7 +2644,9 @@ describe("DaemonServer roles and ownership (ADR 0003 §2-4)", () => { }); await socketClosed; - expect(harness.daemon.health).toBe("running"); + expect(harness.eventBus.replay().map((event) => event.event)).not.toContain( + "daemon.stopping", + ); }); it("lets an admin connection stop the daemon even when its own hello's protocol negotiation failed, and refuses every other operation on it with the version error", async () => { @@ -2719,7 +2681,9 @@ describe("DaemonServer roles and ownership (ADR 0003 §2-4)", () => { ok: true, payload: { stopping: true }, }); - await expect.poll(() => harness.daemon.health).toBe("running"); + await expect + .poll(() => harness.eventBus.replay().map((event) => event.event)) + .toContain("daemon.stopping"); }); it("still refuses daemon.stop with FORBIDDEN on a version-mismatched connection whose role is agent", async () => { @@ -2805,6 +2769,11 @@ describe("DaemonServer roles and ownership (ADR 0003 §2-4)", () => { const admin = await createClient(harness.socketPath); await helloAs(admin, "operator"); + harness.clock.advance(5_000); + await expect(admin.request("lease.renew", { leaseId })).resolves.toMatchObject({ + ok: true, + payload: { ttlDeadline: 6_000 + 60_000 }, + }); await expect(admin.request("lease.release", { leaseId })).resolves.toMatchObject({ ok: true }); await owner.close(); await admin.close(); @@ -2875,7 +2844,10 @@ describe("DaemonServer roles and ownership (ADR 0003 §2-4)", () => { }); it("forwards ttlMs as the initial TTL of any lease, and stores it as the lease's width", async () => { - const harness = await createHarness({ resolveRole: { resolve: () => "agent" } }); + const harness = await createHarness({ + iosMaxDevices: 2, + resolveRole: { resolve: () => "agent" }, + }); const client = await createClient(harness.socketPath); await hello(client); @@ -2897,6 +2869,15 @@ describe("DaemonServer roles and ownership (ADR 0003 §2-4)", () => { ok: true, payload: { ttlMs: 30_000, ttlDeadline: 6_000 + 30_000 }, }); + + const defaulted = await client.request("lease.request", { + requesterId: "defaulted", + model: "iPhone 16", + osVersion: "26.5", + platform: "ios", + }); + expect(defaulted.ok).toBe(true); + expect((defaulted.payload as { lease: { ttlMs: number } }).lease.ttlMs).toBe(60_000); await client.close(); }); @@ -2952,6 +2933,7 @@ describe("DaemonServer roles and ownership (ADR 0003 §2-4)", () => { const push = await observer.nextFrame((frame) => frame.push === "lease-lost"); expect(push.payload).toMatchObject({ leaseId, reason: "explicit" }); + await expect(requester.request("status.get", {})).resolves.toMatchObject({ ok: true }); expect(requester.frames().filter((frame) => frame.push === "lease-lost")).toEqual([]); await requester.close(); diff --git a/src/drivers/android/input/device-input.test.ts b/src/drivers/android/input/device-input.test.ts index b5f25a6f4..4ec71131c 100644 --- a/src/drivers/android/input/device-input.test.ts +++ b/src/drivers/android/input/device-input.test.ts @@ -233,16 +233,11 @@ function remove(): ScriptedProcessExpectation { // why: both devices' worlds: the other device's target is here so "it went through this device's // target" can fail, since an input aimed at the wrong device would show up in the other's log. -class RecordingLogger implements Logger { - readonly lines: string[] = []; +class SilentLogger implements Logger { debug(): void {} info(): void {} - warn(message: string): void { - this.lines.push(message); - } - error(message: string): void { - this.lines.push(message); - } + warn(): void {} + error(): void {} child(): Logger { return this; } @@ -255,7 +250,7 @@ function setup( const clock = new FakeClock(1_000); const target = new FakeTarget(device.address, expectations); const other = new FakeTarget("other", []); - const logger = new RecordingLogger(); + const logger = new SilentLogger(); const factory = createAndroidDeviceInput({ clock, logger, @@ -263,7 +258,7 @@ function setup( serverPath: SERVER, targetFor: async (asked) => (asked === device ? target : other), }); - return { clock, device, factory, logger, other, target }; + return { clock, device, factory, other, target }; } async function drive(clock: FakeClock, promise: Promise, stepMs = 50): Promise { @@ -346,7 +341,7 @@ function configs(input: DeviceInput): InputScreenConfig[] { } describe("Android device input: open", () => { - it("starts a control-only scrcpy and reads the screen's size in its current rotation, a phone passes the adb check first, a start that fails is rejected, a rotation is reported once, a close stops the reads, reads never overlap, and a failing read is logged once", async () => { + it("starts a control-only scrcpy and reads the screen's size in its current rotation, a phone passes the adb check first, a start that fails is rejected, a rotation is reported once, a close stops the reads, reads never overlap, and a failing read keeps the last size until one succeeds", async () => { const world = setup(); const input = opened(await open(world)); expect(world.target.log).toEqual([`push ${SERVER} ${DEVICE_JAR}`, SERVER_ARGS]); @@ -412,7 +407,6 @@ describe("Android device input: open", () => { failing.target.readFails = true; await settle(failing.clock, 7_000); expect(failing.target.reads).toBeGreaterThan(3); - expect(failing.logger.lines).toEqual(["Could not read the screen size"]); expect(kept).toEqual([{ height: 2400, orientation: "portrait", width: 1080 }]); failing.target.display = displays(2400, 1080); failing.target.readFails = false; diff --git a/src/drivers/ios/index.test.ts b/src/drivers/ios/index.test.ts index efef757f5..06c5cca64 100644 --- a/src/drivers/ios/index.test.ts +++ b/src/drivers/ios/index.test.ts @@ -31,7 +31,6 @@ import { IosSimctlDriver, iosRuntimeVersionFromId, sanitizeSlimLabels, - supportsPersistentSlim, type SlimmedFact, type SlimSkippedFact, } from "./index.js"; @@ -1407,7 +1406,11 @@ describe("IosSimctlDriver", () => { { address: driverData.udid, deviceId: driverData.udid, driverData }, { clean: "full" }, ), - ).rejects.toThrow(); + ).rejects.toThrow(`ENOENT: no such file or directory, open '${dataPath}'`); + expect(runner.calls.map((call) => call.args)).toEqual([ + simctlArgs("shutdown", driverData.udid), + simctlArgs("erase", driverData.udid), + ]); // why: A durable mark standing alone is exactly what `Doctor` reads as a foreign erase, so // a half-written pair would have `doctor` accusing the user of erasing the device @@ -1638,22 +1641,6 @@ describe("IosSimctlDriver", () => { ]); }); - it("answers virtualLeaseEnvironment with the device set, and leaseEnvironment for a virtual device equals it", async () => { - const driver = await createDriver(new ScriptedProcessRunner([])); - - expect(driver.virtualLeaseEnvironment()).toEqual({ SIMLOCK_IOS_DEVICE_SET: deviceRoot }); - expect(driver.leaseEnvironment(VIRTUAL_DEVICE)).toEqual(driver.virtualLeaseEnvironment()); - }); - - it("hands a lease holder of a physical device its UDID and no simulator device set", async () => { - const driver = await createDriver(new ScriptedProcessRunner([])); - - expect(driver.leaseEnvironment(VIRTUAL_DEVICE)).toEqual({ SIMLOCK_IOS_DEVICE_SET: deviceRoot }); - expect(driver.leaseEnvironment(PHYSICAL_DEVICE)).toEqual({ - SIMLOCK_DEVICE_UDID: "00008110-001A2B3C4D5E", - }); - }); - it("answers uninstall for the reclaim strategy of a physical device", async () => { const driver = await createDriver(new ScriptedProcessRunner([])); @@ -1879,7 +1866,13 @@ describe("IosSimctlDriver", () => { it("still proxies the runtime operations that only read", async () => { const driver = await createDriver(new ScriptedProcessRunner([])); - expect((await simctlPassthrough(driver, ["runtime", "list"])).args).toContain("list"); + expect((await simctlPassthrough(driver, ["runtime", "list"])).args).toEqual([ + "simctl", + "--set", + deviceRoot, + "runtime", + "list", + ]); }); it.skipIf(process.env.SIMLOCK_LIVE_IOS !== "1")( @@ -1920,6 +1913,7 @@ describe("IosSimctlDriver", () => { const searchSignature = "v1:7587bb9b67a41b64"; const slim18_5 = { ...driverData, runtimeId: "com.apple.CoreSimulator.SimRuntime.iOS-18-5" }; const slim18_4 = { ...driverData, runtimeId: "com.apple.CoreSimulator.SimRuntime.iOS-18-4" }; + const slim9_0 = { ...driverData, runtimeId: "com.apple.CoreSimulator.SimRuntime.iOS-9-0" }; const slim17_5 = { ...driverData, runtimeId: "com.apple.CoreSimulator.SimRuntime.iOS-17-5" }; const slim26_0 = { ...driverData, runtimeId: "com.apple.CoreSimulator.SimRuntime.iOS-26-0" }; const slimGarbageRuntime = { ...driverData, runtimeId: "not-a-runtime-id" }; @@ -1943,16 +1937,6 @@ describe("IosSimctlDriver", () => { expect(iosRuntimeVersionFromId("garbage")).toBeUndefined(); }); - it("gates persistent slim on iOS 18.5+ without falling into string comparison", () => { - // why: A naive string/lexicographic compare would put "9.0" ahead of "18.5" -- the gate must - // compare the parsed integers instead. - expect(supportsPersistentSlim([18, 5])).toBe(true); - expect(supportsPersistentSlim([26, 0])).toBe(true); - expect(supportsPersistentSlim([9, 0])).toBe(false); - expect(supportsPersistentSlim([18, 4])).toBe(false); - expect(supportsPersistentSlim(undefined)).toBe(false); - }); - it("filters a label that could break out of the generated shell script", () => { expect( sanitizeSlimLabels(["com.apple.good", "system; rm -rf /", 'com.apple."quoted"']), @@ -1962,34 +1946,6 @@ describe("IosSimctlDriver", () => { }); }); - it("checks installer readiness without slimming when the slim option is omitted", async () => { - const runner = new ScriptedProcessRunner([ - { match: { command: "xcrun", args: simctlArgs("boot", driverData.udid) } }, - { match: { command: "xcrun", args: simctlArgs("bootstatus", driverData.udid, "-b") } }, - { match: simctl("listapps", driverData.udid) }, - ]); - - // why: `driverData` has no `slimSignature`/`slimMarkToken`, exactly what a registry written - // before slim mode shipped holds, so this also proves such data still loads. - await expect( - (await createDriver(runner)).makeReady( - { address: driverData.udid, deviceId: driverData.udid, driverData }, - prepareFull, - ), - ).resolves.toEqual({ - address: driverData.udid, - deviceId: driverData.udid, - driverData, - // why: Slim mode is off, so this boot slimmed nothing: the device is full. - mode: "full", - }); - expect(runner.calls.map((call) => call.args)).toEqual([ - simctlArgs("boot", driverData.udid), - simctlArgs("bootstatus", driverData.udid, "-b"), - simctlArgs("listapps", driverData.udid), - ]); - }); - it.each([false, true])( "boots a slim-spec device on 18.5, applies the disable list, and reboots (slim by default: %s)", async (slimByDefault) => { @@ -2066,138 +2022,93 @@ describe("IosSimctlDriver", () => { }, ); - it("downgrades a failed post-apply shutdown to a skip instead of failing the lease, but always reboots first (finding #3 / second-review finding #2, issue #87)", async () => { - const filesystem = new MemoryFilesystem(); - await plantManagedDevice(filesystem); - const runner = new ScriptedProcessRunner([ - { match: { command: "xcrun", args: simctlArgs("boot", slim18_5.udid) } }, - { match: { command: "xcrun", args: simctlArgs("bootstatus", slim18_5.udid, "-b") } }, - { match: simctl("listapps", slim18_5.udid) }, - { - match: { - args: simctlArgs("spawn", slim18_5.udid, "/bin/sh", "-c", slimScript(widgetsLabels)), - command: "xcrun", + it.each([ + [ + "exits non-zero", + { result: { code: 1, stderr: "unexpected shutdown failure", stdout: "" } }, + false, + ], + // why: A `simctl shutdown` *timeout* (as opposed to a clean non-zero exit) is the case + // second-review finding #2 targets: the shutdown may have completed, may still be in + // flight, or may never have taken effect. Rather than guess from the exception, the driver + // always re-establishes a known-good running device afterward and reports the apply as + // uncertain. + ["times out", { hangs: true }, true], + ])( + "downgrades a post-apply shutdown that %s to an apply-failed skip instead of failing the lease, but always reboots first (finding #3 / second-review finding #2, issue #87)", + async (_failure, shutdownOutcome, hangs) => { + const filesystem = new MemoryFilesystem(); + await plantManagedDevice(filesystem); + const clock = new FakeClock(); + const runner = new ScriptedProcessRunner([ + { match: { command: "xcrun", args: simctlArgs("boot", slim18_5.udid) } }, + { match: { command: "xcrun", args: simctlArgs("bootstatus", slim18_5.udid, "-b") } }, + { match: simctl("listapps", slim18_5.udid) }, + { + match: { + args: simctlArgs("spawn", slim18_5.udid, "/bin/sh", "-c", slimScript(widgetsLabels)), + command: "xcrun", + }, }, - }, - { - match: { command: "xcrun", args: simctlArgs("shutdown", slim18_5.udid) }, - result: { code: 1, stderr: "unexpected shutdown failure", stdout: "" }, - }, - // why: Always run after the shutdown call, whether or not it succeeded -- `boot` tolerates an - // already-booted device either way. - { match: { command: "xcrun", args: simctlArgs("boot", slim18_5.udid) } }, - { match: { command: "xcrun", args: simctlArgs("bootstatus", slim18_5.udid, "-b") } }, - { match: simctl("listapps", slim18_5.udid) }, - ]); - const onSlimmed = vi.fn(); - const onSlimSkipped = vi.fn(); - const driver = await createSlimDriver( - runner, - filesystem, - slimOptions(), - onSlimmed, - onSlimSkipped, - ); - - const result = await driver.makeReady( - { address: slim18_5.udid, deviceId: slim18_5.udid, driverData: slim18_5 }, - prepareSlim, - ); - - expect(result).toEqual({ - address: slim18_5.udid, - deviceId: slim18_5.udid, - driverData: slim18_5, - mode: "slim", - }); - expect(runner.calls.map((call) => call.args)).toEqual([ - simctlArgs("boot", slim18_5.udid), - simctlArgs("bootstatus", slim18_5.udid, "-b"), - simctlArgs("listapps", slim18_5.udid), - simctlArgs("spawn", slim18_5.udid, "/bin/sh", "-c", slimScript(widgetsLabels)), - simctlArgs("shutdown", slim18_5.udid), - simctlArgs("boot", slim18_5.udid), - simctlArgs("bootstatus", slim18_5.udid, "-b"), - simctlArgs("listapps", slim18_5.udid), - ]); - expect(onSlimmed).not.toHaveBeenCalled(); - expect(onSlimSkipped).toHaveBeenCalledWith( - expect.objectContaining({ deviceId: slim18_5.udid, reason: "apply-failed" }), - ); - }); - - it("recovers from a shutdown that timed out and reports it as an uncertain, skipped apply (second-review finding #2, issue #87)", async () => { - // why: A `simctl shutdown` *timeout* (as opposed to a clean non-zero exit) is exactly the case - // the fix targets: the shutdown may have completed, may still be in flight, or may never - // have taken effect. Rather than guess from the exception, the driver always re-establishes - // a known-good running device afterward and reports the apply as uncertain. - const filesystem = new MemoryFilesystem(); - await plantManagedDevice(filesystem); - const clock = new FakeClock(); - const runner = new ScriptedProcessRunner([ - { match: { command: "xcrun", args: simctlArgs("boot", slim18_5.udid) } }, - { match: { command: "xcrun", args: simctlArgs("bootstatus", slim18_5.udid, "-b") } }, - { match: simctl("listapps", slim18_5.udid) }, - { - match: { - args: simctlArgs("spawn", slim18_5.udid, "/bin/sh", "-c", slimScript(widgetsLabels)), - command: "xcrun", + { + match: { command: "xcrun", args: simctlArgs("shutdown", slim18_5.udid) }, + ...shutdownOutcome, }, - }, - { - hangs: true, - match: { command: "xcrun", args: simctlArgs("shutdown", slim18_5.udid) }, - }, - { match: { command: "xcrun", args: simctlArgs("boot", slim18_5.udid) } }, - { match: { command: "xcrun", args: simctlArgs("bootstatus", slim18_5.udid, "-b") } }, - { match: simctl("listapps", slim18_5.udid) }, - ]); - const onSlimmed = vi.fn(); - const onSlimSkipped = vi.fn(); - const driver = await IosSimctlDriver.create({ - driverConfig: { deviceRoot }, - instanceId, - simlockHome: "/home/.simlock", - clock, - filesystem, - idGenerator: { generate: () => "device-1" }, - onSlimSkipped, - onSlimmed, - processRunner: runner, - slim: slimOptions(), - }); + // why: Always run after the shutdown call, whether or not it succeeded -- `boot` tolerates an + // already-booted device either way. + { match: { command: "xcrun", args: simctlArgs("boot", slim18_5.udid) } }, + { match: { command: "xcrun", args: simctlArgs("bootstatus", slim18_5.udid, "-b") } }, + { match: simctl("listapps", slim18_5.udid) }, + ]); + const onSlimmed = vi.fn(); + const onSlimSkipped = vi.fn(); + const driver = await IosSimctlDriver.create({ + driverConfig: { deviceRoot }, + instanceId, + simlockHome: "/home/.simlock", + clock, + filesystem, + idGenerator: { generate: () => "device-1" }, + onSlimSkipped, + onSlimmed, + processRunner: runner, + slim: slimOptions(), + }); - const ready = driver.makeReady( - { address: slim18_5.udid, deviceId: slim18_5.udid, driverData: slim18_5 }, - prepareSlim, - ); + const ready = driver.makeReady( + { address: slim18_5.udid, deviceId: slim18_5.udid, driverData: slim18_5 }, + prepareSlim, + ); - await waitForCalls(runner, 5); - clock.advance(30_000); // COMMAND_TIMEOUT_MS, the `#shutdown` call's own timeout + if (hangs) { + await waitForCalls(runner, 5); + clock.advance(30_000); // COMMAND_TIMEOUT_MS, the `#shutdown` call's own timeout + } - const result = await ready; + const result = await ready; - expect(result).toEqual({ - address: slim18_5.udid, - deviceId: slim18_5.udid, - driverData: slim18_5, - mode: "slim", - }); - expect(runner.calls.map((call) => call.args)).toEqual([ - simctlArgs("boot", slim18_5.udid), - simctlArgs("bootstatus", slim18_5.udid, "-b"), - simctlArgs("listapps", slim18_5.udid), - simctlArgs("spawn", slim18_5.udid, "/bin/sh", "-c", slimScript(widgetsLabels)), - simctlArgs("shutdown", slim18_5.udid), - simctlArgs("boot", slim18_5.udid), - simctlArgs("bootstatus", slim18_5.udid, "-b"), - simctlArgs("listapps", slim18_5.udid), - ]); - expect(onSlimmed).not.toHaveBeenCalled(); - expect(onSlimSkipped).toHaveBeenCalledWith( - expect.objectContaining({ deviceId: slim18_5.udid, reason: "apply-failed" }), - ); - }); + expect(result).toEqual({ + address: slim18_5.udid, + deviceId: slim18_5.udid, + driverData: slim18_5, + mode: "slim", + }); + expect(runner.calls.map((call) => call.args)).toEqual([ + simctlArgs("boot", slim18_5.udid), + simctlArgs("bootstatus", slim18_5.udid, "-b"), + simctlArgs("listapps", slim18_5.udid), + simctlArgs("spawn", slim18_5.udid, "/bin/sh", "-c", slimScript(widgetsLabels)), + simctlArgs("shutdown", slim18_5.udid), + simctlArgs("boot", slim18_5.udid), + simctlArgs("bootstatus", slim18_5.udid, "-b"), + simctlArgs("listapps", slim18_5.udid), + ]); + expect(onSlimmed).not.toHaveBeenCalled(); + expect(onSlimSkipped).toHaveBeenCalledWith( + expect.objectContaining({ deviceId: slim18_5.udid, reason: "apply-failed" }), + ); + }, + ); it("propagates BootTimeoutError when the shutdown succeeds but the post-slim reboot times out (second-review finding #2, issue #87)", async () => { // why: Unlike the shutdown-failure cases above, the device here genuinely is shut down and the @@ -2423,11 +2334,14 @@ describe("IosSimctlDriver", () => { }); it.each([ - ["18.4", slim18_4], - ["17.5", slim17_5], + // why: 9.0 is the lexicographic trap: a string compare would put "9.0" ahead of "18.5". + ["runtime-too-old", "iOS 9.0", slim9_0], + ["runtime-too-old", "iOS 17.5", slim17_5], + ["runtime-too-old", "iOS 18.4", slim18_4], + ["unknown-runtime", "an unparseable runtimeId", slimGarbageRuntime], ])( - "skips slim with reason runtime-too-old on iOS %s (single boot, no spawns)", - async (_version, data) => { + "skips slim with reason %s on %s (single boot, no spawns)", + async (reason, _runtime, data) => { const runner = new ScriptedProcessRunner([ { match: { command: "xcrun", args: simctlArgs("boot", data.udid) } }, { match: { command: "xcrun", args: simctlArgs("bootstatus", data.udid, "-b") } }, @@ -2455,46 +2369,12 @@ describe("IosSimctlDriver", () => { }); expect(runner.calls).toHaveLength(3); expect(onSlimSkipped).toHaveBeenCalledWith( - expect.objectContaining({ deviceId: data.udid, reason: "runtime-too-old" }), + expect.objectContaining({ deviceId: data.udid, reason }), ); expect(runner.calls[1]?.options).toEqual({ timeoutMs: 120_000 }); }, ); - it("skips slim with reason unknown-runtime on an unparseable runtimeId", async () => { - const runner = new ScriptedProcessRunner([ - { match: { command: "xcrun", args: simctlArgs("boot", slimGarbageRuntime.udid) } }, - { - match: { - command: "xcrun", - args: simctlArgs("bootstatus", slimGarbageRuntime.udid, "-b"), - }, - }, - { match: simctl("listapps", slimGarbageRuntime.udid) }, - ]); - const onSlimSkipped = vi.fn(); - const driver = await createSlimDriver( - runner, - new MemoryFilesystem(), - slimOptions(), - undefined, - onSlimSkipped, - ); - - await driver.makeReady( - { - address: slimGarbageRuntime.udid, - deviceId: slimGarbageRuntime.udid, - driverData: slimGarbageRuntime, - }, - prepareSlim, - ); - - expect(onSlimSkipped).toHaveBeenCalledWith( - expect.objectContaining({ reason: "unknown-runtime" }), - ); - }); - it("slims iOS 26.0 (version comparison must not be string-lexicographic)", async () => { const filesystem = new MemoryFilesystem(); await plantManagedDevice(filesystem); @@ -4546,7 +4426,7 @@ describe("IosSimctlDriver toolVersions()", () => { // why: No scripted invocation: the runner refuses to start it, as a spawn failure would. const driver = await createDriver(new ScriptedProcessRunner([])); - await expect(driver.toolVersions()).rejects.toThrow(); + await expect(driver.toolVersions()).rejects.toThrow(/xcodebuild/); }); }); diff --git a/src/gateway/dispatcher.test.ts b/src/gateway/dispatcher.test.ts index d5195238b..e847ef9e5 100644 --- a/src/gateway/dispatcher.test.ts +++ b/src/gateway/dispatcher.test.ts @@ -1,4 +1,3 @@ -import { Worker } from "node:worker_threads"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createHmac } from "node:crypto"; @@ -19,7 +18,7 @@ import { usageRig, } from "../history/testing.js"; import { OPERATIONS, SimlockError, type OperationName } from "../contract/index.js"; -import { DispatchError, runDispatch, type DispatchSession } from "../daemon/dispatch.js"; +import { DispatchError, type DispatchSession } from "../daemon/dispatch.js"; import { FakeClock, FetchWebhookPoster, @@ -336,38 +335,6 @@ describe("GatewayDispatcher", () => { ).resolves.toEqual({ name: "ci-runner", principal: "tok_9f2c", role: "agent", via: "token" }); }); - it("The gateway dispatcher logs the same line for the same operation", async () => { - const { clock, dispatcher, logSink, workers } = harness(); - workers.connected("wrk_1", "mac-mini-1", "0.3.0"); - const otherSink = new MemoryLogSink(); - const other = (role: "admin" | "agent") => - runDispatch("worker.drain", { workerId: "wrk_1" }, session({ role }), { - handlers: { "worker.drain": () => ({ drained: true, workerId: "wrk_1" }) }, - observe: { - clock, - codeOf: (error) => (error instanceof DispatchError ? error.code : "INTERNAL"), - logger: new JsonLinesLogger({ clock, module: "other.dispatch", sink: otherSink }), - }, - }); - - await dispatcher.dispatch("worker.drain", { workerId: "wrk_1" }, session()); - await other("admin"); - await expect( - dispatcher.dispatch("worker.drain", { workerId: "wrk_1" }, session({ role: "agent" })), - ).rejects.toMatchObject({ code: "FORBIDDEN" }); - await expect(other("agent")).rejects.toMatchObject({ code: "FORBIDDEN" }); - - const gatewayLines = logSink.records.filter((record) => record.message === "operation"); - expect(gatewayLines.map((record) => record.module)).toEqual([ - "gateway.dispatch", - "gateway.dispatch", - ]); - expect(gatewayLines.map(({ module: _module, ...rest }) => rest)).toEqual( - otherSink.records.map(({ module: _module, ...rest }) => rest), - ); - expect(gatewayLines[1]?.fields).toMatchObject({ code: "FORBIDDEN" }); - }); - it("answers status.get from the fleet", async () => { const { dispatcher, workers } = harness(); workers.connected("wrk_1", "mac-mini-1", "0.3.0"); @@ -608,9 +575,7 @@ describe("GatewayDispatcher", () => { it("answers config.get with the gateway's own config", async () => { const { dispatcher } = harness(); - await expect(dispatcher.dispatch("config.get", {}, session())).resolves.toMatchObject({ - mode: "gateway", - }); + await expect(dispatcher.dispatch("config.get", {}, session())).resolves.toEqual(gatewayConfig); }); it("lists workers, and drains, undrains and removes one", async () => { @@ -799,11 +764,17 @@ describe("GatewayDispatcher", () => { ).rejects.toMatchObject({ code: "UNKNOWN_WORKER", details: { workerId: "wrk_ghost" } }); }); - it("keeps worker.* admin-only", async () => { - const { dispatcher } = harness(); + it.each([ + ["worker.list", {}], + ["worker.drain", { workerId: "wrk_1" }], + ["worker.undrain", { workerId: "wrk_1" }], + ["worker.remove", { workerId: "wrk_1" }], + ] as const)("keeps %s admin-only", async (operation, input) => { + const { dispatcher, workers } = harness(); + workers.connected("wrk_1", "mac-mini-1", "0.3.0"); await expect( - dispatcher.dispatch("worker.list", {}, session({ role: "agent" })), + dispatcher.dispatch(operation, input, session({ role: "agent" })), ).rejects.toMatchObject({ code: "FORBIDDEN" }); }); @@ -1283,17 +1254,13 @@ describe("GatewayDispatcher", () => { }, ); - it("mints and revokes its own tokens, worker join tokens included", async () => { + it("mints its own tokens, worker join tokens included", async () => { const { dispatcher, tokens } = harness(); await expect( dispatcher.dispatch("token.create", { role: "worker", label: "mac-mini-1" }, session()), ).resolves.toMatchObject({ secret: "slk_secret", token: { role: "worker" } }); expect(tokens.created).toEqual(["worker"]); - - await expect(dispatcher.dispatch("token.revoke", { id: "tok_1" }, session())).resolves.toEqual({ - revoked: true, - }); }); it("closes the uplink a revoked token authorized, not merely the store entry (C-2)", async () => { @@ -1612,47 +1579,52 @@ describe("GatewayDispatcher", () => { }, ); - it("component.install answers UNSUPPORTED_IN_GATEWAY_MODE, telling the operator to name workers (ADR 0010 §7)", async () => { - const { dispatcher } = harness(); - - await expect( - dispatcher.dispatch("component.install", { platform: "android", version: "35" }, session()), - ).rejects.toMatchObject({ - code: "UNSUPPORTED_IN_GATEWAY_MODE", - details: { operation: "component.install" }, - message: - "component.install installs on one machine; on a gateway, name the workers to install on " + + it.each([ + [ + "component.install", + { platform: "android", version: "35" }, + "admin", + "component.install installs on one machine; on a gateway, name the workers to install on " + "(simlock component install --worker or --all-workers)", - }); - }); - - it("component.list answers UNSUPPORTED_IN_GATEWAY_MODE to an agent session, saying it lists one machine's components (ADR 0010 §8)", async () => { - const { dispatcher } = harness(); - - await expect( - dispatcher.dispatch("component.list", {}, session({ role: "agent" })), - ).rejects.toMatchObject({ - code: "UNSUPPORTED_IN_GATEWAY_MODE", - details: { operation: "component.list" }, - message: "component.list lists one machine's components; run it against a worker", - }); - }); - - it("component.remove answers UNSUPPORTED_IN_GATEWAY_MODE to an admin session, saying it removes from one machine (ADR 0010 §8)", async () => { - const { dispatcher } = harness(); + ], + [ + "component.list", + {}, + "agent", + "component.list lists one machine's components; run it against a worker", + ], + [ + "component.remove", + { platform: "ios", version: "26.4" }, + "admin", + "component.remove removes a component from one machine; run it against a worker", + ], + [ + "device.add", + { id: "00008110-001A2B3C4D5E", platform: "ios" }, + "admin", + "device.add enrols a device plugged into one machine; run it on the worker the device is plugged into", + ], + [ + "device.remove", + { id: "00008110-001A2B3C4D5E" }, + "admin", + "device.remove unenrols a device plugged into one machine; run it on the worker the device is plugged into", + ], + ] as const)( + "%s answers UNSUPPORTED_IN_GATEWAY_MODE with a message pointing at the worker (ADR 0010 §7, §8)", + async (operation, input, role, message) => { + const { dispatcher } = harness(); - await expect( - dispatcher.dispatch( - "component.remove", - { platform: "ios", version: "26.4" }, - session({ role: "admin" }), - ), - ).rejects.toMatchObject({ - code: "UNSUPPORTED_IN_GATEWAY_MODE", - details: { operation: "component.remove" }, - message: "component.remove removes a component from one machine; run it against a worker", - }); - }); + await expect( + dispatcher.dispatch(operation, input, session({ role })), + ).rejects.toMatchObject({ + code: "UNSUPPORTED_IN_GATEWAY_MODE", + details: { operation }, + message, + }); + }, + ); }); describe("daemon.update", () => { @@ -1693,37 +1665,6 @@ describe("GatewayDispatcher", () => { }); }); - describe("device.add and device.remove", () => { - it("answer UNSUPPORTED_IN_GATEWAY_MODE to an admin session, saying to run the command on the worker the device is plugged into", async () => { - const { dispatcher } = harness(); - - await expect( - dispatcher.dispatch( - "device.add", - { id: "00008110-001A2B3C4D5E", platform: "ios" }, - session({ role: "admin" }), - ), - ).rejects.toMatchObject({ - code: "UNSUPPORTED_IN_GATEWAY_MODE", - details: { operation: "device.add" }, - message: - "device.add enrols a device plugged into one machine; run it on the worker the device is plugged into", - }); - await expect( - dispatcher.dispatch( - "device.remove", - { id: "00008110-001A2B3C4D5E" }, - session({ role: "admin" }), - ), - ).rejects.toMatchObject({ - code: "UNSUPPORTED_IN_GATEWAY_MODE", - details: { operation: "device.remove" }, - message: - "device.remove unenrols a device plugged into one machine; run it on the worker the device is plugged into", - }); - }); - }); - describe("lease lifecycle and device.exec (#118)", () => { it("forwards lease.request through the fleet coordinator and answers UNKNOWN_LEASE for renew/release/exec against an id it never issued", async () => { const { dispatcher } = harness(); @@ -1859,6 +1800,10 @@ describe("GatewayDispatcher", () => { ["neither a model nor a class", { platform: "ios" }], ["a class and an OS range", { class: "phone", osVersion: ">=18", platform: "ios" }], ["a model and an OS range", { model: "iPhone 17", osVersion: ">=18", platform: "ios" }], + [ + "a model and an exact osVersion", + { model: "iPhone 17", osVersion: "26.0", platform: "ios" }, + ], ] as const)( "serves a lease.request naming %s through a worker that fits, forwarding it as it arrived", async (_label, input) => { @@ -1893,30 +1838,6 @@ describe("GatewayDispatcher", () => { }, ); - it("forwards a lease.request's exact osVersion to the worker unchanged", async () => { - const { directory, dispatcher, workers } = harness(); - const client = new ScriptedWorkerClient(); - directory.add("wrk_1", client); - workers.connected("wrk_1", undefined, "0.3.0"); - workers.refresh("wrk_1", { - capacity: statusFixture().capacity, - health: "running", - queueDepth: 0, - catalog: catalogFixture([{ models: ["iPhone 17"], platform: "ios", runtimes: ["26.0"] }]) - .platforms, - downloads: { policy: "on-request" }, - }); - client.requestLeaseQueue.push({ grant: grantFixture(), kind: "grant" }); - - await dispatcher.dispatch( - "lease.request", - { model: "iPhone 17", noWait: true, osVersion: "26.0", platform: "ios" }, - session({ role: "agent" }), - ); - - expect(client.lastRequestLeaseInput).toMatchObject({ osVersion: "26.0" }); - }); - it("forwards a lease.request's image tag to the worker unchanged", async () => { const { directory, dispatcher, workers } = harness(); const client = new ScriptedWorkerClient(); @@ -2015,7 +1936,7 @@ describe("GatewayDispatcher", () => { ).rejects.toMatchObject({ code: "FORBIDDEN" }); }); - it("calls onStarted for a silent device.exec from the worker's own started push, with no output and no answer (ADR §19a/§19b/§19e)", async () => { + it("calls onStarted for a silent device.exec from the worker's own started push (ADR §19a/§19b/§19e)", async () => { const { coordinator, directory, dispatcher, workers } = harness(); const client = new ScriptedWorkerClient(); directory.add("wrk_1", client); @@ -2051,9 +1972,7 @@ describe("GatewayDispatcher", () => { role: "agent", }), ); - await Promise.resolve(); - - expect(started).toBe(true); + await expect.poll(() => started).toBe(true); }); it.each([ @@ -2208,7 +2127,7 @@ describe("GatewayDispatcher: waiting requests", () => { dispatcher.dispatch("list.get", { kind: "requests" }, session()); it("list.get requests and status.get waiting on a gateway return the fleet queue with each worker's reason in workers, and each worker's waiting requests with their workerId and reason, a workers field a worker sends on its own entry dropped", async () => { - const { coordinator, dispatcher, workers } = harness(); + const { dispatcher, workers } = harness(); const capacity = statusFixture().capacity; for (const [workerId, requestId] of [ ["wrk_1", "req_w1"], @@ -2238,7 +2157,6 @@ describe("GatewayDispatcher: waiting requests", () => { session({ principal: "fleet-owner", role: "agent" }), ); await settle(); - expect(coordinator.queueDepth).toBe(1); const listed = await listRequests(dispatcher); @@ -2444,17 +2362,14 @@ describe("GatewayDispatcher: usage.get", () => { totals: { requests: 1 }, }); gate.open = false; - const posted = vi.spyOn(Worker.prototype, "postMessage"); await expect(dispatcher.dispatch("usage.get", window, session())).rejects.toMatchObject({ code: "HISTORY_UNAVAILABLE", message: "The history could not be opened, so no figures can be read; see daemon.log.", }); - expect(posted).not.toHaveBeenCalled(); - posted.mockRestore(); }); - it("the gateway handler strips its own gw: prefix before the label lookup and leaves another prefix alone, and labels nothing when it has no token store", async () => { + it("the gateway handler strips its own gw: prefix before the label lookup and leaves another prefix alone, labels nothing when it has no token store, and with includeLedger lists the request rows as entries labelled the same way", async () => { const { eventHistory, rig } = await fleetHistory(); for (const [index, requester] of [ `${GATEWAY_REQUESTER_PREFIX}tok_a`, @@ -2476,6 +2391,11 @@ describe("GatewayDispatcher: usage.get", () => { const answer = await labelled.dispatcher.dispatch("usage.get", window, session()); const bare = await unlabelled.dispatcher.dispatch("usage.get", window, session()); + const ledger = await labelled.dispatcher.dispatch( + "usage.get", + { ...window, includeLedger: true }, + session(), + ); expect( Object.fromEntries(answer.requesters.map((requester) => [requester.id, requester.label])), @@ -2486,34 +2406,10 @@ describe("GatewayDispatcher: usage.get", () => { }); expect(bare.requesters).toHaveLength(3); expect(bare.requesters.every((requester) => !("label" in requester))).toBe(true); - }); - - it("the gateway handler with includeLedger lists the fleet's request rows as entries, labelled after stripping its own gw: prefix", async () => { - const { eventHistory, rig } = await fleetHistory(); - for (const [index, requester] of [ - `${GATEWAY_REQUESTER_PREFIX}tok_a`, - "gw:other-instance:tok_a", - "tok_a", - ].entries()) { - rig.emit("lease.requested", requested(`R${index}`, { requester })); - } - const { dispatcher } = harness({ - eventHistory, - historyPath: rig.historyPath, - tokenRecords: [{ id: "tok_a", label: "ci-bot" }], - }); - - const answer = await dispatcher.dispatch( - "usage.get", - { ...window, includeLedger: true }, - session(), - ); - const plain = await dispatcher.dispatch("usage.get", window, session()); - expect( - Object.fromEntries((answer.requests ?? []).map((entry) => [entry.requestId, entry.label])), + Object.fromEntries((ledger.requests ?? []).map((entry) => [entry.requestId, entry.label])), ).toEqual({ R0: "ci-bot", R1: undefined, R2: "ci-bot" }); - expect(plain).not.toHaveProperty("requests"); + expect(answer).not.toHaveProperty("requests"); }); it("the gateway handler lists every registry worker with its label and a worker id the rows name that the registry lacks, and counts a fleet request with no worker in totals and its platform only", async () => { diff --git a/src/http/app.test.ts b/src/http/app.test.ts index 71a936005..c9758cfe9 100644 --- a/src/http/app.test.ts +++ b/src/http/app.test.ts @@ -259,29 +259,6 @@ describe("GET /v1/identity", () => { }); describe("authentication and role/ownership (via the shared dispatcher)", () => { - it("401s a protected route with no token", async () => { - const { app } = buildHarness(); - const response = await app.request("/v1/status"); - expect(response.status).toBe(401); - }); - - it("403s an agent token whose dispatched operation is admin-only -- ADR: role gating moved to the shared dispatcher, not an HTTP-only minRole check", async () => { - const { app, dispatcher } = buildHarness(); - dispatcher.handlers["list.get"] = () => { - throw new DispatchError("FORBIDDEN", "Operation list.get requires role admin"); - }; - const response = await app.request("/v1/devices", { headers: agentAuth }); - expect(response.status).toBe(403); - expect(((await response.json()) as { error: { code: string } }).error.code).toBe("FORBIDDEN"); - }); - - it("admits an operator token on the same admin-only route", async () => { - const { app, dispatcher } = buildHarness(); - dispatcher.handlers["list.get"] = () => []; - const response = await app.request("/v1/devices", { headers: operatorAuth }); - expect(response.status).toBe(200); - }); - it("403s an agent reaching another requester's lease request (HTTP-only tracker resource guard)", async () => { const { app, dispatcher } = buildHarness(); const { id } = await createLeaseRequest(app, dispatcher); @@ -375,21 +352,12 @@ describe("GET /v1/status, /v1/catalog", () => { ], }); }); - - it("400s an invalid platform query, before ever dispatching", async () => { - const { app, dispatcher } = buildHarness(); - dispatcher.handlers["catalog.get"] = () => { - throw new Error("should not be called"); - }; - const response = await app.request("/v1/catalog?platform=windows", { headers: agentAuth }); - expect(response.status).toBe(400); - expect(((await response.json()) as { error: { code: string } }).error.code).toBe("BAD_REQUEST"); - }); }); describe("a query to a System route", () => { it.each([ ["/v1/catalog?platform=windows", 400, "platform must be ios or android", undefined], + ["/v1/components?platform=windows", 400, "platform must be ios or android", undefined], ["/v1/catalog?platform=ios&platform=android", 200, undefined, { platform: "ios" }], ["/v1/stats?ledger=2", 400, "ledger must be 1", undefined], [ @@ -409,6 +377,10 @@ describe("a query to a System route", () => { seen.push(input); return {}; }; + dispatcher.handlers["component.list"] = (input) => { + seen.push(input); + return { components: [] }; + }; const response = await app.request(url, { headers: operatorAuth }); @@ -462,18 +434,6 @@ describe("GET /v1/components", () => { expect(response.status).toBe(200); }); - - it("400s an invalid platform query, before ever dispatching", async () => { - const { app, dispatcher } = buildHarness(); - dispatcher.handlers["component.list"] = () => { - throw new Error("should not be called"); - }; - - const response = await app.request("/v1/components?platform=windows", { headers: agentAuth }); - - expect(response.status).toBe(400); - expect(((await response.json()) as { error: { code: string } }).error.code).toBe("BAD_REQUEST"); - }); }); describe("POST /v1/devices and DELETE /v1/devices/{id}", () => { @@ -727,35 +687,37 @@ describe("POST /v1/lease-requests", () => { expect(dispatcher.calls).toHaveLength(0); }); - it("dispatches a body naming a device as the model, with no class", async () => { - const { app, dispatcher } = buildHarness(); - const responsePromise = postLeaseRequest(app, defaultBody); - const call = await waitForDispatch(dispatcher, "lease.request"); - call.session.onProgress?.({ queuePosition: 1, stage: "queued" }); - await responsePromise; - - expect(call.input).toMatchObject({ model: "iPhone 17 Pro", platform: "ios" }); - expect(call.input).not.toHaveProperty("class"); - }); - it.each([ - ["a class", { class: "tablet", platform: "ios" }, { class: "tablet", platform: "ios" }], - ["only a platform", { platform: "android" }, { platform: "android" }], - ])("dispatches a body naming %s with no model", async (_label, body, expected) => { - const { app, dispatcher } = buildHarness(); - const responsePromise = postLeaseRequest(app, body); - const call = await waitForDispatch(dispatcher, "lease.request"); - call.session.onProgress?.({ queuePosition: 1, stage: "queued" }); - await responsePromise; + ["a device as the model", defaultBody, { model: "iPhone 17 Pro", platform: "ios" }, "class"], + [ + "a class", + { class: "tablet", platform: "ios" }, + { class: "tablet", platform: "ios" }, + "model", + ], + ["only a platform", { platform: "android" }, { platform: "android" }, "model"], + ])( + "dispatches a body naming %s with only the keys it names", + async (_label, body, expected, absentKey) => { + const { app, dispatcher } = buildHarness(); + const responsePromise = postLeaseRequest(app, body); + const call = await waitForDispatch(dispatcher, "lease.request"); + call.session.onProgress?.({ queuePosition: 1, stage: "queued" }); + await responsePromise; - expect(call.input).toMatchObject(expected); - expect(call.input).not.toHaveProperty("model"); - }); + expect(call.input).toMatchObject(expected); + expect(call.input).not.toHaveProperty(absentKey); + }, + ); it.each([ ["full", { full: true }], ["a mode other than slim or full", { mode: "fast" }], ["a key the route does not know", { colour: "blue" }], + [ + "a fleetRequestId, a contract field the route does not take", + { fleetRequestId: "req_1", noWait: true }, + ], ["an image tag with a space", { imageTag: "google apis" }], ["an image tag over 64 characters", { imageTag: "a".repeat(65) }], ])("400s a body carrying %s as BAD_REQUEST before ever dispatching", async (_label, extra) => { @@ -997,21 +959,6 @@ describe("POST /v1/lease-requests leaseId", () => { expect(dispatcher.calls).toEqual([]); }); - it("answers 400 BAD_REQUEST for a body carrying fleetRequestId, as for any unknown field, without dispatching", async () => { - const { app, dispatcher } = buildHarness(); - dispatcher.handlers["lease.request"] = () => makeGrant({ lease: { id: "lse_1" } }); - - const response = await postLeaseRequest(app, { - ...defaultBody, - fleetRequestId: "req_1", - noWait: true, - }); - - expect(response.status).toBe(400); - expect(((await response.json()) as { error: { code: string } }).error.code).toBe("BAD_REQUEST"); - expect(dispatcher.calls).toEqual([]); - }); - it("answers 409 LEASE_ID_TAKEN with the ID in the body when the dispatcher refuses it", async () => { const { app, dispatcher } = buildHarness(); dispatcher.handlers["lease.request"] = () => { @@ -1076,8 +1023,16 @@ describe("full lease-request lifecycle via GET / long-poll / SSE", () => { const { id } = await createLeaseRequest(app, dispatcher); const waitPromise = app.request(`/v1/lease-requests/${id}?wait=5`, { headers: agentAuth }); + let answered = false; + void waitPromise.then(() => { + answered = true; + }); await settle(); - clock.advance(5_000); + expect(answered).toBe(false); + clock.advance(4_999); + for (let turn = 0; turn < 50; turn += 1) await settle(); + expect(answered).toBe(false); + clock.advance(1); const response = await waitPromise; const body = (await response.json()) as { request: { state: string; queuePosition: number } }; expect(body.request).toMatchObject({ queuePosition: 1, state: "queued" }); @@ -1311,6 +1266,9 @@ describe("DELETE /v1/lease-requests/:id", () => { method: "DELETE", }); expect(response.status).toBe(404); + expect(((await response.json()) as { error: { code: string } }).error.code).toBe( + "UNKNOWN_LEASE_REQUEST", + ); }); it("204s when the request was still queued and cancellable, and a GET right after reads cancelled", async () => { @@ -1368,6 +1326,7 @@ describe("DELETE /v1/lease-requests/:id", () => { message: expect.any(String), }, }); + expect(dispatcher.calls.map((call) => call.operation)).not.toContain("lease.cancel"); }); }); @@ -1526,12 +1485,6 @@ describe("lease routes", () => { }).toMatchObject({ get: named, poll: named, post: named }); }); - it("404s an unknown lease id", async () => { - const { app } = buildHarness(); - const response = await app.request("/v1/leases/lse-missing", { headers: agentAuth }); - expect(response.status).toBe(404); - }); - it("POST /v1/leases/:id/renew drains buffered device-health notices", async () => { const { app, dispatcher, eventBus, registry } = buildHarness(); registry.devices = [makeDevice({ id: "dev_1" })]; @@ -1570,29 +1523,6 @@ describe("lease routes", () => { ]); }); - it("POST /v1/leases/:id/renew accepts an empty body", async () => { - const { app, dispatcher, registry } = buildHarness(); - registry.devices = [makeDevice({ id: "dev_1" })]; - registry.leases = [makeLease({ deviceId: "dev_1", id: "lse_1", ownerId: "tok_agent" })]; - dispatcher.handlers["lease.renew"] = (input) => { - expect(input).toEqual({ leaseId: "lse_1" }); - return { - deviceId: "dev_1", - grantedAt: 1_000, - id: "lse_1", - ownerId: "tok_agent", - requesterId: "tok_agent", - ttlDeadline: 2_000, - }; - }; - - const response = await app.request("/v1/leases/lse_1/renew", { - headers: agentAuth, - method: "POST", - }); - expect(response.status).toBe(200); - }); - it.each([ ["renew", "/v1/leases/lse_1/renew", '{"ttlMs":60000}', "lease.renew"], ["exec", "/v1/leases/lse_1/exec", '{"args":["list"],"tool":"simctl"}', "device.exec"], @@ -2046,6 +1976,7 @@ describe("lease routes", () => { await reader?.cancel(); await Promise.resolve(); + expect(call.session.onOutput).toBeDefined(); const chunk = "x".repeat(1_024); for (let index = 0; index < 1_000; index += 1) call.session.onOutput?.("stdout", chunk); @@ -2056,7 +1987,8 @@ describe("lease routes", () => { expect(call.session.onOutput?.("stdout", chunk)).toBeUndefined(); call.resolve({ exitCode: 0 }); - await expect(responsePromise).resolves.toBeDefined(); + await settle(); + await expect(reader?.read()).resolves.toMatchObject({ done: true }); }); }); @@ -2367,45 +2299,28 @@ describe("operator-only listing routes", () => { expect(body.devices.map((device) => device.id).sort()).toEqual(["dev_1", "dev_2"]); }); - it("GET /v1/events replays via events.replay, translating ?since to an absolute sinceTs", async () => { + it("GET /v1/events replays via events.replay, translating ?since in seconds or days to an absolute sinceTs", async () => { const { app, clock, dispatcher } = buildHarness(); + const seen: Array = []; dispatcher.handlers["events.replay"] = (input) => { const sinceTs = (input as { sinceTs?: number }).sinceTs; + seen.push(sinceTs); if (sinceTs === undefined) return [{ event: "daemon.started" }, { event: "daemon.stopping" }]; - expect(sinceTs).toBe(clock.now() - 5_000); return [{ event: "daemon.stopping" }]; }; const all = await app.request("/v1/events", { headers: operatorAuth }); expect(((await all.json()) as { events: unknown[] }).events).toHaveLength(2); - const recent = await app.request("/v1/events?since=5s", { headers: operatorAuth }); - const recentBody = (await recent.json()) as { events: Array<{ event: string }> }; - expect(recentBody.events).toEqual([{ event: "daemon.stopping" }]); - }); - - it("GET /v1/events accepts the d unit in ?since", async () => { - const { app, clock, dispatcher } = buildHarness(); - let seen: number | undefined; - dispatcher.handlers["events.replay"] = (input) => { - seen = (input as { sinceTs?: number }).sinceTs; - return []; - }; - - const response = await app.request("/v1/events?since=2d", { headers: operatorAuth }); - - expect(response.status).toBe(200); - expect(seen).toBe(clock.now() - 2 * 24 * 60 * 60 * 1000); - }); - - it("400s an invalid ?since duration, before dispatching", async () => { - const { app, dispatcher } = buildHarness(); - dispatcher.handlers["events.replay"] = () => { - throw new Error("should not be called"); - }; - const response = await app.request("/v1/events?since=nonsense", { headers: operatorAuth }); - expect(response.status).toBe(400); - expect(JSON.stringify(await response.json())).toContain("Invalid duration: nonsense"); + for (const [since, windowMs] of [ + ["5s", 5_000], + ["2d", 2 * 24 * 60 * 60 * 1000], + ] as const) { + const recent = await app.request(`/v1/events?since=${since}`, { headers: operatorAuth }); + const recentBody = (await recent.json()) as { events: Array<{ event: string }> }; + expect(recentBody.events).toEqual([{ event: "daemon.stopping" }]); + expect(seen.at(-1)).toBe(clock.now() - windowMs); + } }); it("GET /v1/events with before and limit dispatches events.replay with beforeId and limit, and answers HISTORY_NOT_KEPT with 422 and oldestTs in the body and HISTORY_UNAVAILABLE with 503", async () => { @@ -2557,13 +2472,23 @@ describe("operator-only listing routes", () => { }); describe("hardening from review", () => { - it("400s an oversized Idempotency-Key", async () => { - const { app } = buildHarness(); + it("400s an oversized Idempotency-Key as BAD_REQUEST, where a 200-character key is accepted", async () => { + const { app, dispatcher } = buildHarness(); + const accepted = await Promise.race([ + postLeaseRequest(app, defaultBody, { ...agentAuth, "Idempotency-Key": "x".repeat(200) }), + waitForDispatch(dispatcher, "lease.request").then(() => "dispatched" as const), + ]); + if (accepted instanceof Response) expect(accepted.status).toBe(201); + else expect(accepted).toBe("dispatched"); + const response = await postLeaseRequest(app, defaultBody, { ...agentAuth, "Idempotency-Key": "x".repeat(201), }); expect(response.status).toBe(400); + const { error } = (await response.json()) as { error: { code: string; message: string } }; + expect(error.code).toBe("BAD_REQUEST"); + expect(error.message).toContain("idempotencyKey"); }); it("clamps an oversized ?wait to the 60s ceiling", async () => { @@ -2573,8 +2498,15 @@ describe("hardening from review", () => { const waitPromise = app.request(`/v1/lease-requests/${id}?wait=999999`, { headers: agentAuth, }); + let answered = false; + void waitPromise.then(() => { + answered = true; + }); await settle(); - clock.advance(60_000); + clock.advance(59_999); + for (let turn = 0; turn < 50; turn += 1) await settle(); + expect(answered).toBe(false); + clock.advance(1); const response = await waitPromise; expect(((await response.json()) as { request: { state: string } }).request.state).toBe( "queued", @@ -3323,7 +3255,7 @@ describe("the stream mount's listed route", () => { }); describe("ETag on a JSON read under /v1", () => { - it("answers an unchanged JSON read with 304 and no body, and anything else with 200", async () => { + it("304 and no body for an unchanged read, 200 with a new ETag otherwise, no ETag on a 404 or a mount", async () => { const tokens: { id: string }[] = [{ id: "tok_1" }]; const { app, dispatcher, eventBus } = buildHarness({ mounts: [ diff --git a/src/leasing/create-leasing.test.ts b/src/leasing/create-leasing.test.ts index 33f39208e..2e262bad7 100644 --- a/src/leasing/create-leasing.test.ts +++ b/src/leasing/create-leasing.test.ts @@ -478,7 +478,7 @@ describe("createLeasing", () => { }); }); - it("evicts the other platform's LRU warm device when only the global limit blocks demand", async () => { + it("evicts the other platform's warm device when only the global limit blocks demand", async () => { const clock = new FakeClock(1_000); const ios = new FakeDriver({ availableOsVersions: ["26.5"], clock, platform: "ios" }); const android = new FakeDriver({ availableOsVersions: ["36"], clock, platform: "android" }); @@ -1287,7 +1287,11 @@ describe("createLeasing", () => { }); await flush(); - harness.clock.advance(10); + harness.clock.advance(9); + await flush(); + expect(await settledOrPending(queued)).toBe("still pending"); + + harness.clock.advance(1); await flush(); await expect(queued).resolves.toMatchObject({ lease: { ownerId: "agent-2", requesterId: "agent-2" }, @@ -2143,29 +2147,31 @@ describe("createLeasing fresh lease identity (#75)", () => { }; } - it("deletes a fresh device on the next start when the daemon crashed while it was reclaiming", async () => { - const restarted = await restartAfterCrash("reclaiming"); - expect(restarted.registry.snapshot.devices).toMatchObject([{ state: "reclaiming" }]); - - await restarted.engine.convergeRunningCapacity(); - - expect(restarted.registry.snapshot.devices).toMatchObject([ - { id: restarted.device.id, state: "deleted" }, - ]); - expect(restarted.operationsSinceStart()).toEqual(["listManaged", "shutdown", "destroy"]); - }); - - it("deletes a fresh device on the next start when the daemon crashed after its shutdown commit and before its delete", async () => { - const restarted = await restartAfterCrash("shutdown"); - expect(restarted.registry.snapshot.devices).toMatchObject([{ state: "shutdown" }]); + it.each([ + { + crashedIn: "reclaiming" as const, + operations: ["listManaged", "shutdown", "destroy"], + title: "while it was reclaiming", + }, + { + crashedIn: "shutdown" as const, + operations: ["listManaged", "destroy"], + title: "after its shutdown commit and before its delete", + }, + ])( + "deletes a fresh device on the next start when the daemon crashed $title", + async ({ crashedIn, operations }) => { + const restarted = await restartAfterCrash(crashedIn); + expect(restarted.registry.snapshot.devices).toMatchObject([{ state: crashedIn }]); - await restarted.engine.convergeRunningCapacity(); + await restarted.engine.convergeRunningCapacity(); - expect(restarted.registry.snapshot.devices).toMatchObject([ - { id: restarted.device.id, state: "deleted" }, - ]); - expect(restarted.operationsSinceStart()).toEqual(["listManaged", "destroy"]); - }); + expect(restarted.registry.snapshot.devices).toMatchObject([ + { id: restarted.device.id, state: "deleted" }, + ]); + expect(restarted.operationsSinceStart()).toEqual(operations); + }, + ); }); describe("createLeasing logger wiring", () => { @@ -2299,44 +2305,41 @@ describe("createLeasing restart recovery of stored lease requests", () => { return after; } - it("settles a request still open at a restart as failed and says the daemon restarted", async () => { - const after = await restartWithAgentQueued("key-1"); + it.each([{ idempotencyKey: "key-1" as string | undefined }, { idempotencyKey: undefined }])( + "settles a request still open at a restart as failed and says the daemon restarted, whether it carried an idempotency key or not ($idempotencyKey)", + async ({ idempotencyKey }) => { + const after = await restartWithAgentQueued(idempotencyKey); - expect( - after.registry.leaseRequests().find((record) => record.requesterId === "agent"), - ).toMatchObject({ - failure: { code: "INTERNAL", message: expect.stringContaining("daemon restarted") }, - state: "failed", - }); - expect( - after.bus - .replay() - .filter((event) => event.event === "lease.rejected") - .map((event) => event.payload), - ).toEqual([ - { - reason: "daemon-restarted", - requestId: expect.stringMatching(/^req_/), - requester: "agent", - requestSpec: request, - }, - ]); - await expect( - after.engine.request(request, { - idempotencyKey: "key-1", - ownerId: "agent", - requesterId: "agent", - }), - ).rejects.toMatchObject({ message: expect.stringContaining("daemon restarted") }); - }); - - it("settles a request that carried no idempotency key at a restart too", async () => { - const after = await restartWithAgentQueued(); - - expect( - after.registry.leaseRequests().find((record) => record.requesterId === "agent"), - ).toMatchObject({ state: "failed" }); - }); + expect( + after.registry.leaseRequests().find((record) => record.requesterId === "agent"), + ).toMatchObject({ + failure: { code: "INTERNAL", message: expect.stringContaining("daemon restarted") }, + state: "failed", + }); + expect( + after.bus + .replay() + .filter((event) => event.event === "lease.rejected") + .map((event) => event.payload), + ).toEqual([ + { + reason: "daemon-restarted", + requestId: expect.stringMatching(/^req_/), + requester: "agent", + requestSpec: request, + }, + ]); + if (idempotencyKey !== undefined) { + await expect( + after.engine.request(request, { + idempotencyKey, + ownerId: "agent", + requesterId: "agent", + }), + ).rejects.toMatchObject({ message: expect.stringContaining("daemon restarted") }); + } + }, + ); }); describe("createLeasing a grant and its request's result", () => { @@ -2359,15 +2362,12 @@ describe("createLeasing a grant and its request's result", () => { expect(after.registry.snapshot.leases).toHaveLength(1); }); - it("answers a repeat under the same key in the same process with the lease after the request book's settle finds the request already granted", async () => { + it("answers a repeat under the same key in the same process with the lease", async () => { const harness = await createHarness(); await seedReady(harness); - const settle = vi.spyOn(harness.registry, "settleLeaseRequest"); const grant = await harness.engine.request(request, asker); await flush(); - expect(settle).toHaveBeenCalledTimes(1); - await expect(settle.mock.results[0]?.value).resolves.toBeUndefined(); const repeat = await harness.engine.request(request, asker); expect(repeat).toEqual(grant); expect(repeat.lease.id).toBe(grant.lease.id); @@ -3702,8 +3702,19 @@ describe("createLeasing warm targets", () => { const device = await seedReady(harness); await harness.engine.convergeRunningCapacity(); - harness.clock.advance(30_000); - await harness.engine.settle(); + // why: a pass runs on a trigger, and the periodic tick is 30 s away: a daemon.started fact is one + // at the threshold and one millisecond past it. + harness.clock.advance(10_000); + harness.bus.emit("daemon.started", { configSnapshot: {}, version: "test" }, "test"); + await drain(harness); + expect(harness.registry.snapshot.devices.find((item) => item.id === device.id)?.state).toBe( + "ready", + ); + expect(eventsNamed(harness, "device.shutdown")).toEqual([]); + + harness.clock.advance(1); + harness.bus.emit("daemon.started", { configSnapshot: {}, version: "test" }, "test"); + await drain(harness); expect(harness.registry.snapshot.devices.find((item) => item.id === device.id)?.state).toBe( "shutdown", @@ -4565,145 +4576,170 @@ describe("createLeasing: a gateway dispatch is a probe (ADR 0021)", () => { ?.id; } - it("emits lease.declined with reason no-wait and its fleetRequestId, and no lease.rejected, for a probe refused for no capacity", async () => { - const harness = await createHarness(); - await harness.engine.request(request, holder); - - await expect(harness.engine.request(request, probeOf("gw:one"))).rejects.toBeInstanceOf( - NoCapacityError, - ); + interface Declined { + /** The harness whose bus saw the decline. */ + readonly harness: Harness; + /** The one lease.declined payload the case must emit. */ + readonly payload: unknown; + /** Case-specific assertions beyond the payload. */ + readonly check?: () => void; + } - expect(payloads(harness, "lease.declined")).toEqual([ - { - fleetRequestId: "req_gw1", - reason: "no-wait", - requestId: await admittedId(harness, "gw:one"), - requestSpec: request, - requester: "gw:one", + const declinedCases: ReadonlyArray Promise]> = [ + [ + "no-wait", + async () => { + const harness = await createHarness(); + await harness.engine.request(request, holder); + await expect(harness.engine.request(request, probeOf("gw:one"))).rejects.toBeInstanceOf( + NoCapacityError, + ); + return { + harness, + payload: { + fleetRequestId: "req_gw1", + reason: "no-wait", + requestId: await admittedId(harness, "gw:one"), + requestSpec: request, + requester: "gw:one", + }, + }; }, - ]); - expect(payloads(harness, "lease.rejected")).toEqual([]); - }); - - it("emits lease.declined with reason unresolvable-spec for a probe naming a model the catalog lacks", async () => { - const harness = await createHarness(); - const odd = { ...request, model: "iPhone 99 Imaginary", osVersion: ">=18" }; - - await settledOrPending(harness.engine.request(odd, probeOf("gw:odd"))); - - expect(payloads(harness, "lease.declined")).toEqual([ - expect.objectContaining({ - fleetRequestId: "req_gw1", - reason: "unresolvable-spec", - requestSpec: odd, - }), - ]); - expect(payloads(harness, "lease.rejected")).toEqual([]); - }); - - it("emits lease.declined for a probe refused already-leased at admission, carrying its requestSpec", async () => { - const driver = new FakeDriver({ - availableOsVersions: ["26.5"], - clock: new FakeClock(1_000), - platform: "ios", - }); - driver.hangMakeReady(); - const harness = await createHarness({ driver }); - void harness.engine.request(request, probeOf("gw:one")).catch(() => undefined); - await expect - .poll(() => - harness.registry.leaseRequests().find((record) => record.requesterId === "gw:one"), - ) - .toMatchObject({ state: "open" }); - - await expect(harness.engine.request(request, probeOf("gw:one"))).rejects.toMatchObject({ - name: "RequesterAlreadyLeasedError", - }); - - expect(payloads(harness, "lease.declined")).toEqual([ - { - fleetRequestId: "req_gw1", - reason: "already-leased", - requestId: expect.stringMatching(/^req_/), - requestSpec: request, - requester: "gw:one", + ], + [ + "unresolvable-spec", + async () => { + const harness = await createHarness(); + const odd = { ...request, model: "iPhone 99 Imaginary", osVersion: ">=18" }; + await settledOrPending(harness.engine.request(odd, probeOf("gw:odd"))); + return { + harness, + payload: expect.objectContaining({ + fleetRequestId: "req_gw1", + reason: "unresolvable-spec", + requestSpec: odd, + }), + }; }, - ]); - expect(payloads(harness, "lease.rejected")).toEqual([]); - }); - - it("emits lease.declined for a probe refused lease-id-taken", async () => { - const harness = await createHarness({ - limits: { - android: { maxDevices: 2, maxRunning: 2 }, - ios: { maxDevices: 3, maxRunning: 3 }, - maxRunning: 4, + ], + [ + "already-leased", + async () => { + const driver = new FakeDriver({ + availableOsVersions: ["26.5"], + clock: new FakeClock(1_000), + platform: "ios", + }); + driver.hangMakeReady(); + const harness = await createHarness({ driver }); + void harness.engine.request(request, probeOf("gw:one")).catch(() => undefined); + await expect + .poll(() => + harness.registry.leaseRequests().find((record) => record.requesterId === "gw:one"), + ) + .toMatchObject({ state: "open" }); + await expect(harness.engine.request(request, probeOf("gw:one"))).rejects.toMatchObject({ + name: "RequesterAlreadyLeasedError", + }); + return { + harness, + payload: { + fleetRequestId: "req_gw1", + reason: "already-leased", + requestId: expect.stringMatching(/^req_/), + requestSpec: request, + requester: "gw:one", + }, + }; }, - }); - await harness.engine.request(request, { ...holder, leaseId: "myid" }); - - await settledOrPending(harness.engine.request(request, probeOf("gw:two", { leaseId: "myid" }))); - - expect(payloads(harness, "lease.declined")).toEqual([ - expect.objectContaining({ fleetRequestId: "req_gw1", reason: "lease-id-taken" }), - ]); - expect(payloads(harness, "lease.rejected")).toEqual([]); - }); - - it("emits lease.declined for a probe whose boot times out after a progress push, with reason boot-timeout", async () => { - const driver = new FakeDriver({ - availableOsVersions: ["26.5"], - clock: new FakeClock(1_000), - platform: "ios", - }); - driver.failOn("makeReady", 1, new Error("boot failed")); - const harness = await createHarness({ driver }); - const stages: string[] = []; - - await settledOrPending( - harness.engine.request(request, { - ...probeOf("gw:slow"), - onProgress: (progress) => stages.push(progress.stage), - }), - ); - - expect(stages.length).toBeGreaterThan(0); - expect(payloads(harness, "lease.declined")).toEqual([ - expect.objectContaining({ fleetRequestId: "req_gw1", reason: "boot-timeout" }), - ]); - expect(payloads(harness, "lease.rejected")).toEqual([]); - }); - - it("emits lease.declined daemon-restarted with its fleetRequestId at the next start, for a probe still open when the daemon stopped, after the registry is reloaded from disk", async () => { - const driver = new FakeDriver({ - availableOsVersions: ["26.5"], - clock: new FakeClock(1_000), - platform: "ios", - }); - driver.hangMakeReady(); - const before = await createHarness({ driver }); - void before.engine.request(request, probeOf("gw:open")).catch(() => undefined); - await expect - .poll(() => - before.registry.leaseRequests().find((record) => record.requesterId === "gw:open"), - ) - .toMatchObject({ fleetRequestId: "req_gw1", state: "open" }); - const requestId = await admittedId(before, "gw:open"); + ], + [ + "lease-id-taken", + async () => { + const harness = await createHarness({ + limits: { + android: { maxDevices: 2, maxRunning: 2 }, + ios: { maxDevices: 3, maxRunning: 3 }, + maxRunning: 4, + }, + }); + await harness.engine.request(request, { ...holder, leaseId: "myid" }); + await settledOrPending( + harness.engine.request(request, probeOf("gw:two", { leaseId: "myid" })), + ); + return { + harness, + payload: expect.objectContaining({ fleetRequestId: "req_gw1", reason: "lease-id-taken" }), + }; + }, + ], + [ + "boot-timeout", + async () => { + const driver = new FakeDriver({ + availableOsVersions: ["26.5"], + clock: new FakeClock(1_000), + platform: "ios", + }); + driver.failOn("makeReady", 1, new Error("boot failed")); + const harness = await createHarness({ driver }); + const stages: string[] = []; + await settledOrPending( + harness.engine.request(request, { + ...probeOf("gw:slow"), + onProgress: (progress) => stages.push(progress.stage), + }), + ); + return { + check: () => expect(stages.length).toBeGreaterThan(0), + harness, + payload: expect.objectContaining({ fleetRequestId: "req_gw1", reason: "boot-timeout" }), + }; + }, + ], + [ + "daemon-restarted", + async () => { + const driver = new FakeDriver({ + availableOsVersions: ["26.5"], + clock: new FakeClock(1_000), + platform: "ios", + }); + driver.hangMakeReady(); + const before = await createHarness({ driver }); + void before.engine.request(request, probeOf("gw:open")).catch(() => undefined); + await expect + .poll(() => + before.registry.leaseRequests().find((record) => record.requesterId === "gw:open"), + ) + .toMatchObject({ fleetRequestId: "req_gw1", state: "open" }); + const requestId = await admittedId(before, "gw:open"); + const after = await createHarness({ state: before.state }); + await after.engine.convergeRunningCapacity(); + return { + harness: after, + payload: { + fleetRequestId: "req_gw1", + reason: "daemon-restarted", + requestId, + requestSpec: request, + requester: "gw:open", + }, + }; + }, + ], + ]; - const after = await createHarness({ state: before.state }); - await after.engine.convergeRunningCapacity(); + it.each(declinedCases)( + "emits one lease.declined with its fleetRequestId and reason %s, and no lease.rejected, for a probe refused that way", + async (_reason, run) => { + const { check, harness, payload } = await run(); - expect(payloads(after, "lease.declined")).toEqual([ - { - fleetRequestId: "req_gw1", - reason: "daemon-restarted", - requestId, - requestSpec: request, - requester: "gw:open", - }, - ]); - expect(payloads(after, "lease.rejected")).toEqual([]); - }); + expect(payloads(harness, "lease.declined")).toEqual([payload]); + expect(payloads(harness, "lease.rejected")).toEqual([]); + check?.(); + }, + ); it("still emits lease.rejected no-wait, and no lease.declined, for a local noWait request with no room", async () => { const harness = await createHarness(); @@ -4989,6 +5025,7 @@ describe("createLeasing: a waiting request says why it is not placed (#636)", () { reason: undefined, requesterId: "failing", stage: "starting" }, { reason: "device-limit", requesterId: "meanwhile", stage: "queued" }, ]); + expect(harness.engine.waitingRequests()[0]).not.toHaveProperty("reason"); clock.advance(50); await flush(); @@ -5171,22 +5208,4 @@ describe("createLeasing: a waiting request says why it is not placed (#636)", () { reason: "device-limit", requesterId: "second", stage: "queued" }, ]); }); - - it("lists no reason for a starting request that is being provisioned", async () => { - const clock = new FakeClock(1_000); - const driver = new FakeDriver({ - availableOsVersions: ["26.5"], - clock, - latencyMs: { provision: 50 }, - platform: "ios", - }); - const harness = await createHarness({ driver }); - void harness.engine.request(request, asking("provisioned")); - await flush(); - - expect(listing(harness)).toEqual([ - { reason: undefined, requesterId: "provisioned", stage: "starting" }, - ]); - expect(harness.engine.waitingRequests()[0]).not.toHaveProperty("reason"); - }); }); diff --git a/src/spec-lint.test.ts b/src/spec-lint.test.ts index db07d724f..3c6c3c3f9 100644 --- a/src/spec-lint.test.ts +++ b/src/spec-lint.test.ts @@ -221,6 +221,11 @@ describe("spec-lint.mjs on a task", () => { swap("| F2 |", "| F1 |"), "B 100 Facts: F1 is used twice; renumber one", ], + [ + "an F number that comes after a higher one", + swap("| F1 |", "| F3 |"), + "N 100 Facts: F2 comes after F3", + ], [ "a line past the end of its file", swap("`package.json:2`", "`package.json:99999`"), @@ -334,12 +339,6 @@ describe("spec-lint.mjs on a task", () => { expect(result.status).toBe(expected.startsWith("B") ? 1 : 0); }); - it("notes an F number that comes after a higher one", () => { - const body = TASK.replace("| F1 |", "| F3 |"); - - expect(lintTask(body).lines).toContain("N 100 Facts: F2 comes after F3"); - }); - it("passes Failure modes written as none", () => { const body = TASK.replace( /\| Call \| Outcome[\s\S]*?\| at start \|/, diff --git a/src/updater/index.test.ts b/src/updater/index.test.ts index 6259d742c..456622d8b 100644 --- a/src/updater/index.test.ts +++ b/src/updater/index.test.ts @@ -754,6 +754,11 @@ describe("parseUpdaterArgs", () => { argv: ["--from", "0.3.0", "--to", "1".repeat(65), "--pid", "7"], parsed: undefined, }, + { + name: "a version of exactly 64 characters", + argv: ["--from", "0.3.0", "--to", "1".repeat(64), "--pid", "7"], + parsed: { from: "0.3.0", to: "1".repeat(64), pid: 7, role: "worker" }, + }, { name: "an argument it does not know", argv: ["--from", "0.3.0", "--to", "0.4.0", "--pid", "7", "--force"], diff --git a/ui/src/state/queries.test.ts b/ui/src/state/queries.test.ts index 239b6010a..8a910f35a 100644 --- a/ui/src/state/queries.test.ts +++ b/ui/src/state/queries.test.ts @@ -292,7 +292,7 @@ describe("a read's answers and failures", () => { expect(resourceOf(observer.getCurrentResult())).toEqual({}); }); - it("a read that gives up after 10 seconds marks the console disconnected", async () => { + it("a read that times out marks the console disconnected", async () => { const { connection, daemon, watch } = start(false); daemon.reads = () => Promise.reject(new RequestTimeoutError("/v1/workers")); watch("/v1/workers"); @@ -619,6 +619,8 @@ describe("a lease renewal", () => { gateway.watch("/v1/leases"); gateway.watch("/v1/workers"); await settle(); + // why: polls land 9_999 ms after the renewal, so the checks below straddle its 60 s. + await vi.advanceTimersByTimeAsync(1); const deadline = Date.now() + 120_000; gateway.daemon .openStream() @@ -660,10 +662,11 @@ describe("a lease renewal", () => { gateway.daemon.reads = (path) => Promise.resolve({ body: path === "/v1/leases" ? old : fleet, date: null }); await vi.advanceTimersByTimeAsync(30_000); + await vi.advanceTimersByTimeAsync(9_999); const still = gateway.state("/v1/leases").data as { leases: { ttlDeadline: number }[] }; expect(still.leases[0]?.ttlDeadline).toBe(deadline); - await vi.advanceTimersByTimeAsync(30_000); + await vi.advanceTimersByTimeAsync(10_000); const dropped = gateway.state("/v1/leases").data as { leases: { ttlDeadline: number }[] }; expect(dropped.leases[0]?.ttlDeadline).toBe(NOW + 60_000); }); From 776aabe78a2e39d3ebf065582637840e9578dfc9 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 29 Oct 2026 19:32:50 +0100 Subject: [PATCH 2/2] test(audit): fix typecheck and the unused export the audit left --- src/drivers/ios/index.ts | 2 +- src/http/app.test.ts | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/drivers/ios/index.ts b/src/drivers/ios/index.ts index 0b4db273b..b779541ed 100644 --- a/src/drivers/ios/index.ts +++ b/src/drivers/ios/index.ts @@ -2285,7 +2285,7 @@ function iosDriverData(device: DriverDevice): IosDriverData { export { iosRuntimeVersionFromId }; -export function supportsPersistentSlim(version: readonly [number, number] | undefined): boolean { +function supportsPersistentSlim(version: readonly [number, number] | undefined): boolean { if (version === undefined) { return false; } diff --git a/src/http/app.test.ts b/src/http/app.test.ts index c9758cfe9..e4eb8858e 100644 --- a/src/http/app.test.ts +++ b/src/http/app.test.ts @@ -1022,7 +1022,9 @@ describe("full lease-request lifecycle via GET / long-poll / SSE", () => { const { app, clock, dispatcher } = buildHarness(); const { id } = await createLeaseRequest(app, dispatcher); - const waitPromise = app.request(`/v1/lease-requests/${id}?wait=5`, { headers: agentAuth }); + const waitPromise = Promise.resolve( + app.request(`/v1/lease-requests/${id}?wait=5`, { headers: agentAuth }), + ); let answered = false; void waitPromise.then(() => { answered = true; @@ -2495,9 +2497,9 @@ describe("hardening from review", () => { const { app, clock, dispatcher } = buildHarness(); const { id } = await createLeaseRequest(app, dispatcher); - const waitPromise = app.request(`/v1/lease-requests/${id}?wait=999999`, { - headers: agentAuth, - }); + const waitPromise = Promise.resolve( + app.request(`/v1/lease-requests/${id}?wait=999999`, { headers: agentAuth }), + ); let answered = false; void waitPromise.then(() => { answered = true;