server/boat-auto-sleep.test.ts
Rama main · réplica de lectura del repositorio original.
// 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<void>(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<void>(resolve => api.close(() => resolve()));
});
describe("a cloud computer goes to sleep after its idle window", () => {
let power: ReturnType<typeof import("./host/cloud-computer-power.ts").createCloudComputerPower>;
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);
});
});