// The idle sleep over HTTP, against one stub that plays both Boat (/boat, public API v1) and the Admin's relay (/relay, // the legacy box dialect): the stop reaches whichever one the credential belongs to, after Chrome is asked to exit. import { createHash } from "node:crypto"; import { createServer, type Server } from "node:http"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { hostTimeout } from "./testing/host-timeout.ts"; const INCLUDED = "box_omb_included-relay-token"; const OWN = "boat_own-key"; const botId = "auto-sleep-test"; const boxId = "bx_23456789"; const machineName = `ogb-${botId.slice(0, 8).toLowerCase().replace(/[^a-z0-9]/g, "")}-${createHash("sha256").update(botId).digest("hex").slice(0, 6)}`; let api: Server; let base = ""; let state = "ready"; /** Status the stub answers a stop with; a 2xx archives the computer. */ let stopStatus = 200; /** Whether the background command the stub started (process 7) still runs. */ let jobRunning = false; const requests: Array<{ method: string; path: string; auth: string; command?: string; at: number }> = []; const stops = () => requests.filter(request => request.method === "POST" && request.path.endsWith("/stop")); const waitFor = async (accept: () => boolean, ms = hostTimeout(5_000)) => { const end = Date.now() + ms; while (!accept()) { if (Date.now() > end) throw new Error(`timed out: ${JSON.stringify(requests)}`); await new Promise(resolve => setTimeout(resolve, 25)); } }; beforeAll(async () => { api = createServer((req, res) => { const url = new URL(req.url ?? "/", "http://boat.test"); let raw = ""; req.on("data", chunk => { raw += chunk; }); req.on("end", () => { const body = raw ? JSON.parse(raw) : {}; requests.push({ method: req.method ?? "GET", path: url.pathname, auth: String(req.headers.authorization ?? ""), command: body.command, at: Date.now() }); const send = (status: number, payload: unknown) => { res.writeHead(status, { "content-type": "application/json", connection: "close" }); res.end(JSON.stringify(payload)); }; const [, prefix = "", rest = ""] = /^(\/boat\/api\/v1|\/relay\/api\/box\/v1)(\/.*)$/.exec(url.pathname) ?? []; const legacy = prefix === "/relay/api/box/v1"; const expected = legacy ? `Bearer ${INCLUDED}` : `Bearer ${OWN}`; if (!prefix || req.headers.authorization !== expected) return send(401, { ok: false, code: "unauthorized", message: "Unauthorized" }); const [root, one, many] = legacy ? ["/boxes", "box", "boxes"] : ["/sandboxes", "sandbox", "sandboxes"]; const row = { id: boxId, name: machineName, state }; if (rest === root) return send(200, { ok: true, [many]: [row], pageInfo: { nextCursor: null } }); if (rest === `${root}/${boxId}` && req.method === "GET") return send(200, { ok: true, [one]: row }); if (rest === `${root}/${boxId}/commands` && body.detached === true) { jobRunning = true; return send(200, { ok: true, processId: 7 }); } if (rest === `${root}/${boxId}/commands`) return send(200, { exitCode: 0, stdout: "", stderr: "" }); if (rest === `${root}/${boxId}/commands/7`) return send(200, { ok: true, processId: 7, status: jobRunning ? "running" : "exited", running: jobRunning, exitCode: jobRunning ? null : 0, stdout: "", stderr: "" }); if (rest === `${root}/${boxId}/stop`) { if (stopStatus < 300) state = "archived"; return send(stopStatus, stopStatus < 300 ? { ok: true } : { ok: false, message: "Cloud computers are not available right now." }); } send(404, { ok: false, code: "not_found", message: `unexpected ${req.method} ${url.pathname}` }); }); }); await new Promise(resolve => api.listen(0, "127.0.0.1", resolve)); base = `http://127.0.0.1:${(api.address() as { port: number }).port}`; }); afterAll(async () => { await new Promise(resolve => api.close(() => resolve())); }); describe("a cloud computer goes to sleep after its idle window", () => { let power: ReturnType; let logs: string[]; let busy: boolean; async function powerFor(cfg: import("./config.ts").AppConfig) { vi.resetModules(); const { createCloudComputerPower } = await import("./host/cloud-computer-power.ts"); power = createCloudComputerPower({ cfg, busy: () => busy, claim: () => () => {}, owners: () => [{ botId, name: "Auto sleep", inUse: busy }], routines: () => [], routineComputer: () => null, broadcast: () => {}, log: line => logs.push(line), }); return power; } beforeEach(() => { requests.length = 0; state = "ready"; stopStatus = 200; jobRunning = false; logs = []; busy = false; vi.stubEnv("BOX_TOKEN", undefined); vi.stubEnv("OMB_BOAT_API", `${base}/boat/api/v1`); vi.stubEnv("OMB_CLOUD_BOAT_URL", `${base}/relay/api/box/v1`); vi.stubEnv("OMB_CLOUD_BOAT_TOKEN", INCLUDED); vi.stubEnv("OMB_CLOUD_COMPUTER_IDLE_MS", "1000"); }); afterEach(() => { power?.close(); vi.unstubAllEnvs(); }); it("with a person's own Boat key, stops it at boat.dev, asking Chrome to exit first", async () => { await powerFor({ box: { token: OWN } }); power.used(botId); await new Promise(resolve => setTimeout(resolve, 600)); expect(stops()).toEqual([]); await waitFor(() => stops().length === 1); expect(stops()[0]).toMatchObject({ path: `/boat/api/v1/sandboxes/${boxId}/stop`, auth: `Bearer ${OWN}` }); const quiesce = requests.findIndex(request => request.path.endsWith("/commands")); expect(requests[quiesce]?.command).toContain("kill -TERM"); expect(quiesce).toBeLessThan(requests.indexOf(stops()[0]!)); expect(requests.every(request => request.path.startsWith("/boat/"))).toBe(true); expect(state).toBe("archived"); }); it("with the cloud computer a Cloud plan includes, stops it through the Admin relay only", async () => { await powerFor({}); power.used(botId); await waitFor(() => stops().length === 1); expect(stops()[0]).toMatchObject({ path: `/relay/api/box/v1/boxes/${boxId}/stop`, auth: `Bearer ${INCLUDED}` }); expect(requests.every(request => request.path.startsWith("/relay/"))).toBe(true); }); it("after a restart, finds a computer left on through the relay and puts it to sleep", async () => { await powerFor({}); await power.armRunning(); expect(requests.map(request => `${request.method} ${request.path}`)).toEqual(["GET /relay/api/box/v1/boxes"]); await waitFor(() => stops().length === 1); expect(stops()[0]?.path).toBe(`/relay/api/box/v1/boxes/${boxId}/stop`); }); it("keeps a busy computer on, and leaves one already asleep alone", async () => { await powerFor({ box: { token: OWN } }); busy = true; power.used(botId); await new Promise(resolve => setTimeout(resolve, 3_500)); expect(requests).toEqual([]); busy = false; state = "archived"; // The first quiet look starts a full window; the look after it finds the computer asleep and lets its timer go. await waitFor(() => !power.awake()); expect(stops()).toEqual([]); expect(requests.map(request => request.method)).toEqual(["GET"]); }); it.each([ ["a person's own Boat key", { box: { token: OWN } }, "/boat/api/v1/sandboxes"], ["the cloud computer a Cloud plan includes", {}, "/relay/api/box/v1/boxes"], ])("with %s, stays on while a background command runs and sleeps once it has ended", async (_label, cfg, root) => { await powerFor(cfg); const background = await import("./cloud-computer-background.ts"); await background.startInBackground(cfg, boxId, "python3 job.py", new AbortController().signal); power.used(botId); await new Promise(resolve => setTimeout(resolve, 3_500)); expect(stops()).toEqual([]); expect(requests.filter(request => request.path === `${root}/${boxId}/commands/7`).length).toBeGreaterThanOrEqual(2); expect(power.awake()).toBe(true); jobRunning = false; await waitFor(() => stops().length === 1); expect(stops()[0]?.path).toBe(`${root}/${boxId}/stop`); }); it("a stop the relay refuses is logged and retried after a longer wait each time", async () => { stopStatus = 503; await powerFor({}); power.used(botId); await waitFor(() => stops().length === 3 && logs.length === 3, hostTimeout(10_000)); for (const line of logs) { expect(line).toBe("cloud computer auto-sleep-test: could not go to sleep, retrying in 1 min: boat sleep failed: Cloud computers are not available right now."); } const [first, second, third] = stops().map(stop => stop.at); // A Node timer may fire up to a millisecond before its delay by Date.now(). expect(second! - first!).toBeGreaterThanOrEqual(1_000 - 5); expect(third! - second!).toBeGreaterThanOrEqual(2_000 - 5); }); });