import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Central } from "../../central/service"; import { CentralStore } from "../../central/store"; import { testKeys } from "../helpers/centralKeys"; /** What the service gives the dispatcher: jobs that know whether they post and which dispatch they belong to, skipped jobs, a stop that survives a restart and ends work in progress, and word when a job ends for good. */ const ORIGIN = "http://127.0.0.1:4780"; const PLAIN = `test "plain"\n start ${ORIGIN}/form.html\n step "s"\n sleep 1ms\n`; const POSTING = `test "posting"\n posts\n start ${ORIGIN}/form.html\n step "s"\n sleep 1ms\n`; let dir: string; let clock: number; let store: CentralStore; let c: Central; const make = async () => { const keys = testKeys(); return new Central({ store, dataDir: dir, now: () => clock, ...keys.deps, leaseMs: 60_000, maxAttempts: 2, maxArchiveBytes: 1_000_000 }); }; beforeEach(async () => { dir = mkdtempSync(join(tmpdir(), "aite-dispsvc-")); clock = Date.parse("2026-10-15T12:00:00Z"); store = new CentralStore(":memory:"); c = await make(); }); afterEach(() => { store.close(); rmSync(dir, { recursive: true, force: true }); }); async function setup(script = PLAIN) { const dev = c.enrolDevice("balrog", [ORIGIN]); const bundle = await c.publishBundle({ "scripts/a.aite": script }, "c1"); return { dev, bundle, input: { deviceId: dev.device.id, bundle: bundle.hash, file: "scripts/a.aite", start: `${ORIGIN}/form.html`, allow: [ORIGIN], postsAllowed: 1 } }; } describe("createJob for a dispatch", () => { it("records whether the test posts, from the script itself (not from what the caller says)", async () => { const plain = await setup(PLAIN); const p = c.createJob(plain.input); expect(p.ok && p.job.posts).toBe(false); const posting = await setup(POSTING); const q = c.createJob(posting.input); expect(q.ok && q.job.posts).toBe(true); }); it("records whether the test spends, from the script itself, and refuses it without a spends allowance; a posts allowance does not do (#69)", async () => { const SPENDING = `test "spending"\n spends\n start ${ORIGIN}/form.html\n step "s"\n sleep 1ms\n`; const plain = await setup(PLAIN); const p = c.createJob(plain.input); expect(p.ok && p.job.spends).toBe(false); const spending = await setup(SPENDING); expect(c.createJob(spending.input)).toMatchObject({ ok: false, message: expect.stringContaining("spends") }); expect(c.createJob({ ...spending.input, postsAllowed: 5 })).toMatchObject({ ok: false, message: expect.stringContaining("spends") }); const q = c.createJob({ ...spending.input, spendsAllowed: 1 }); expect(q.ok && q.job).toMatchObject({ spends: true, posts: false, budget: { totalMs: 60_000, postsAllowed: 1, spendsAllowed: 1 } }); }); it("keeps the dispatch id and the job it replaces", async () => { const { input } = await setup(); const a = c.createJob({ ...input, dispatchId: "dsp-1" }); const b = c.createJob({ ...input, dispatchId: "dsp-1", retryOf: a.ok ? a.job.id : "" }); expect(b.ok && b.job).toMatchObject({ dispatchId: "dsp-1", retryOf: a.ok ? a.job.id : "" }); expect(store.jobsOfDispatch("dsp-1")).toHaveLength(2); }); }); describe("recordSkipped: a job that was not run, with the reason, so it is reported", () => { it("makes a skipped job that is never offered, rings nothing, and shows in the list and the events", async () => { const { input, dev } = await setup(POSTING); const rang: string[] = []; c.onOffered((j) => void rang.push(j.id)); const r = c.recordSkipped(input, "the post cap for this device is reached (10 a day)", "dsp-9"); expect(r).toMatchObject({ ok: true, job: { state: "skipped", failReason: expect.stringContaining("post cap"), dispatchId: "dsp-9", posts: true } }); expect(rang).toEqual([]); expect(c.offered(dev.device)).toEqual([]); expect(c.accept(dev.device, r.ok ? r.job.id : "")).toEqual({ ok: false, status: 409 }); expect(c.jobDetail(r.ok ? r.job.id : "")!.events.map((e) => e.kind)).toEqual(["skipped"]); }); it("refuses a job that could not have been made (unknown device, bad origin)", async () => { const { input } = await setup(); expect(c.recordSkipped({ ...input, deviceId: "dev-none" }, "x")).toMatchObject({ ok: false }); expect(c.recordSkipped({ ...input, allow: ["https://evil.example"], start: "https://evil.example/" }, "x")).toMatchObject({ ok: false }); }); }); describe("the server-side stop", () => { it("while stopped nothing is offered, nothing is taken, and a job made now does not ring", async () => { const { input, dev } = await setup(); const made = c.createJob(input); c.stop("deploying"); expect(c.stopped()).toMatchObject({ reason: "deploying" }); expect(c.offered(dev.device)).toEqual([]); expect(c.accept(dev.device, made.ok ? made.job.id : "")).toEqual({ ok: false, status: 409 }); const rang: string[] = []; c.onOffered((j) => void rang.push(j.id)); c.createJob(input); expect(rang).toEqual([]); }); it("ends the work in progress: accepted jobs are cancelled with the reason, so the next heartbeat is refused", async () => { const { input, dev } = await setup(); const made = c.createJob(input); const id = made.ok ? made.job.id : ""; c.accept(dev.device, id); expect(c.heartbeat(dev.device, id)).toBe(true); c.stop("deploying"); expect(store.jobById(id)).toMatchObject({ state: "cancelled", failReason: expect.stringContaining("deploying") }); expect(c.heartbeat(dev.device, id)).toBe(false); expect(c.jobDetail(id)!.events.map((e) => e.kind)).toContain("stopped"); }); it("an archive for a stopped job is refused", async () => { const { input, dev } = await setup(); const id = (c.createJob(input) as { ok: true; job: { id: string } }).job.id; c.accept(dev.device, id); c.stop("x"); expect(c.putArchive(dev.device, id, new Uint8Array([1]), "0".repeat(64))).toMatchObject({ ok: false, status: 409 }); }); it("survives a restart: a new service on the same database is still stopped, until it is resumed", async () => { c.stop("maintenance"); const again = await make(); expect(again.stopped()).toMatchObject({ reason: "maintenance", since: clock }); again.resume(); expect(again.stopped()).toBeUndefined(); expect((await make()).stopped()).toBeUndefined(); }); it("resume rings every job that is waiting, so devices pick up straight away", async () => { const { input } = await setup(); c.createJob(input); c.createJob(input); c.stop("x"); const rang: string[] = []; c.onOffered((j) => void rang.push(j.id)); c.resume(); expect(rang).toHaveLength(2); }); it("stop twice keeps the first reason and time; resume when not stopped is not an error", async () => { c.stop("first"); clock += 5000; c.stop("second"); expect(c.stopped()).toMatchObject({ reason: "first" }); c.resume(); expect(() => c.resume()).not.toThrow(); }); it("a stop needs a reason a person can read", () => { expect(() => c.stop("")).toThrow(/reason/); expect(() => c.stop("x".repeat(300))).toThrow(/reason/); }); }); describe("onTerminal: a job that ended without a result", () => { it("is announced when its attempts run out (expired) and when the device reports a final failure, with the job", async () => { const { input, dev } = await setup(); const ended: Array<{ id: string; state: string; reason?: string }> = []; c.onTerminal((j) => void ended.push({ id: j.id, state: j.state, ...(j.failReason !== undefined ? { reason: j.failReason } : {}) })); const a = c.createJob(input); const aid = a.ok ? a.job.id : ""; for (let i = 0; i < 2; i++) { c.accept(dev.device, aid); clock += 61_000; c.sweep(); } expect(ended).toEqual([{ id: aid, state: "expired", reason: expect.any(String) }]); const b = c.createJob(input); const bid = b.ok ? b.job.id : ""; c.accept(dev.device, bid); c.fail(dev.device, bid, "aborted-by-device: paused"); expect(ended.at(-1)).toEqual({ id: bid, state: "failed", reason: "aborted-by-device: paused" }); }); it("is not announced for a transient failure that is offered again, for a job that is done, or for a stop", async () => { const { input, dev } = await setup(); const ended: string[] = []; c.onTerminal((j) => void ended.push(j.id)); const a = c.createJob(input); const id = a.ok ? a.job.id : ""; c.accept(dev.device, id); c.fail(dev.device, id, "bundle-unavailable: down"); // offered again at attempt 2 expect(ended).toEqual([]); c.accept(dev.device, id); c.stop("x"); expect(ended).toEqual([]); }); it("a listener that throws changes nothing", async () => { const { input, dev } = await setup(); c.onTerminal(() => { throw new Error("boom"); }); const id = (c.createJob(input) as { ok: true; job: { id: string } }).job.id; c.accept(dev.device, id); expect(c.fail(dev.device, id, "bundle-refused: x")).toBe(true); expect(store.jobById(id)?.state).toBe("failed"); }); }); describe("a lease is a fence: a runner that lost its attempt cannot touch the next one", () => { it("a heartbeat for attempt 1 after the job is back at attempt 2 is refused and does not extend attempt 2's lease", async () => { const { input, dev } = await setup(); const id = (c.createJob(input) as { ok: true; job: { id: string } }).job.id; c.accept(dev.device, id); clock += 61_000; c.sweep(); // attempt 1 lost its lease: offered again as attempt 2 expect(c.accept(dev.device, id)).toMatchObject({ ok: true, job: { attempt: 2 } }); const lease = store.jobById(id)!.leaseUntil; clock += 30_000; expect(c.heartbeat(dev.device, id, 1)).toBe(false); // the stale runner expect(store.jobById(id)!.leaseUntil).toBe(lease); expect(c.heartbeat(dev.device, id, 2)).toBe(true); // the real one }); it("a failure report for attempt 1 does not release attempt 2", async () => { const { input, dev } = await setup(); const id = (c.createJob(input) as { ok: true; job: { id: string } }).job.id; c.accept(dev.device, id); clock += 61_000; c.sweep(); c.accept(dev.device, id); expect(c.fail(dev.device, id, "host-error: stale", 1)).toBe(false); expect(store.jobById(id)).toMatchObject({ state: "accepted", attempt: 2 }); expect(c.fail(dev.device, id, "bundle-refused: real", 2)).toBe(true); }); it("without an attempt the service behaves as before (the HTTP layer is what insists on one)", async () => { const { input, dev } = await setup(); const id = (c.createJob(input) as { ok: true; job: { id: string } }).job.id; c.accept(dev.device, id); expect(c.heartbeat(dev.device, id)).toBe(true); }); }); describe("the central system looks inside the scripts a test includes (`run`)", () => { const withFiles = async (files: Record) => { const dev = c.enrolDevice("balrog", [ORIGIN]); const bundle = await c.publishBundle(files, "c1"); return { dev, bundle, input: { deviceId: dev.device.id, bundle: bundle.hash, file: "scripts/main.aite", start: `${ORIGIN}/form.html`, allow: [ORIGIN], postsAllowed: 1 } }; }; const MAIN = (inc: string) => `test "main"\n start ${ORIGIN}/form.html\n step "s"\n run ${inc}\n`; it("a goto in an included script that leaves the job's origins is refused when the job is made, as it would be on the device", async () => { const { input } = await withFiles({ "scripts/main.aite": MAIN("shared/inc.aite"), "scripts/shared/inc.aite": `test "inc"\n start ${ORIGIN}/x\n step "s"\n goto https://evil.example/steal\n` }); expect(c.createJob(input)).toMatchObject({ ok: false, message: expect.stringContaining("allow list") }); }); it("a test that posts only in an included script is a test that posts: the job is marked, the cap counts it, and the job needs a posts allowance", async () => { const { input } = await withFiles({ "scripts/main.aite": MAIN("shared/inc.aite"), "scripts/shared/inc.aite": `test "inc"\n posts\n start ${ORIGIN}/x\n step "s"\n sleep 1ms\n` }); expect(c.scriptInfo(input.bundle, "scripts/main.aite")).toEqual({ posts: true, spends: false }); expect(c.createJob({ ...input, postsAllowed: 0 })).toMatchObject({ ok: false, message: expect.stringContaining("posts") }); const made = c.createJob(input); expect(made.ok && made.job.posts).toBe(true); }); it("a test that spends only in an included script is a test that spends: the job is marked, the cap counts it, and the job needs a spends allowance (#69)", async () => { const { input } = await withFiles({ "scripts/main.aite": MAIN("shared/inc.aite"), "scripts/shared/inc.aite": `test "inc"\n spends\n start ${ORIGIN}/x\n step "s"\n sleep 1ms\n` }); expect(c.scriptInfo(input.bundle, "scripts/main.aite")).toEqual({ posts: false, spends: true }); expect(c.createJob(input)).toMatchObject({ ok: false, message: expect.stringContaining("spends") }); expect(c.createJob({ ...input, spendsAllowed: 0 })).toMatchObject({ ok: false, message: expect.stringContaining("spends") }); const made = c.createJob({ ...input, spendsAllowed: 1 }); expect(made.ok && made.job).toMatchObject({ spends: true, posts: false, budget: { spendsAllowed: 1 } }); }); it("an include that is missing from the bundle, escapes the folder, or loops is refused with the reason", async () => { const missing = await withFiles({ "scripts/main.aite": MAIN("shared/none.aite") }); expect(c.createJob(missing.input)).toMatchObject({ ok: false, message: expect.stringMatching(/none\.aite|cannot read/) }); const loop = await withFiles({ "scripts/main.aite": MAIN("shared/a.aite"), "scripts/shared/a.aite": `test "a"\n start ${ORIGIN}/x\n step "s"\n run b.aite\n`, "scripts/shared/b.aite": `test "b"\n start ${ORIGIN}/x\n step "s"\n run a.aite\n` }); expect(c.createJob(loop.input)).toMatchObject({ ok: false, message: expect.stringMatching(/loop|deep/) }); const out = await withFiles({ "scripts/main.aite": MAIN("../../../etc/passwd.aite") }); expect(c.createJob(out.input)).toMatchObject({ ok: false, message: expect.stringContaining("inside the project") }); }); it("a plain test with an include that stays on its origins is made as before", async () => { const { input } = await withFiles({ "scripts/main.aite": MAIN("shared/inc.aite"), "scripts/shared/inc.aite": `test "inc"\n start ${ORIGIN}/x\n step "s"\n goto ${ORIGIN}/other\n` }); expect(c.createJob(input)).toMatchObject({ ok: true }); }); });