@intentius/chant-lexicon-fly 0.16.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.
- package/README.md +2 -2
- package/dist/composites/fly-deploy.d.ts +1 -1
- package/dist/composites/fly-deploy.d.ts.map +1 -1
- package/dist/coverage.d.ts +15 -0
- package/dist/coverage.d.ts.map +1 -0
- package/dist/emulator-freshness-cli.d.ts +11 -0
- package/dist/emulator-freshness-cli.d.ts.map +1 -0
- package/dist/emulator-freshness.d.ts +38 -0
- package/dist/emulator-freshness.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/mcp/context-tools.d.ts +15 -0
- package/dist/mcp/context-tools.d.ts.map +1 -0
- package/dist/op/activities/emulator-images.d.ts +20 -0
- package/dist/op/activities/emulator-images.d.ts.map +1 -0
- package/dist/op/activities/flaps.d.ts +1 -1
- package/dist/op/activities/flaps.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +4 -0
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/machines-contract.d.ts +36 -0
- package/dist/op/activities/machines-contract.d.ts.map +1 -0
- package/dist/op/activities/sprites-contract.d.ts +45 -0
- package/dist/op/activities/sprites-contract.d.ts.map +1 -0
- package/dist/op/activities/sprites-emulator.d.ts +29 -0
- package/dist/op/activities/sprites-emulator.d.ts.map +1 -0
- package/dist/op/activities/sprites-fake.d.ts +62 -0
- package/dist/op/activities/sprites-fake.d.ts.map +1 -0
- package/dist/op/activities/sprites.d.ts +195 -0
- package/dist/op/activities/sprites.d.ts.map +1 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/reference-catalog.d.ts +21 -0
- package/dist/reference-catalog.d.ts.map +1 -0
- package/package.json +6 -2
- package/src/composites/fly-deploy.ts +1 -1
- package/src/coverage.ts +49 -0
- package/src/emulator-freshness-cli.ts +49 -0
- package/src/emulator-freshness.test.ts +86 -0
- package/src/emulator-freshness.ts +87 -0
- package/src/index.ts +15 -0
- package/src/mcp/context-tools.test.ts +27 -0
- package/src/mcp/context-tools.ts +120 -0
- package/src/op/activities/emulator-images.ts +21 -0
- package/src/op/activities/flaps.test.ts +2 -1
- package/src/op/activities/flaps.ts +3 -2
- package/src/op/activities/index.ts +53 -0
- package/src/op/activities/machines-contract.docker.integration.test.ts +72 -0
- package/src/op/activities/machines-contract.test.ts +49 -0
- package/src/op/activities/machines-contract.ts +73 -0
- package/src/op/activities/sprites-contract.docker.integration.test.ts +74 -0
- package/src/op/activities/sprites-contract.test.ts +60 -0
- package/src/op/activities/sprites-contract.ts +61 -0
- package/src/op/activities/sprites-emulator.ts +46 -0
- package/src/op/activities/sprites-fake.ts +314 -0
- package/src/op/activities/sprites.docker.integration.test.ts +99 -0
- package/src/op/activities/sprites.integration.test.ts +158 -0
- package/src/op/activities/sprites.real.test.ts +56 -0
- package/src/op/activities/sprites.test.ts +296 -0
- package/src/op/activities/sprites.ts +527 -0
- package/src/plugin.ts +28 -5
- package/src/reference-catalog.test.ts +50 -0
- package/src/reference-catalog.ts +39 -0
- package/src/skills/chant-fly-patterns.md +2 -2
- package/src/skills/chant-fly-sprites.md +104 -0
- package/src/skills/chant-fly.md +2 -2
- package/src/generated/.gitkeep +0 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll } from "vitest";
|
|
2
|
+
import { flapsUp, flapsDown } from "./flaps";
|
|
3
|
+
import { MACHINES_CONTRACT, normalizeEndpoint, contractKeys } from "./machines-contract";
|
|
4
|
+
|
|
5
|
+
// Fidelity check: every flaps endpoint the flyApply applier depends on
|
|
6
|
+
// (MACHINES_CONTRACT) must be served by the pinned mudflaps image — the twin of
|
|
7
|
+
// the Sprites contract ⊆ spritzer check (#808 T3). mudflaps enumerates its
|
|
8
|
+
// implemented paths at `/_mudflaps/health` and answers roadmap endpoints
|
|
9
|
+
// (machines/{id}/signal, /exec, /ps) with 501; flyApply must never depend on one.
|
|
10
|
+
// Docker required; skipped in CI unless FLY_DOCKER=1 (GitHub runners have Docker,
|
|
11
|
+
// so relying on absence would pull the image on every run).
|
|
12
|
+
|
|
13
|
+
const CONTAINER = "chant-mudflaps-contract-it";
|
|
14
|
+
const PORT = 4283;
|
|
15
|
+
|
|
16
|
+
let available = false;
|
|
17
|
+
let endpoint = "";
|
|
18
|
+
|
|
19
|
+
/** Parse a mudflaps health `implemented` entry ("METHOD path (note)") to a normalized key. */
|
|
20
|
+
function normalizeImplemented(entry: string): string {
|
|
21
|
+
const stripped = entry.replace(/\s*\(.*\)\s*$/, "").trim();
|
|
22
|
+
const sp = stripped.indexOf(" ");
|
|
23
|
+
return normalizeEndpoint(stripped.slice(0, sp), stripped.slice(sp + 1));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
beforeAll(async () => {
|
|
27
|
+
if (process.env.CI && !process.env.FLY_DOCKER) {
|
|
28
|
+
available = false;
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
const up = await flapsUp({ name: CONTAINER, port: PORT, timeoutMs: 30_000 });
|
|
33
|
+
endpoint = up.endpoint;
|
|
34
|
+
available = true;
|
|
35
|
+
} catch {
|
|
36
|
+
available = false;
|
|
37
|
+
}
|
|
38
|
+
}, 60_000);
|
|
39
|
+
|
|
40
|
+
afterAll(async () => {
|
|
41
|
+
if (available) await flapsDown({ name: CONTAINER });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("Machines contract ⊆ mudflaps implemented paths", () => {
|
|
45
|
+
test("the pinned mudflaps serves every endpoint flyApply depends on", async (ctx) => {
|
|
46
|
+
if (!available) ctx.skip();
|
|
47
|
+
|
|
48
|
+
const res = await fetch(`${endpoint}/_mudflaps/health`);
|
|
49
|
+
expect(res.ok).toBe(true);
|
|
50
|
+
const health = (await res.json()) as { implemented?: string[] };
|
|
51
|
+
expect(Array.isArray(health.implemented)).toBe(true);
|
|
52
|
+
|
|
53
|
+
const served = new Set((health.implemented ?? []).map(normalizeImplemented));
|
|
54
|
+
|
|
55
|
+
const missing = [...contractKeys()].filter((k) => !served.has(k));
|
|
56
|
+
// A non-empty list means flyApply calls something mudflaps can't serve — a
|
|
57
|
+
// real fidelity gap between the applier and the pinned emulator.
|
|
58
|
+
expect(missing, `mudflaps is missing contract endpoints: ${missing.join(", ")}`).toEqual([]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("flyApply never depends on a mudflaps roadmap (501) endpoint", async (ctx) => {
|
|
62
|
+
if (!available) ctx.skip();
|
|
63
|
+
const res = await fetch(`${endpoint}/_mudflaps/health`);
|
|
64
|
+
const health = (await res.json()) as { unimplemented?: string[] };
|
|
65
|
+
const roadmap = new Set((health.unimplemented ?? []).map(normalizeImplemented));
|
|
66
|
+
|
|
67
|
+
for (const e of MACHINES_CONTRACT) {
|
|
68
|
+
const key = normalizeEndpoint(e.method, e.path);
|
|
69
|
+
expect(roadmap.has(key), `${e.op} → ${e.method} ${e.path} is a mudflaps roadmap endpoint`).toBe(false);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { MACHINES_CONTRACT, normalizeEndpoint, contractKeys } from "./machines-contract";
|
|
6
|
+
|
|
7
|
+
describe("MACHINES_CONTRACT", () => {
|
|
8
|
+
test("covers the flyApply resource operations (apps, machines, leases, volumes, ips, certs, secrets)", () => {
|
|
9
|
+
for (const area of ["/machines", "/lease", "/volumes", "/ip_assignments", "/certificates", "/secrets"]) {
|
|
10
|
+
expect(MACHINES_CONTRACT.some((e) => e.path.includes(area)), area).toBe(true);
|
|
11
|
+
}
|
|
12
|
+
expect(MACHINES_CONTRACT.some((e) => e.path === "/v1/apps"), "apps").toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("every entry has a v1/apps path and a known method", () => {
|
|
16
|
+
for (const e of MACHINES_CONTRACT) {
|
|
17
|
+
expect(e.path.startsWith("/v1/apps")).toBe(true);
|
|
18
|
+
expect(["GET", "POST", "PUT", "DELETE"]).toContain(e.method);
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("normalizeEndpoint collapses param names ({id} ≡ {vol} ≡ {hostname})", () => {
|
|
23
|
+
expect(normalizeEndpoint("DELETE", "/v1/apps/{app}/volumes/{id}")).toBe(
|
|
24
|
+
normalizeEndpoint("DELETE", "/v1/apps/{app}/volumes/{vol}"),
|
|
25
|
+
);
|
|
26
|
+
expect(normalizeEndpoint("GET", "/v1/apps/{app}")).toBe("GET /v1/apps/{}");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("contractKeys is the deduped normalized set", () => {
|
|
30
|
+
const keys = contractKeys();
|
|
31
|
+
expect(keys.has("POST /v1/apps")).toBe(true);
|
|
32
|
+
expect(keys.has("DELETE /v1/apps/{}/machines/{}")).toBe(true);
|
|
33
|
+
// lease acquire (POST) and release (DELETE) are distinct keys
|
|
34
|
+
expect(keys.has("POST /v1/apps/{}/machines/{}/lease")).toBe(true);
|
|
35
|
+
expect(keys.has("DELETE /v1/apps/{}/machines/{}/lease")).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("every contract path segment appears in the fly-apply.ts source (drift anchor)", () => {
|
|
39
|
+
const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "fly-apply.ts"), "utf-8");
|
|
40
|
+
const segments = new Set(
|
|
41
|
+
MACHINES_CONTRACT.flatMap((e) =>
|
|
42
|
+
e.path.split("/").filter((s) => s.length > 0 && !s.startsWith("{")),
|
|
43
|
+
),
|
|
44
|
+
);
|
|
45
|
+
for (const seg of segments) {
|
|
46
|
+
expect(src, `path segment "${seg}" from the contract is absent from fly-apply.ts`).toContain(seg);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fly Machines (flaps) API contract — the endpoint set the `flyApply` applier
|
|
3
|
+
* (./fly-apply.ts) depends on, and the mudflaps counterpart of the hand-authored
|
|
4
|
+
* Sprites contract (./sprites-contract.ts, #808 T3).
|
|
5
|
+
*
|
|
6
|
+
* Unlike Sprites, Machines *does* ship a machine-readable OpenAPI that the fly
|
|
7
|
+
* resource surface drift-checks against (docs.machines.dev, the rolling-upgrade
|
|
8
|
+
* path #813). This contract serves a different fidelity axis: it pins the exact
|
|
9
|
+
* endpoints flyApply calls so the docker-gated coverage test can prove the pinned
|
|
10
|
+
* mudflaps emulator serves them all. mudflaps carries roadmap endpoints that
|
|
11
|
+
* answer 501 (currently machines/{id}/signal, /exec, /ps) — flyApply must never
|
|
12
|
+
* depend on one; if it ever does, the coverage test fails instead of the applier
|
|
13
|
+
* silently passing against a fake that can't model the call.
|
|
14
|
+
*
|
|
15
|
+
* Param names match ./fly-apply.ts's URL builders (`{app}`, `{id}`); the coverage
|
|
16
|
+
* check normalizes param names before comparing, since mudflaps spells volume
|
|
17
|
+
* ids `{vol}` and cert hostnames `{hostname}`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** One flaps endpoint the flyApply applier calls. */
|
|
21
|
+
export interface MachinesEndpoint {
|
|
22
|
+
method: "GET" | "POST" | "PUT" | "DELETE";
|
|
23
|
+
/** Path template under the flaps base, e.g. `/v1/apps/{app}/machines/{id}`. */
|
|
24
|
+
path: string;
|
|
25
|
+
/** The applier operation that calls it. */
|
|
26
|
+
op: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The flaps endpoints ./fly-apply.ts depends on. Keep in sync with the applier —
|
|
31
|
+
* the unit test asserts every path segment appears in fly-apply.ts.
|
|
32
|
+
*/
|
|
33
|
+
export const MACHINES_CONTRACT: readonly MachinesEndpoint[] = [
|
|
34
|
+
// Apps
|
|
35
|
+
{ method: "POST", path: "/v1/apps", op: "createApp" },
|
|
36
|
+
{ method: "GET", path: "/v1/apps/{app}", op: "getApp" },
|
|
37
|
+
{ method: "DELETE", path: "/v1/apps/{app}", op: "deleteApp" },
|
|
38
|
+
// Machines
|
|
39
|
+
{ method: "GET", path: "/v1/apps/{app}/machines", op: "listMachines" },
|
|
40
|
+
{ method: "POST", path: "/v1/apps/{app}/machines", op: "createMachine" },
|
|
41
|
+
{ method: "POST", path: "/v1/apps/{app}/machines/{id}", op: "updateMachine" },
|
|
42
|
+
{ method: "DELETE", path: "/v1/apps/{app}/machines/{id}", op: "destroyMachine" },
|
|
43
|
+
{ method: "GET", path: "/v1/apps/{app}/machines/{id}/wait", op: "waitForMachine" },
|
|
44
|
+
// Leases
|
|
45
|
+
{ method: "POST", path: "/v1/apps/{app}/machines/{id}/lease", op: "acquireLease" },
|
|
46
|
+
{ method: "DELETE", path: "/v1/apps/{app}/machines/{id}/lease", op: "releaseLease" },
|
|
47
|
+
// Volumes
|
|
48
|
+
{ method: "GET", path: "/v1/apps/{app}/volumes", op: "listVolumes" },
|
|
49
|
+
{ method: "POST", path: "/v1/apps/{app}/volumes", op: "createVolume" },
|
|
50
|
+
{ method: "DELETE", path: "/v1/apps/{app}/volumes/{id}", op: "deleteVolume" },
|
|
51
|
+
// IP assignments
|
|
52
|
+
{ method: "GET", path: "/v1/apps/{app}/ip_assignments", op: "listIps" },
|
|
53
|
+
{ method: "POST", path: "/v1/apps/{app}/ip_assignments", op: "allocateIp" },
|
|
54
|
+
{ method: "DELETE", path: "/v1/apps/{app}/ip_assignments/{ip}", op: "releaseIp" },
|
|
55
|
+
// Certificates
|
|
56
|
+
{ method: "GET", path: "/v1/apps/{app}/certificates", op: "listCerts" },
|
|
57
|
+
{ method: "POST", path: "/v1/apps/{app}/certificates", op: "addCert" },
|
|
58
|
+
{ method: "DELETE", path: "/v1/apps/{app}/certificates/{hostname}", op: "deleteCert" },
|
|
59
|
+
// Secrets
|
|
60
|
+
{ method: "GET", path: "/v1/apps/{app}/secrets", op: "listSecrets" },
|
|
61
|
+
{ method: "POST", path: "/v1/apps/{app}/secrets/{name}", op: "setSecret" },
|
|
62
|
+
{ method: "DELETE", path: "/v1/apps/{app}/secrets/{name}", op: "deleteSecret" },
|
|
63
|
+
] as const;
|
|
64
|
+
|
|
65
|
+
/** Normalize a `METHOD path` key: collapse every `{param}` to `{}` so param names match. */
|
|
66
|
+
export function normalizeEndpoint(method: string, path: string): string {
|
|
67
|
+
return `${method.toUpperCase()} ${path.replace(/\{[^}]+\}/g, "{}")}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The contract as a set of normalized `METHOD path` keys. */
|
|
71
|
+
export function contractKeys(): Set<string> {
|
|
72
|
+
return new Set(MACHINES_CONTRACT.map((e) => normalizeEndpoint(e.method, e.path)));
|
|
73
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll } from "vitest";
|
|
2
|
+
import { spritesUp, spritesDown } from "./sprites-emulator";
|
|
3
|
+
import { SPRITES_CONTRACT, normalizeEndpoint, contractKeys } from "./sprites-contract";
|
|
4
|
+
|
|
5
|
+
// Fidelity check (#808 T3): every endpoint the fly sprite activities depend on
|
|
6
|
+
// (SPRITES_CONTRACT) must be served by the pinned spritzer image. Sprites has no
|
|
7
|
+
// OpenAPI to diff, so spritzer's `/_spritzer/health` implemented-paths list is
|
|
8
|
+
// the machine-readable oracle. If an activity ever calls something the emulator
|
|
9
|
+
// doesn't model, this fails instead of the tests silently passing against a
|
|
10
|
+
// partial fake. Docker required; skipped in CI unless SPRITES_DOCKER=1 (GitHub
|
|
11
|
+
// runners have Docker, so relying on absence would pull the image every run).
|
|
12
|
+
|
|
13
|
+
const CONTAINER = "chant-spritzer-contract-it";
|
|
14
|
+
const PORT = 4293;
|
|
15
|
+
|
|
16
|
+
let available = false;
|
|
17
|
+
let endpoint = "";
|
|
18
|
+
|
|
19
|
+
/** Parse a spritzer health `implemented` entry ("METHOD path (note)") to a normalized key. */
|
|
20
|
+
function normalizeImplemented(entry: string): string {
|
|
21
|
+
const stripped = entry.replace(/\s*\(.*\)\s*$/, "").trim();
|
|
22
|
+
const sp = stripped.indexOf(" ");
|
|
23
|
+
const method = stripped.slice(0, sp);
|
|
24
|
+
const path = stripped.slice(sp + 1);
|
|
25
|
+
return normalizeEndpoint(method, path);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
beforeAll(async () => {
|
|
29
|
+
if (process.env.CI && !process.env.SPRITES_DOCKER) {
|
|
30
|
+
available = false;
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const up = await spritesUp({ name: CONTAINER, port: PORT, timeoutMs: 30_000 });
|
|
35
|
+
endpoint = up.endpoint;
|
|
36
|
+
available = true;
|
|
37
|
+
} catch {
|
|
38
|
+
available = false;
|
|
39
|
+
}
|
|
40
|
+
}, 60_000);
|
|
41
|
+
|
|
42
|
+
afterAll(async () => {
|
|
43
|
+
if (available) await spritesDown({ name: CONTAINER });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("Sprites contract ⊆ spritzer implemented paths (#808 T3)", () => {
|
|
47
|
+
test("the pinned spritzer serves every endpoint the fly activities depend on", async (ctx) => {
|
|
48
|
+
if (!available) ctx.skip();
|
|
49
|
+
|
|
50
|
+
const res = await fetch(`${endpoint}/_spritzer/health`);
|
|
51
|
+
expect(res.ok).toBe(true);
|
|
52
|
+
const health = (await res.json()) as { implemented?: string[] };
|
|
53
|
+
expect(Array.isArray(health.implemented)).toBe(true);
|
|
54
|
+
|
|
55
|
+
const served = new Set((health.implemented ?? []).map(normalizeImplemented));
|
|
56
|
+
|
|
57
|
+
const missing = [...contractKeys()].filter((k) => !served.has(k));
|
|
58
|
+
// A non-empty list means an activity calls something spritzer can't serve —
|
|
59
|
+
// a real fidelity gap between the contract and the pinned emulator.
|
|
60
|
+
expect(missing, `spritzer is missing contract endpoints: ${missing.join(", ")}`).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("each contract endpoint maps to a served path (per-activity report)", async (ctx) => {
|
|
64
|
+
if (!available) ctx.skip();
|
|
65
|
+
const res = await fetch(`${endpoint}/_spritzer/health`);
|
|
66
|
+
const health = (await res.json()) as { implemented?: string[] };
|
|
67
|
+
const served = new Set((health.implemented ?? []).map(normalizeImplemented));
|
|
68
|
+
|
|
69
|
+
for (const e of SPRITES_CONTRACT) {
|
|
70
|
+
const key = normalizeEndpoint(e.method, e.path);
|
|
71
|
+
expect(served.has(key), `${e.activity} → ${e.method} ${e.path} not served by spritzer`).toBe(true);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { SPRITES_CONTRACT, normalizeEndpoint, contractKeys } from "./sprites-contract";
|
|
6
|
+
|
|
7
|
+
describe("SPRITES_CONTRACT", () => {
|
|
8
|
+
test("covers every sprite activity that calls the Sprites API", () => {
|
|
9
|
+
const activities = new Set(SPRITES_CONTRACT.map((e) => e.activity));
|
|
10
|
+
// The six ./sprites.ts activities that make an HTTP/WS call.
|
|
11
|
+
expect(activities).toEqual(
|
|
12
|
+
new Set([
|
|
13
|
+
"spriteCreate",
|
|
14
|
+
"spriteExec",
|
|
15
|
+
"spriteCheckpoint",
|
|
16
|
+
"listCheckpoints",
|
|
17
|
+
"spriteRestore",
|
|
18
|
+
"spriteDestroy",
|
|
19
|
+
]),
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("every entry has a v1 path and a known method", () => {
|
|
24
|
+
for (const e of SPRITES_CONTRACT) {
|
|
25
|
+
expect(e.path.startsWith("/v1/sprites")).toBe(true);
|
|
26
|
+
expect(["GET", "POST", "DELETE", "WS"]).toContain(e.method);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("normalizeEndpoint collapses param names and maps WS→GET", () => {
|
|
31
|
+
// {cp} and {cid} must compare equal (spritzer spells it {cid}).
|
|
32
|
+
expect(normalizeEndpoint("POST", "/v1/sprites/{id}/checkpoints/{cp}/restore")).toBe(
|
|
33
|
+
normalizeEndpoint("POST", "/v1/sprites/{id}/checkpoints/{cid}/restore"),
|
|
34
|
+
);
|
|
35
|
+
// The exec WebSocket is registered as a GET on the emulator.
|
|
36
|
+
expect(normalizeEndpoint("WS", "/v1/sprites/{id}/exec")).toBe("GET /v1/sprites/{}/exec");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("contractKeys is the deduped normalized set", () => {
|
|
40
|
+
const keys = contractKeys();
|
|
41
|
+
expect(keys.has("POST /v1/sprites")).toBe(true);
|
|
42
|
+
expect(keys.has("DELETE /v1/sprites/{}")).toBe(true);
|
|
43
|
+
expect(keys.size).toBe(SPRITES_CONTRACT.length);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("every contract path segment appears in the sprites.ts source (drift anchor)", () => {
|
|
47
|
+
// Anchors the hand-authored contract to the activity implementations: if an
|
|
48
|
+
// activity's endpoint path changes, a segment goes missing here and the
|
|
49
|
+
// contract must be updated in the same change.
|
|
50
|
+
const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "sprites.ts"), "utf-8");
|
|
51
|
+
const segments = new Set(
|
|
52
|
+
SPRITES_CONTRACT.flatMap((e) =>
|
|
53
|
+
e.path.split("/").filter((s) => s.length > 0 && !s.startsWith("{")),
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
for (const seg of segments) {
|
|
57
|
+
expect(src, `path segment "${seg}" from the contract is absent from sprites.ts`).toContain(seg);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hand-authored Fly Sprites API contract (#808 T3).
|
|
3
|
+
*
|
|
4
|
+
* The Fly Machines API ships a machine-readable OpenAPI (docs.machines.dev) that
|
|
5
|
+
* the fly resource surface generates + drift-checks against. The Sprites API
|
|
6
|
+
* (api.sprites.dev) ships no such spec at any conventional path, so there is
|
|
7
|
+
* nothing to diff automatically. This module is the manual stand-in: the exact
|
|
8
|
+
* endpoint set the fly sprite activities (./sprites.ts) depend on, maintained by
|
|
9
|
+
* hand from https://docs.sprites.dev.
|
|
10
|
+
*
|
|
11
|
+
* It anchors two fidelity checks:
|
|
12
|
+
* 1. Coverage — every endpoint here must be served by the pinned spritzer
|
|
13
|
+
* emulator (its `/_spritzer/health` enumerates implemented paths). If an
|
|
14
|
+
* activity ever calls something spritzer doesn't model, the docker-gated
|
|
15
|
+
* contract test fails instead of silently passing against a partial fake.
|
|
16
|
+
* 2. Drift anchor — when the Sprites API changes, a human updates this file;
|
|
17
|
+
* reviewers see exactly which activity/endpoint moved.
|
|
18
|
+
*
|
|
19
|
+
* Path params are written with names matching ./sprites.ts (`{id}`, `{cp}`);
|
|
20
|
+
* the coverage check normalizes param names before comparing, since spritzer
|
|
21
|
+
* spells the checkpoint id `{cid}`.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** One endpoint the fly sprite activities call. */
|
|
25
|
+
export interface SpritesEndpoint {
|
|
26
|
+
/** HTTP method, or "WS" for the control-WebSocket exec channel. */
|
|
27
|
+
method: "GET" | "POST" | "DELETE" | "WS";
|
|
28
|
+
/** Path template under the Sprites base, e.g. `/v1/sprites/{id}/checkpoint`. */
|
|
29
|
+
path: string;
|
|
30
|
+
/** The fly activity that calls it (./sprites.ts export). */
|
|
31
|
+
activity: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The Sprites endpoints ./sprites.ts depends on. Keep in sync with the activity
|
|
36
|
+
* implementations — the unit test asserts every sprite activity is represented.
|
|
37
|
+
*/
|
|
38
|
+
export const SPRITES_CONTRACT: readonly SpritesEndpoint[] = [
|
|
39
|
+
{ method: "POST", path: "/v1/sprites", activity: "spriteCreate" },
|
|
40
|
+
{ method: "WS", path: "/v1/sprites/{id}/exec", activity: "spriteExec" },
|
|
41
|
+
{ method: "POST", path: "/v1/sprites/{id}/checkpoint", activity: "spriteCheckpoint" },
|
|
42
|
+
{ method: "GET", path: "/v1/sprites/{id}/checkpoints", activity: "listCheckpoints" },
|
|
43
|
+
{ method: "POST", path: "/v1/sprites/{id}/checkpoints/{cp}/restore", activity: "spriteRestore" },
|
|
44
|
+
{ method: "DELETE", path: "/v1/sprites/{id}", activity: "spriteDestroy" },
|
|
45
|
+
] as const;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Normalize a `METHOD path` key for comparison across sources: collapse every
|
|
49
|
+
* `{param}` to `{}` (so `{cp}` and `{cid}` match) and treat the WebSocket exec
|
|
50
|
+
* channel as a `GET` (spritzer registers it as `GET .../exec`).
|
|
51
|
+
*/
|
|
52
|
+
export function normalizeEndpoint(method: string, path: string): string {
|
|
53
|
+
const m = method === "WS" ? "GET" : method.toUpperCase();
|
|
54
|
+
const p = path.replace(/\{[^}]+\}/g, "{}");
|
|
55
|
+
return `${m} ${p}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The contract as a set of normalized `METHOD path` keys. */
|
|
59
|
+
export function contractKeys(): Set<string> {
|
|
60
|
+
return new Set(SPRITES_CONTRACT.map((e) => normalizeEndpoint(e.method, e.path)));
|
|
61
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { emulatorLifecycle } from "@intentius/chant/op";
|
|
2
|
+
import { SPRITZER_IMAGE } from "./emulator-images";
|
|
3
|
+
|
|
4
|
+
export interface SpritesUpArgs {
|
|
5
|
+
/** Container name. Default: `chant-spritzer`. */
|
|
6
|
+
name?: string;
|
|
7
|
+
/** Host port mapped to the emulator's `:4290`. Default: `4290`. */
|
|
8
|
+
port?: number;
|
|
9
|
+
/** Image. Default: the pinned spritzer image ({@link SPRITZER_IMAGE}). */
|
|
10
|
+
image?: string;
|
|
11
|
+
/** Readiness timeout in ms. Default: `60000`. */
|
|
12
|
+
timeoutMs?: number;
|
|
13
|
+
/** Health poll interval in ms. Default: `2000`. */
|
|
14
|
+
intervalMs?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SpritesDownArgs {
|
|
18
|
+
/** Container name to remove. Default: `chant-spritzer`. */
|
|
19
|
+
name?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// spritzer is a stateful fake of the Fly Sprites API — a plain 200 on its health
|
|
23
|
+
// endpoint means ready. The local target for the sprite activities; point them
|
|
24
|
+
// there with SPRITES_BASE_URL. Shared lifecycle: emulatorLifecycle (the same
|
|
25
|
+
// helper that boots mudflaps for fly).
|
|
26
|
+
const spritzer = emulatorLifecycle({
|
|
27
|
+
name: "chant-spritzer",
|
|
28
|
+
image: SPRITZER_IMAGE,
|
|
29
|
+
containerPort: 4290,
|
|
30
|
+
healthPath: "/_spritzer/health",
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export const spritesExistsCommand = spritzer.existsCommand;
|
|
34
|
+
export const spritesRmCommand = spritzer.rmCommand;
|
|
35
|
+
export const spritesHealthUrl = spritzer.healthUrl;
|
|
36
|
+
/** The Sprites endpoint URL (what the sprite activities' `SPRITES_BASE_URL`/`endpoint` points at). */
|
|
37
|
+
export const spritesEndpoint = spritzer.endpoint;
|
|
38
|
+
export const spritesRunCommand = (args: SpritesUpArgs = {}): string => spritzer.runCommand(args);
|
|
39
|
+
|
|
40
|
+
/** Boot a local spritzer (Fly Sprites API emulator) in Docker and return its endpoint. */
|
|
41
|
+
export const spritesUp = (args: SpritesUpArgs = {}, signal?: AbortSignal): Promise<{ endpoint: string }> =>
|
|
42
|
+
spritzer.up(args, signal);
|
|
43
|
+
|
|
44
|
+
/** Stop and remove the local spritzer container (no-op if already gone). */
|
|
45
|
+
export const spritesDown = (args: SpritesDownArgs = {}, signal?: AbortSignal): Promise<void> =>
|
|
46
|
+
spritzer.down(args, signal);
|