@intentius/chant-lexicon-fly 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/composites/fly-deploy.d.ts +1 -1
  2. package/dist/composites/fly-deploy.d.ts.map +1 -1
  3. package/dist/emulator-freshness-cli.d.ts +11 -0
  4. package/dist/emulator-freshness-cli.d.ts.map +1 -0
  5. package/dist/emulator-freshness.d.ts +38 -0
  6. package/dist/emulator-freshness.d.ts.map +1 -0
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/op/activities/emulator-images.d.ts +20 -0
  10. package/dist/op/activities/emulator-images.d.ts.map +1 -0
  11. package/dist/op/activities/flaps.d.ts +1 -1
  12. package/dist/op/activities/flaps.d.ts.map +1 -1
  13. package/dist/op/activities/index.d.ts +4 -0
  14. package/dist/op/activities/index.d.ts.map +1 -1
  15. package/dist/op/activities/machines-contract.d.ts +36 -0
  16. package/dist/op/activities/machines-contract.d.ts.map +1 -0
  17. package/dist/op/activities/sprites-contract.d.ts +45 -0
  18. package/dist/op/activities/sprites-contract.d.ts.map +1 -0
  19. package/dist/op/activities/sprites-emulator.d.ts +29 -0
  20. package/dist/op/activities/sprites-emulator.d.ts.map +1 -0
  21. package/dist/op/activities/sprites-fake.d.ts +62 -0
  22. package/dist/op/activities/sprites-fake.d.ts.map +1 -0
  23. package/dist/op/activities/sprites.d.ts +195 -0
  24. package/dist/op/activities/sprites.d.ts.map +1 -0
  25. package/dist/plugin.d.ts.map +1 -1
  26. package/package.json +6 -2
  27. package/src/composites/fly-deploy.ts +1 -1
  28. package/src/emulator-freshness-cli.ts +49 -0
  29. package/src/emulator-freshness.test.ts +86 -0
  30. package/src/emulator-freshness.ts +87 -0
  31. package/src/index.ts +15 -0
  32. package/src/op/activities/emulator-images.ts +21 -0
  33. package/src/op/activities/flaps.test.ts +2 -1
  34. package/src/op/activities/flaps.ts +3 -2
  35. package/src/op/activities/index.ts +53 -0
  36. package/src/op/activities/machines-contract.docker.integration.test.ts +72 -0
  37. package/src/op/activities/machines-contract.test.ts +49 -0
  38. package/src/op/activities/machines-contract.ts +73 -0
  39. package/src/op/activities/sprites-contract.docker.integration.test.ts +74 -0
  40. package/src/op/activities/sprites-contract.test.ts +60 -0
  41. package/src/op/activities/sprites-contract.ts +61 -0
  42. package/src/op/activities/sprites-emulator.ts +46 -0
  43. package/src/op/activities/sprites-fake.ts +314 -0
  44. package/src/op/activities/sprites.docker.integration.test.ts +99 -0
  45. package/src/op/activities/sprites.integration.test.ts +158 -0
  46. package/src/op/activities/sprites.real.test.ts +56 -0
  47. package/src/op/activities/sprites.test.ts +296 -0
  48. package/src/op/activities/sprites.ts +527 -0
  49. package/src/plugin.ts +13 -0
  50. package/src/skills/chant-fly-sprites.md +104 -0
