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 344219f8a..ec1ddf99b 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -1390,57 +1390,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(); @@ -1548,7 +1542,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"); }); @@ -2550,19 +2544,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"; @@ -2695,7 +2676,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"); @@ -2935,20 +2916,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[] = []; @@ -3063,14 +3030,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"]); @@ -3083,6 +3042,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"]], @@ -3114,7 +3074,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 () => { @@ -3347,47 +3309,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"], @@ -7077,42 +6998,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[] = []; @@ -7289,7 +7182,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 5320edac9..45cca4f3f 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", - }), - ); }); }); @@ -1427,20 +1387,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 () => { @@ -2080,7 +2026,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); @@ -2111,9 +2057,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". @@ -2241,9 +2185,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({ @@ -2263,13 +2210,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 () => { @@ -2306,29 +2246,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); @@ -2410,23 +2327,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(); @@ -2903,7 +2839,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( @@ -2917,19 +2853,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([]); }); }); @@ -2963,7 +2892,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" }]; @@ -2975,21 +2904,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 () => { @@ -3022,7 +2936,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", () => { @@ -3034,21 +2948,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(); } @@ -3254,9 +3156,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() }, }, @@ -3275,18 +3177,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" }, - }, - ]); }); }); @@ -3649,28 +3539,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 = { @@ -3724,21 +3629,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, { @@ -3751,10 +3641,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([]); @@ -3996,39 +3888,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", @@ -4151,17 +4079,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 () => { @@ -4222,32 +4147,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", @@ -4255,30 +4157,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; @@ -4292,16 +4170,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({}); @@ -4312,17 +4180,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); @@ -4354,6 +4212,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 054ff3bd1..2ea5cb416 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 () => { @@ -2819,6 +2783,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(); @@ -2889,7 +2858,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); @@ -2911,6 +2883,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(); }); @@ -2966,6 +2947,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/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); });