@@ -0,0 +1,158 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from "vitest";
2
+ import {
3
+ loadActivities,
4
+ runOpLocally,
5
+ OpRunFailure,
6
+ phase,
7
+ spriteCreate,
8
+ spriteExec,
9
+ spriteCheckpoint,
10
+ spriteRestore,
11
+ spriteDestroy,
12
+ type ActivityFn,
13
+ type ActivityProfile,
14
+ type OpConfig,
15
+ } from "@intentius/chant/op";
16
+ import { createSpritesFake } from "./sprites-fake";
17
+
18
+ // End-to-end against the in-process fake (S7) — no Docker, runs in CI. The
19
+ // activities resolve by name through `loadActivities(["fly"])` and reach
20
+ // the fake via `SPRITES_BASE_URL`. Fast profiles so retry loops run in ms.
21
+
22
+ const PROFILES: Record<string, ActivityProfile> = {
23
+ longInfra: { startToCloseTimeout: "5m", retry: { maximumAttempts: 3, initialInterval: "1ms", backoffCoefficient: 1 } },
24
+ fastIdempotent: { startToCloseTimeout: "5m", retry: { maximumAttempts: 2, initialInterval: "1ms", backoffCoefficient: 1 } },
25
+ };
26
+
27
+ let fake: { url: string; close(): Promise<void> };
28
+ let activities: Map<string, ActivityFn>;
29
+ let prevBaseUrl: string | undefined;
30
+
31
+ beforeAll(async () => {
32
+ fake = await createSpritesFake();
33
+ // Prove endpoint override via the env (S3): the same Op targets the fake with
34
+ // no code change. Activities read SPRITES_BASE_URL when no `endpoint` arg is set.
35
+ prevBaseUrl = process.env.SPRITES_BASE_URL;
36
+ process.env.SPRITES_BASE_URL = fake.url;
37
+ activities = await loadActivities(["fly"]);
38
+ });
39
+
40
+ afterAll(async () => {
41
+ if (prevBaseUrl === undefined) delete process.env.SPRITES_BASE_URL;
42
+ else process.env.SPRITES_BASE_URL = prevBaseUrl;
43
+ await fake?.close();
44
+ });
45
+
46
+ /** Read a sprite's live state (fs + checkpoints) from the fake. */
47
+ async function inspect(id: string): Promise<{ status: string; fs: Record<string, string>; checkpoints: string[] }> {
48
+ const res = await fetch(`${fake.url}/v1/sprites/${id}`);
49
+ return (await res.json()) as { status: string; fs: Record<string, string>; checkpoints: string[] };
50
+ }
51
+
52
+ describe("sprite activities resolve by name (S2)", () => {
53
+ test("loadActivities([\"fly\"]) exposes the five sprite activities", () => {
54
+ for (const fn of ["spriteCreate", "spriteExec", "spriteCheckpoint", "spriteRestore", "spriteDestroy"]) {
55
+ expect(typeof activities.get(fn)).toBe("function");
56
+ }
57
+ });
58
+ });
59
+
60
+ describe("agent-task happy path", () => {
61
+ test("Create → Checkpoint → Run → Verify → Destroy runs green end to end", async () => {
62
+ const op: OpConfig = {
63
+ name: "agent-task",
64
+ overview: "happy path",
65
+ taskQueue: "sprites",
66
+ phases: [
67
+ phase("Create", [spriteCreate({ name: "task-1" })]),
68
+ phase("Checkpoint", [spriteCheckpoint({ id: "task-1", comment: "pre-run" })]),
69
+ phase("Run", [spriteExec({ id: "task-1", cmd: "echo hello > /work/output" })]),
70
+ phase("Verify", [spriteExec({ id: "task-1", cmd: "cat /work/output" })]),
71
+ phase("Destroy", [spriteDestroy({ id: "task-1" })]),
72
+ ],
73
+ };
74
+ const result = await runOpLocally(op, activities, PROFILES);
75
+ expect(result.ok).toBe(true);
76
+ expect(result.records.map((r) => r.fn)).toEqual([
77
+ "spriteCreate",
78
+ "spriteCheckpoint",
79
+ "spriteExec",
80
+ "spriteExec",
81
+ "spriteDestroy",
82
+ ]);
83
+ expect(result.records.every((r) => r.status === "ok")).toBe(true);
84
+ });
85
+
86
+ test("exec mutates the sprite fs (checkpoint/restore observability)", async () => {
87
+ const op: OpConfig = {
88
+ name: "agent-write",
89
+ overview: "state observability",
90
+ phases: [
91
+ phase("Create", [spriteCreate({ name: "obs-1" })]),
92
+ phase("Run", [spriteExec({ id: "obs-1", cmd: "echo hello > /work/output" })]),
93
+ ],
94
+ };
95
+ await runOpLocally(op, activities, PROFILES);
96
+ const state = await inspect("obs-1");
97
+ expect(state.fs).toEqual({ "/work/output": "hello" });
98
+ });
99
+ });
100
+
101
+ describe("guarded-task — checkpoint-as-compensation (S5)", () => {
102
+ test("Run fails → onFailure Restore runs → fs is rewound to the pre-run checkpoint", async () => {
103
+ const op: OpConfig = {
104
+ name: "guarded-task",
105
+ overview: "checkpoint, run a risky step, restore on failure",
106
+ taskQueue: "sprites",
107
+ phases: [
108
+ phase("Create", [spriteCreate({ name: "guard-1" })]),
109
+ phase("Seed", [spriteExec({ id: "guard-1", cmd: "echo good > /state" })]),
110
+ phase("Checkpoint", [spriteCheckpoint({ id: "guard-1", comment: "pre-run" })]),
111
+ // Overwrites the good state, then fails — the risky phase.
112
+ phase("Run", [spriteExec({ id: "guard-1", cmd: "echo bad > /state; false" })]),
113
+ phase("Destroy", [spriteDestroy({ id: "guard-1" })]),
114
+ ],
115
+ onFailure: [phase("Restore", [spriteRestore({ id: "guard-1", comment: "pre-run" })])],
116
+ };
117
+
118
+ let failure: OpRunFailure | undefined;
119
+ try {
120
+ await runOpLocally(op, activities, PROFILES);
121
+ } catch (err) {
122
+ failure = err as OpRunFailure;
123
+ }
124
+
125
+ // The Op failed at the risky Run and never reached Destroy.
126
+ expect(failure).toBeInstanceOf(OpRunFailure);
127
+ const records = failure!.result.records;
128
+ const run = records.find((r) => r.phase === "Run");
129
+ expect(run?.status).toBe("fail");
130
+ expect(records.some((r) => r.phase === "Destroy")).toBe(false);
131
+
132
+ // Compensation ran: onFailure Restore executed and succeeded.
133
+ const restore = records.find((r) => r.phase === "Restore");
134
+ expect(restore?.fn).toBe("spriteRestore");
135
+ expect(restore?.status).toBe("ok");
136
+
137
+ // The environment is the transaction: fs is back to the checkpoint state.
138
+ const state = await inspect("guard-1");
139
+ expect(state.fs).toEqual({ "/state": "good" });
140
+ expect(state.status).toBe("running");
141
+ });
142
+
143
+ test("control: without onFailure the risky write is NOT rewound (fs stays corrupt)", async () => {
144
+ const op: OpConfig = {
145
+ name: "unguarded-task",
146
+ overview: "no compensation",
147
+ phases: [
148
+ phase("Create", [spriteCreate({ name: "unguard-1" })]),
149
+ phase("Seed", [spriteExec({ id: "unguard-1", cmd: "echo good > /state" })]),
150
+ phase("Checkpoint", [spriteCheckpoint({ id: "unguard-1", comment: "pre-run" })]),
151
+ phase("Run", [spriteExec({ id: "unguard-1", cmd: "echo bad > /state; false" })]),
152
+ ],
153
+ };
154
+ await expect(runOpLocally(op, activities, PROFILES)).rejects.toBeInstanceOf(OpRunFailure);
155
+ const state = await inspect("unguard-1");
156
+ expect(state.fs).toEqual({ "/state": "bad" });
157
+ });
158
+ });
@@ -0,0 +1,56 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { spriteCreate, spriteExec, spriteDestroy, resolveSpritesEndpoint } from "./sprites";
3
+
4
+ // Real Sprites smoke test (#766), gated on `SPRITES_API_TOKEN`. Runs the WS exec
5
+ // client against the live control WebSocket at api.sprites.dev: create a
6
+ // uniquely-named sprite, exec `echo hi` (assert stdout + exit 0), exec
7
+ // `sh -c "exit 7"` (assert the exit code surfaces as 7), then destroy in a
8
+ // finally. Skips cleanly when the token is absent. The token is read from the
9
+ // environment only — never hardcode it.
10
+
11
+ const TOKEN = process.env.SPRITES_API_TOKEN;
12
+ const BASE = resolveSpritesEndpoint();
13
+
14
+ /** Poll the sprite until it reports `running` (or give up after `timeoutMs`). */
15
+ async function waitRunning(name: string, timeoutMs = 90_000): Promise<void> {
16
+ const deadline = Date.now() + timeoutMs;
17
+ while (Date.now() < deadline) {
18
+ const res = await fetch(`${BASE}/v1/sprites/${encodeURIComponent(name)}`, {
19
+ headers: { authorization: `Bearer ${TOKEN}` },
20
+ });
21
+ if (res.ok) {
22
+ const b = (await res.json().catch(() => ({}))) as { status?: string };
23
+ if (b.status === "running") return;
24
+ }
25
+ await new Promise((r) => setTimeout(r, 2000));
26
+ }
27
+ }
28
+
29
+ describe("real Sprites exec over the control WebSocket (gated on SPRITES_API_TOKEN)", () => {
30
+ test.skipIf(!TOKEN)(
31
+ "echo hi → exit 0; sh -c \"exit 7\" → exit 7",
32
+ async () => {
33
+ const name = `chant-it-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
34
+ try {
35
+ await spriteCreate({ name });
36
+ await waitRunning(name);
37
+
38
+ const ok = await spriteExec({ id: name, cmd: "echo hi" });
39
+ expect(ok.exitCode).toBe(0);
40
+ expect(ok.stdout).toContain("hi");
41
+
42
+ // A non-zero exit throws; capture the surfaced exit code.
43
+ let code: number | undefined;
44
+ try {
45
+ await spriteExec({ id: name, cmd: 'sh -c "exit 7"' });
46
+ } catch (err) {
47
+ code = Number((err as Error).message.match(/exited (\d+)/)?.[1]);
48
+ }
49
+ expect(code).toBe(7);
50
+ } finally {
51
+ await spriteDestroy({ id: name }).catch(() => {});
52
+ }
53
+ },
54
+ 120_000,
55
+ );
56
+ });
@@ -0,0 +1,296 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import {
3
+ resolveSpritesEndpoint,
4
+ DEFAULT_SPRITES_BASE_URL,
5
+ accumulateExecFrames,
6
+ parseCheckpointNdjson,
7
+ pickCheckpointByComment,
8
+ splitCommand,
9
+ spriteExecWsUrl,
10
+ defaultSpritesHttp,
11
+ spriteCreate,
12
+ spriteCheckpoint,
13
+ spriteRestore,
14
+ listCheckpoints,
15
+ spriteDestroy,
16
+ type SpritesHttp,
17
+ type Checkpoint,
18
+ } from "./sprites";
19
+
20
+ const enc = new TextEncoder();
21
+
22
+ /** Build one exec frame `[stream][payload]`. */
23
+ function frame(stream: number, payload?: string | number[]): Uint8Array {
24
+ const body = typeof payload === "string" ? enc.encode(payload) : Uint8Array.from(payload ?? []);
25
+ return Uint8Array.from([stream, ...body]);
26
+ }
27
+
28
+ // A recording HTTP stub: captures every call and answers from a callback.
29
+ function recorder(answer: (method: string, url: string, body?: unknown) => { status: number; text: string }) {
30
+ const calls: Array<{ method: string; url: string; body?: unknown; headers?: Record<string, string> }> = [];
31
+ const http: SpritesHttp = async (method, url, body, headers) => {
32
+ calls.push({ method, url, body, headers });
33
+ return answer(method, url, body);
34
+ };
35
+ return { http, calls };
36
+ }
37
+
38
+ // ── Exec frame accumulator (the WS framing, socket-free) ──────────────────────
39
+
40
+ describe("accumulateExecFrames", () => {
41
+ test("stdout then exit 0", () => {
42
+ expect(accumulateExecFrames([frame(1, "hi\n"), frame(3, [0])])).toEqual({
43
+ stdout: "hi\n",
44
+ stderr: "",
45
+ exitCode: 0,
46
+ });
47
+ });
48
+
49
+ test("stderr then exit 1", () => {
50
+ expect(accumulateExecFrames([frame(2, "err"), frame(3, [1])])).toEqual({
51
+ stdout: "",
52
+ stderr: "err",
53
+ exitCode: 1,
54
+ });
55
+ });
56
+
57
+ test("concatenates multiple stdout frames and ignores stdin/eof frames", () => {
58
+ const frames = [frame(1, "foo"), frame(0), frame(1, "bar"), frame(2, "warn"), frame(4), frame(3, [7])];
59
+ expect(accumulateExecFrames(frames)).toEqual({ stdout: "foobar", stderr: "warn", exitCode: 7 });
60
+ });
61
+
62
+ test("no exit frame defaults exitCode to 0; empty frames are skipped", () => {
63
+ expect(accumulateExecFrames([new Uint8Array(0), frame(1, "x")])).toEqual({
64
+ stdout: "x",
65
+ stderr: "",
66
+ exitCode: 0,
67
+ });
68
+ });
69
+
70
+ test("a multi-byte character split across two stdout frames decodes intact", () => {
71
+ // "€" is E2 82 AC; split after the first byte across two frames.
72
+ expect(accumulateExecFrames([frame(1, [0xe2]), frame(1, [0x82, 0xac]), frame(3, [0])]).stdout).toBe("€");
73
+ });
74
+ });
75
+
76
+ // ── Checkpoint NDJSON parsing ─────────────────────────────────────────────────
77
+
78
+ describe("parseCheckpointNdjson", () => {
79
+ test("mines the version id from real Sprites' message text (type/data shape)", () => {
80
+ // Verbatim shape from api.sprites.dev: no structured id field — the version
81
+ // rides inside the " ID: v1" detail line and the completion message.
82
+ const body = [
83
+ '{"type":"info","data":"Creating checkpoint...","time":"2026-07-12T05:01:17Z"}',
84
+ '{"type":"info","data":"Checkpoint created successfully"}',
85
+ '{"type":"info","data":" ID: v1"}',
86
+ '{"type":"info","data":" Path: checkpoints/v1"}',
87
+ '{"type":"complete","data":"Checkpoint v1 created successfully"}',
88
+ ].join("\n");
89
+ expect(parseCheckpointNdjson(body)).toEqual({ checkpointId: "v1" });
90
+ });
91
+
92
+ test("honors a structured id from the older {event, id} shape", () => {
93
+ const body = '{"event":"info","message":"checkpointing"}\n{"event":"complete","id":"v3"}\n';
94
+ expect(parseCheckpointNdjson(body)).toEqual({ checkpointId: "v3" });
95
+ });
96
+
97
+ test("blank and unparseable lines are skipped; no id → empty", () => {
98
+ expect(parseCheckpointNdjson('\n{"event":"info"}\nnot-json\n')).toEqual({ checkpointId: "" });
99
+ });
100
+ });
101
+
102
+ // ── Comment picker (newest match) ─────────────────────────────────────────────
103
+
104
+ describe("pickCheckpointByComment", () => {
105
+ const list: Checkpoint[] = [
106
+ { id: "v1", comment: "pre-run", create_time: "2026-01-01T00:00:00.000Z", is_auto: false },
107
+ { id: "v2", comment: "other", create_time: "2026-01-01T00:00:01.000Z", is_auto: false },
108
+ { id: "v3", comment: "pre-run", create_time: "2026-01-01T00:00:02.000Z", is_auto: false },
109
+ ];
110
+
111
+ test("returns the newest checkpoint carrying the comment", () => {
112
+ expect(pickCheckpointByComment(list, "pre-run")?.id).toBe("v3");
113
+ });
114
+
115
+ test("returns undefined when nothing matches", () => {
116
+ expect(pickCheckpointByComment(list, "nope")).toBeUndefined();
117
+ });
118
+ });
119
+
120
+ // ── Endpoint resolution (S3) ──────────────────────────────────────────────────
121
+
122
+ describe("resolveSpritesEndpoint (S3)", () => {
123
+ test("explicit arg wins and trailing slash is stripped", () => {
124
+ expect(resolveSpritesEndpoint({ endpoint: "http://localhost:9000/" }, {} as NodeJS.ProcessEnv)).toBe(
125
+ "http://localhost:9000",
126
+ );
127
+ });
128
+
129
+ test("SPRITES_BASE_URL env when no arg", () => {
130
+ expect(resolveSpritesEndpoint({}, { SPRITES_BASE_URL: "http://fake:9000" } as NodeJS.ProcessEnv)).toBe(
131
+ "http://fake:9000",
132
+ );
133
+ });
134
+
135
+ test("arg beats env", () => {
136
+ expect(
137
+ resolveSpritesEndpoint({ endpoint: "http://arg:1" }, { SPRITES_BASE_URL: "http://env:2" } as NodeJS.ProcessEnv),
138
+ ).toBe("http://arg:1");
139
+ });
140
+
141
+ test("default is real Sprites", () => {
142
+ expect(resolveSpritesEndpoint({}, {} as NodeJS.ProcessEnv)).toBe(DEFAULT_SPRITES_BASE_URL);
143
+ });
144
+ });
145
+
146
+ // ── Command tokenizing + exec WS url ──────────────────────────────────────────
147
+
148
+ describe("splitCommand", () => {
149
+ test("splits on whitespace and respects quotes", () => {
150
+ expect(splitCommand("echo hi")).toEqual(["echo", "hi"]);
151
+ expect(splitCommand('sh -c "exit 7"')).toEqual(["sh", "-c", "exit 7"]);
152
+ expect(splitCommand("echo bad > /state; false")).toEqual(["echo", "bad", ">", "/state;", "false"]);
153
+ });
154
+ });
155
+
156
+ describe("spriteExecWsUrl", () => {
157
+ test("http → ws with cmd/path/stdin/cc params", () => {
158
+ const url = new URL(spriteExecWsUrl("http://x:9000", "task-1", "echo hi"));
159
+ expect(url.protocol).toBe("ws:");
160
+ expect(url.pathname).toBe("/v1/sprites/task-1/exec");
161
+ expect(url.searchParams.getAll("cmd")).toEqual(["echo", "hi"]);
162
+ expect(url.searchParams.get("path")).toBe("echo");
163
+ expect(url.searchParams.get("stdin")).toBe("false");
164
+ expect(url.searchParams.get("cc")).toBe("true");
165
+ });
166
+
167
+ test("https → wss", () => {
168
+ expect(spriteExecWsUrl("https://api.sprites.dev", "s", "ls").startsWith("wss://api.sprites.dev/")).toBe(true);
169
+ });
170
+ });
171
+
172
+ // ── Activity request shapes (injected SpritesHttp; no real sockets) ───────────
173
+
174
+ describe("spriteCreate", () => {
175
+ test("POSTs /v1/sprites with the name and parses { id, url }", async () => {
176
+ const { http, calls } = recorder(() => ({
177
+ status: 201,
178
+ text: JSON.stringify({ id: "sprite-1", url: "http://h/s/task-1" }),
179
+ }));
180
+ const res = await spriteCreate({ name: "task-1", endpoint: "http://x:9000", image: "base:1" }, undefined, http);
181
+ expect(res).toEqual({ id: "sprite-1", url: "http://h/s/task-1" });
182
+ expect(calls[0].method).toBe("POST");
183
+ expect(calls[0].url).toBe("http://x:9000/v1/sprites");
184
+ expect(calls[0].body).toEqual({ name: "task-1", image: "base:1" });
185
+ });
186
+ });
187
+
188
+ describe("spriteCheckpoint", () => {
189
+ test("POSTs the singular /checkpoint with the comment and reads the NDJSON complete id", async () => {
190
+ const { http, calls } = recorder(() => ({
191
+ status: 200,
192
+ text: '{"event":"info"}\n{"event":"complete","id":"v5"}\n',
193
+ }));
194
+ const res = await spriteCheckpoint({ id: "task-1", comment: "pre-run", endpoint: "http://x" }, undefined, http);
195
+ expect(res).toEqual({ checkpointId: "v5" });
196
+ expect(calls[0].url).toBe("http://x/v1/sprites/task-1/checkpoint");
197
+ expect(calls[0].body).toEqual({ comment: "pre-run" });
198
+ });
199
+
200
+ test("omits the comment key when empty", async () => {
201
+ const { http, calls } = recorder(() => ({ status: 200, text: '{"event":"complete","id":"v1"}\n' }));
202
+ await spriteCheckpoint({ id: "task-1", endpoint: "http://x" }, undefined, http);
203
+ expect(calls[0].body).toBeUndefined();
204
+ });
205
+ });
206
+
207
+ describe("listCheckpoints", () => {
208
+ test("GETs the plural /checkpoints and returns the array", async () => {
209
+ const list = [{ id: "v1", comment: "pre-run", create_time: "2026-01-01T00:00:00.000Z", is_auto: false }];
210
+ const { http, calls } = recorder(() => ({ status: 200, text: JSON.stringify(list) }));
211
+ const res = await listCheckpoints({ id: "task-1", endpoint: "http://x" }, undefined, http);
212
+ expect(res).toEqual(list);
213
+ expect(calls[0].method).toBe("GET");
214
+ expect(calls[0].url).toBe("http://x/v1/sprites/task-1/checkpoints");
215
+ });
216
+ });
217
+
218
+ describe("spriteRestore", () => {
219
+ test("explicit checkpoint id → POST /checkpoints/{id}/restore", async () => {
220
+ const { http, calls } = recorder(() => ({ status: 200, text: '{"event":"complete","id":"v2"}\n' }));
221
+ const res = await spriteRestore({ id: "task-1", checkpoint: "v2", endpoint: "http://x" }, undefined, http);
222
+ expect(res).toEqual({});
223
+ expect(calls).toHaveLength(1);
224
+ expect(calls[0].method).toBe("POST");
225
+ expect(calls[0].url).toBe("http://x/v1/sprites/task-1/checkpoints/v2/restore");
226
+ });
227
+
228
+ test("comment → list, pick newest match, then restore that id", async () => {
229
+ const list: Checkpoint[] = [
230
+ { id: "v1", comment: "pre-run", create_time: "2026-01-01T00:00:00.000Z", is_auto: false },
231
+ { id: "v4", comment: "pre-run", create_time: "2026-01-01T00:00:09.000Z", is_auto: false },
232
+ ];
233
+ const { http, calls } = recorder((method) =>
234
+ method === "GET"
235
+ ? { status: 200, text: JSON.stringify(list) }
236
+ : { status: 200, text: '{"event":"complete","id":"v4"}\n' },
237
+ );
238
+ await spriteRestore({ id: "task-1", comment: "pre-run", endpoint: "http://x" }, undefined, http);
239
+ expect(calls[0].method).toBe("GET");
240
+ expect(calls[0].url).toBe("http://x/v1/sprites/task-1/checkpoints");
241
+ expect(calls[1].url).toBe("http://x/v1/sprites/task-1/checkpoints/v4/restore");
242
+ });
243
+
244
+ test("throws when no checkpoint carries the comment", async () => {
245
+ const { http } = recorder(() => ({ status: 200, text: "[]" }));
246
+ await expect(
247
+ spriteRestore({ id: "task-1", comment: "nope", endpoint: "http://x" }, undefined, http),
248
+ ).rejects.toThrow(/no checkpoint matching comment "nope"/);
249
+ });
250
+ });
251
+
252
+ describe("spriteDestroy", () => {
253
+ test("DELETEs /v1/sprites/{id}", async () => {
254
+ const { http, calls } = recorder(() => ({ status: 200, text: "{}" }));
255
+ await spriteDestroy({ id: "task-1", endpoint: "http://x" }, undefined, http);
256
+ expect(calls[0].method).toBe("DELETE");
257
+ expect(calls[0].url).toBe("http://x/v1/sprites/task-1");
258
+ });
259
+
260
+ test("a 404 is idempotent (already gone), not an error", async () => {
261
+ const { http } = recorder(() => ({ status: 404, text: "gone" }));
262
+ await expect(spriteDestroy({ id: "task-1", endpoint: "http://x" }, undefined, http)).resolves.toEqual({});
263
+ });
264
+ });
265
+
266
+ // ── Bearer header on the default client ───────────────────────────────────────
267
+
268
+ describe("defaultSpritesHttp bearer header", () => {
269
+ test("sends Authorization: Bearer <token> when a token is set", async () => {
270
+ const seen: Array<Record<string, string> | undefined> = [];
271
+ const fakeFetch = (async (_url: string, init: { headers?: Record<string, string> }) => {
272
+ seen.push(init.headers);
273
+ return { status: 200, text: async () => "{}" } as unknown as Response;
274
+ }) as unknown as typeof fetch;
275
+ const http = defaultSpritesHttp("secret-token", fakeFetch);
276
+ await http("GET", "http://x/v1/sprites/task-1");
277
+ expect(seen[0]?.authorization).toBe("Bearer secret-token");
278
+ });
279
+
280
+ test("no Authorization header when no token is set", async () => {
281
+ const seen: Array<Record<string, string> | undefined> = [];
282
+ const fakeFetch = (async (_url: string, init: { headers?: Record<string, string> }) => {
283
+ seen.push(init?.headers);
284
+ return { status: 200, text: async () => "{}" } as unknown as Response;
285
+ }) as unknown as typeof fetch;
286
+ const prev = process.env.SPRITES_API_TOKEN;
287
+ delete process.env.SPRITES_API_TOKEN;
288
+ try {
289
+ const http = defaultSpritesHttp(undefined, fakeFetch);
290
+ await http("GET", "http://x/v1/sprites/task-1");
291
+ expect(seen[0]?.authorization).toBeUndefined();
292
+ } finally {
293
+ if (prev !== undefined) process.env.SPRITES_API_TOKEN = prev;
294
+ }
295
+ });
296
+ });