@vincentt-xr/cli 0.7.0 → 0.8.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.
@@ -0,0 +1,202 @@
1
+ // QA-F5-C3 · B36's narrowed boundary — zero `/library` requests, and the deny
2
+ // predicate is pinned.
3
+ //
4
+ // THIS IS THE CLAIM f5 IS JUDGED ON. B36: a project builds, previews and
5
+ // publishes with the library unreachable, and no platform capability works
6
+ // better because an asset came from us. The creator loses a catalogue, not a
7
+ // toolchain.
8
+ //
9
+ // dev's b32.test.ts owns layers 1 and 4 (the static import check and the
10
+ // predicate's four URLs) and says so. THIS FILE OWNS LAYERS 2 AND 3 — the live
11
+ // arms — plus the predicate's behavioral half.
12
+ //
13
+ // ⚠ THE NEGATIVE ASSERTION IS THE CASE. A publish that succeeds while the
14
+ // library is unreachable is ALSO what you observe if the call was attempted,
15
+ // timed out and was swallowed by a try/catch — which would make f5 a dependency
16
+ // WITH A TIMEOUT, silently adding seconds to every publish. Only "zero /library
17
+ // requests recorded" distinguishes NOT A DEPENDENCY from A DEPENDENCY THAT
18
+ // FAILS SOFT.
19
+ //
20
+ // ⚠ AND IT ONLY MEANS THAT BECAUSE THE API IS LIVE. Under a host-level deny
21
+ // everything fails, and the fixture cannot tell "publish never called f5" from
22
+ // "publish failed because nothing was reachable"
23
+ // (D-B36-fixture-denies-a-path-prefix-not-a-host).
24
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
25
+ import { httpLibraryClient, LibraryUnreachableError } from "./client.js";
26
+ function denyPath(prefix, host) {
27
+ const attempts = [];
28
+ const denied = [];
29
+ const realFetch = globalThis.fetch;
30
+ // TWO SEPARATE CONDITIONS on the PARSED url. Written this way deliberately —
31
+ // see the header, and layer 4 below, which is the case that fails when
32
+ // somebody "simplifies" it into `raw.includes(prefix)`.
33
+ const predicate = (u) => u.host === host && u.pathname.startsWith(prefix);
34
+ globalThis.fetch = (async (input, init) => {
35
+ const raw = typeof input === "string"
36
+ ? input
37
+ : input instanceof URL
38
+ ? input.href
39
+ : input.url;
40
+ const u = new URL(raw);
41
+ const attempt = { host: u.host, pathname: u.pathname };
42
+ attempts.push(attempt);
43
+ if (predicate(u)) {
44
+ denied.push(attempt);
45
+ // A refused connection, exactly as an unreachable host produces. The CLI
46
+ // must map this to LibraryUnreachableError → exit 4, never to an answer.
47
+ throw new TypeError("fetch failed");
48
+ }
49
+ return realFetch(input, init);
50
+ });
51
+ return {
52
+ attempts,
53
+ denied,
54
+ predicate,
55
+ restore: () => {
56
+ globalThis.fetch = realFetch;
57
+ },
58
+ };
59
+ }
60
+ // ---------------------------------------------------------------------------
61
+ // the live stack
62
+ // ---------------------------------------------------------------------------
63
+ //
64
+ // Inherited from dev, never built here. The endpoint comes from the environment
65
+ // so a port change is a config change rather than an edit to this file — the
66
+ // ports are DERIVED from the task slug, not chosen.
67
+ const API_URL = process.env.QA_F5_API_URL ?? "http://localhost:8088";
68
+ const API_HOST = new URL(API_URL).host;
69
+ let apiLive = false;
70
+ beforeEach(async () => {
71
+ if (apiLive)
72
+ return;
73
+ try {
74
+ const res = await fetch(`${API_URL}/health`, { signal: AbortSignal.timeout(2000) });
75
+ apiLive = res.ok;
76
+ }
77
+ catch {
78
+ apiLive = false;
79
+ }
80
+ });
81
+ let fixture;
82
+ afterEach(() => {
83
+ fixture?.restore();
84
+ fixture = undefined;
85
+ });
86
+ describe("QA-F5-C3 · B36 layer 4 — the deny predicate is /library-on-the-API-host only", () => {
87
+ it("(c) drives the predicate with four URLs, including the anti-drift pin", () => {
88
+ const f = denyPath("/library", "api.dev.vincentt.studio");
89
+ fixture = f;
90
+ const denied = f.predicate;
91
+ // 1. the f5 route on the API host — DENIED.
92
+ expect(denied(new URL("https://api.dev.vincentt.studio/library/assets"))).toBe(true);
93
+ expect(denied(new URL("https://api.dev.vincentt.studio/library/assets:batch"))).toBe(true);
94
+ // 2. every OTHER route on the SAME host — ALLOWED. f5 has no host of its
95
+ // own, so a host deny takes publish down with it.
96
+ expect(denied(new URL("https://api.dev.vincentt.studio/publishes"))).toBe(false);
97
+ expect(denied(new URL("https://api.dev.vincentt.studio/projects"))).toBe(false);
98
+ // 3. THE ANTI-DRIFT PIN. The assets CDN serves library/v1/, so its paths
99
+ // contain "/library" too. A predicate written as url.includes("/library")
100
+ // denies the CDN and asserts a property f5 NO LONGER HAS
101
+ // (D-add-prints-urls). This url is what fails when someone "simplifies"
102
+ // the predicate.
103
+ expect(denied(new URL("https://assets.dev.vincentt.studio/library/v1/props/x.png")), "the assets CDN was DENIED. Under D-add-prints-urls it is on every published viewer's " +
104
+ "critical path; denying it asserts f5-independence of a published experience, which is " +
105
+ "explicitly NOT a claim f5 makes any more.").toBe(false);
106
+ // 4. the CDN at any other path — ALLOWED.
107
+ expect(denied(new URL("https://assets.dev.vincentt.studio/anything"))).toBe(false);
108
+ // The weaker form, demonstrated failing, so it is not silently reintroduced.
109
+ const substring = (raw) => raw.includes("/library");
110
+ expect(substring("https://assets.dev.vincentt.studio/library/v1/props/x.png")).toBe(true);
111
+ expect(denied(new URL("https://assets.dev.vincentt.studio/library/v1/props/x.png"))).toBe(false);
112
+ });
113
+ });
114
+ // The live arms need a running API. They are opt-in, mirroring the Go 4E tier's
115
+ // QA_F5_RUN_SERVE_TIER: absent a stack they report BLOCKED rather than failing,
116
+ // because a red CI check for "no stack here" trains people to ignore the check.
117
+ // They must NEVER silently pass: B36's claim only means something against a
118
+ // REACHABLE route, or the fixture cannot tell "publish never called f5" from
119
+ // "publish failed because nothing was reachable".
120
+ const RUN_LIVE = process.env.QA_F5_RUN_LIVE === "1";
121
+ describe.skipIf(!RUN_LIVE)("QA-F5-C3 · B36 layers 2 and 3 — against the LIVE api", () => {
122
+ it("(b) an f5 call under the deny fixture is REFUSED and maps to exit 4, not to an answer", async () => {
123
+ if (!apiLive) {
124
+ throw new Error(`the api at ${API_URL} is not reachable, so QA-F5-C3's live arms cannot run.\n\n` +
125
+ `This case is BLOCKED, not passed. B36's assertion only means what it claims when ` +
126
+ `the API is LIVE: a stray f5 call must be a real request to a REACHABLE route, or ` +
127
+ `the fixture cannot tell "publish never called f5" from "publish failed because ` +
128
+ `nothing was reachable".\n\n` +
129
+ `Boot it: dev-stack.sh up (then re-run; override with QA_F5_API_URL)`);
130
+ }
131
+ const f = denyPath("/library", API_HOST);
132
+ fixture = f;
133
+ const client = httpLibraryClient({ apiUrl: API_URL, pat: "vct_qa_f5_not_a_real_token" });
134
+ let threw;
135
+ try {
136
+ await client.search({ q: "pastel pumpkin" });
137
+ }
138
+ catch (err) {
139
+ threw = err;
140
+ }
141
+ expect(threw, "the library search RESOLVED under a deny fixture. A denied request must surface as a " +
142
+ "transport failure, never as an answer.").toBeInstanceOf(LibraryUnreachableError);
143
+ // The attempt WAS made and WAS recorded — a fixture that recorded nothing
144
+ // would make every "zero attempts" assertion below vacuous.
145
+ expect(f.denied.length, "the deny fixture recorded ZERO denials. A fixture that never fires cannot prove an " +
146
+ "absence, and every assertion built on it passes for free.").toBeGreaterThan(0);
147
+ expect(f.denied[0].pathname.startsWith("/library")).toBe(true);
148
+ expect(f.denied[0].host).toBe(API_HOST);
149
+ });
150
+ it("(a) the NON-/library API surface is unaffected — publish's origin still answers", async () => {
151
+ if (!apiLive) {
152
+ throw new Error(`the api at ${API_URL} is not reachable; QA-F5-C3 is BLOCKED, not passed`);
153
+ }
154
+ const f = denyPath("/library", API_HOST);
155
+ fixture = f;
156
+ // The publish path's own origin, under the SAME fixture. This is what makes
157
+ // layer 2 STRONGER than a host deny: the chain runs against a LIVE, WORKING
158
+ // API, so a stray f5 call would be a real request to a reachable route
159
+ // rather than one failure among many.
160
+ const health = await fetch(`${API_URL}/health`);
161
+ expect(health.ok, "the API's own /health failed under the deny fixture. The fixture is denying more than " +
162
+ "the /library path prefix — probably the HOST — which takes publish down with it and " +
163
+ "makes the whole case fail for a reason unrelated to B36.").toBe(true);
164
+ const readyz = await fetch(`${API_URL}/readyz`);
165
+ expect([200, 503]).toContain(readyz.status);
166
+ // THE ASSERTION THAT ACTUALLY TESTS B36: across every non-f5 request made
167
+ // above, ZERO have a path beginning with /library.
168
+ const strays = f.attempts.filter((a) => a.host === API_HOST && a.pathname.startsWith("/library"));
169
+ expect(strays, `the non-f5 surface issued ${strays.length} request(s) under /library: ` +
170
+ `${JSON.stringify(strays)}.\n\n` +
171
+ `B36 says the library is not a dependency of the toolchain's core loop. A green ` +
172
+ `publish alone is ALSO what you observe if the call was attempted, timed out and was ` +
173
+ `swallowed — which would make f5 a dependency WITH A TIMEOUT, silently adding seconds ` +
174
+ `to every publish. Only the empty recorded list distinguishes the two.`).toEqual([]);
175
+ });
176
+ it("the f5 route EXISTS and is reachable when the fixture is NOT installed — the control", async () => {
177
+ if (!apiLive) {
178
+ throw new Error(`the api at ${API_URL} is not reachable; QA-F5-C3 is BLOCKED, not passed`);
179
+ }
180
+ // WITHOUT the fixture. If /library did not exist on this build, every
181
+ // "zero /library requests" assertion above would pass against a router that
182
+ // simply has no such route — certifying the boundary while proving nothing.
183
+ const res = await fetch(`${API_URL}/library/assets?q=pumpkin`);
184
+ expect(res.status, `GET /library/assets returned ${res.status}. A 404 means the route is NOT MOUNTED on ` +
185
+ `this build — which makes B36's negative assertion vacuous. The surface is gated on ` +
186
+ `LIBRARY_ASSETS_BASE_URL; unset, /library never mounts.`).not.toBe(404);
187
+ // 401 is the expected answer to an unauthenticated read: the route exists
188
+ // and refuses anonymous callers.
189
+ expect([200, 401, 403, 503]).toContain(res.status);
190
+ });
191
+ });
192
+ describe("QA-F5-C3 · live tier status", () => {
193
+ it("reports whether B36's live arms ran", () => {
194
+ if (!RUN_LIVE) {
195
+ console.warn("\nQA-F5-C3 live arms: BLOCKED (not passed, not failed).\n" +
196
+ " B36 layers 2 and 3 need a running API. Layer 4 (the deny predicate and the\n" +
197
+ " anti-drift pin) DID run and is green.\n" +
198
+ " To run them: dev-stack.sh up then QA_F5_RUN_LIVE=1 npm test\n");
199
+ }
200
+ expect(true).toBe(true);
201
+ });
202
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,433 @@
1
+ // QA f5 · 4C — the CLI surface. QA-F5-C1 and QA-F5-C2.
2
+ //
3
+ // This is the tier a BLIND CONSUMER's control flow depends on. Two properties of
4
+ // this file are load-bearing:
5
+ //
6
+ // 1. QA-F5-C1 asserts that exit 1 and exit 4 DIFFER, not merely that each has
7
+ // the right value. "We answered and something is missing" and "we did not
8
+ // answer" have different correct recoveries (re-search vs retry later), and
9
+ // a build that collapses them passes every per-code assertion.
10
+ //
11
+ // 2. QA-F5-C2(a) is a CONTROL, not advice. The prose in AGENTS.md is advice;
12
+ // iteration 2 shipped exactly that guardrail prose and it is the same class
13
+ // as the alphaMap hallucination it cites. Under print-by-default the agent
14
+ // handles URLs as strings BY DESIGN, so the only thing standing between a
15
+ // hallucinated URL and a fetch is a parser that refuses one.
16
+ //
17
+ // Written from features/f5-asset-library/qa.md, not from the implementation.
18
+ import { promises as fs } from "node:fs";
19
+ import os from "node:os";
20
+ import path from "node:path";
21
+ import { afterEach, describe, expect, it } from "vitest";
22
+ import { assetsAdd, assetsSearch, assetsShow } from "./assetsCommand.js";
23
+ import { LibraryError, LibraryUnreachableError } from "./client.js";
24
+ import { looksLikeUrl, parseIdArgs } from "./parse.js";
25
+ // ---------------------------------------------------------------------------
26
+ // fixtures — VALID base32-lower ids
27
+ // ---------------------------------------------------------------------------
28
+ //
29
+ // ⚠ The record's example ids (lib_7f3a2c67d4e5b6a7, lib_2b7e6d4a4c7f2537) are
30
+ // 16-char HEX and contain 0/1/8/9 — characters ABSENT from base32-lower. They do
31
+ // not satisfy the record's own ^lib_[a-z2-7]{16}$, which the API enforces at
32
+ // three places. Fixtures here use ids the SERVER would actually mint, so a
33
+ // resolution that works here works against a real API.
34
+ const PUMPKIN = {
35
+ id: "lib_75eokepbmoh4g6ol",
36
+ url: "https://assets.vincentt.studio/library/v1/props/pastel-pumpkin-75eokepbmoh4g6ol.png",
37
+ kind: "prop",
38
+ aspect: "1:1",
39
+ width: 1024,
40
+ height: 1024,
41
+ has_alpha: true,
42
+ bytes: 184320,
43
+ format: "png",
44
+ subject: "pumpkin",
45
+ description: "A soft pastel-orange pumpkin with a cream stem.",
46
+ usage_hint: "Place as a standalone prop.",
47
+ };
48
+ const TRIO = {
49
+ ...PUMPKIN,
50
+ id: "lib_2b7e6dqa4c5fzs37",
51
+ url: "https://assets.vincentt.studio/library/v1/props/pumpkin-trio-2b7e6dqa4c5fzs37.png",
52
+ subject: "pumpkin trio",
53
+ };
54
+ const LANTERN = {
55
+ ...PUMPKIN,
56
+ id: "lib_lanternqqqqqqqq",
57
+ url: "https://assets.vincentt.studio/library/v1/props/lantern-lanternqqqqqqqq.png",
58
+ subject: "paper lantern",
59
+ };
60
+ const FRAME = {
61
+ ...PUMPKIN,
62
+ id: "lib_frameaqqqqqqqqq",
63
+ url: "https://assets.vincentt.studio/library/v1/frames/gold-frameaqqqqqqqqq.png",
64
+ kind: "frame",
65
+ aspect: "9:16",
66
+ width: 720,
67
+ height: 1280,
68
+ subject: "gold frame border",
69
+ };
70
+ const GAPS = [
71
+ { tag: "wearable", message: "the library has no wearable or face-filter assets yet" },
72
+ { tag: "face-filter", message: "the library has no wearable or face-filter assets yet" },
73
+ ];
74
+ const tempDirs = [];
75
+ async function tempCwd() {
76
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "qa-f5-"));
77
+ tempDirs.push(dir);
78
+ return dir;
79
+ }
80
+ afterEach(async () => {
81
+ while (tempDirs.length > 0) {
82
+ const dir = tempDirs.pop();
83
+ if (dir)
84
+ await fs.rm(dir, { recursive: true, force: true });
85
+ }
86
+ });
87
+ function harness(client, cwd = "/tmp/qa-f5-nonexistent") {
88
+ const stdout = [];
89
+ const stderr = [];
90
+ const calls = [];
91
+ const downloaded = [];
92
+ const full = {
93
+ search: async (p) => {
94
+ calls.push("search");
95
+ if (client.search)
96
+ return client.search(p);
97
+ throw new Error("search not stubbed");
98
+ },
99
+ fetchOne: async (id) => {
100
+ calls.push("fetchOne");
101
+ if (client.fetchOne)
102
+ return client.fetchOne(id);
103
+ throw new Error("fetchOne not stubbed");
104
+ },
105
+ batch: async (ids) => {
106
+ calls.push(`batch:${ids.join(",")}`);
107
+ if (client.batch)
108
+ return client.batch(ids);
109
+ throw new Error("batch not stubbed");
110
+ },
111
+ download: async (url) => {
112
+ calls.push("download");
113
+ downloaded.push(url);
114
+ if (client.download)
115
+ return client.download(url);
116
+ return new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
117
+ },
118
+ };
119
+ return {
120
+ deps: { out: (l) => stdout.push(l), err: (l) => stderr.push(l), client: full, cwd },
121
+ stdout,
122
+ stderr,
123
+ calls,
124
+ downloaded,
125
+ cwd,
126
+ };
127
+ }
128
+ const search = (res) => ({
129
+ resultCount: res.results?.length ?? 0,
130
+ results: [],
131
+ gaps: [],
132
+ ...res,
133
+ });
134
+ const batch = (res) => ({
135
+ resolved: [],
136
+ unknown: [],
137
+ ...res,
138
+ });
139
+ // ---------------------------------------------------------------------------
140
+ // QA-F5-C1 · The exit-code contract, across all five outcomes
141
+ // ---------------------------------------------------------------------------
142
+ describe("QA-F5-C1 · the exit-code contract", () => {
143
+ it("(a) search WITH results exits 0", async () => {
144
+ const h = harness({ search: async () => search({ results: [PUMPKIN], resultCount: 1 }) });
145
+ expect(await assetsSearch(h.deps, { query: "pastel pumpkin", json: false })).toBe(0);
146
+ });
147
+ it("(b) search with ZERO results exits 0, carries NO glyph, and --json is valid", async () => {
148
+ const h = harness({ search: async () => search({ resultCount: 0, gaps: GAPS }) });
149
+ const code = await assetsSearch(h.deps, { query: "christmas face filter", json: false });
150
+ // THE SINGLE MOST IMPORTANT EXIT CODE IN f5. A non-zero exit here teaches an
151
+ // agent that an empty library is a TOOL FAILURE — and an agent's move on a
152
+ // tool failure is to retry or route around, where "route around" means
153
+ // inventing a URL or shipping a box geometry. That is the exact behavior the
154
+ // feature exists to prevent.
155
+ expect(code).toBe(0);
156
+ const screen = h.stderr.join("\n");
157
+ for (const glyph of ["✗", "⚠", "Error", "error:", "failed"]) {
158
+ expect(screen.includes(glyph), `the zero-result screen contains ${JSON.stringify(glyph)}. A well-formed question with ` +
159
+ `no matching inventory is a CORRECT ANSWER; styling it as an error is what teaches ` +
160
+ `the agent to treat an honest empty as a failure:\n${screen}`).toBe(false);
161
+ }
162
+ // The gap message is VERBATIM from the corpus — the CLI reports an inventory
163
+ // claim, it does not compose one.
164
+ expect(screen).toContain(GAPS[0].message);
165
+ // --json: stdout is a SINGLE valid JSON document, and stderr carries nothing
166
+ // that could contaminate it.
167
+ const j = harness({ search: async () => search({ resultCount: 0, gaps: GAPS }) });
168
+ expect(await assetsSearch(j.deps, { query: "christmas face filter", json: true })).toBe(0);
169
+ expect(j.stdout).toHaveLength(1);
170
+ const doc = JSON.parse(j.stdout[0]);
171
+ expect(doc.resultCount).toBe(0);
172
+ expect(doc.results).toEqual([]);
173
+ expect(doc.gaps.length, "`gaps` is empty under --json on a zero-result search. The keys must ALWAYS be " +
174
+ "present and populated here: a key that appears only sometimes is a key nobody handles.").toBeGreaterThan(0);
175
+ expect(doc.gaps[0]).toHaveProperty("tag");
176
+ expect(doc.gaps[0]).toHaveProperty("message");
177
+ });
178
+ it("(c) a PARTIAL add exits 1, prints the good URLs FIRST and in full, and says nothing was written", async () => {
179
+ const h = harness({
180
+ batch: async () => batch({ resolved: [PUMPKIN, TRIO, LANTERN, FRAME], unknown: ["lib_ffffffffffffffff"] }),
181
+ });
182
+ const code = await assetsAdd(h.deps, {
183
+ ids: [PUMPKIN.id, TRIO.id, LANTERN.id, FRAME.id, "lib_ffffffffffffffff"],
184
+ json: false,
185
+ download: false,
186
+ force: false,
187
+ });
188
+ expect(code).toBe(1);
189
+ const screen = h.stderr.join("\n");
190
+ // The four good URLs print FIRST and IN FULL.
191
+ for (const a of [PUMPKIN, TRIO, LANTERN, FRAME]) {
192
+ expect(screen, `the URL for ${a.id} is missing from the add screen`).toContain(a.url);
193
+ }
194
+ const firstUrlAt = screen.indexOf(PUMPKIN.url);
195
+ const failureAt = screen.indexOf("✗");
196
+ expect(failureAt, "the failure block is not last. An agent skimming the screen must see its four usable " +
197
+ "URLs before the thing that went wrong, or it discards the whole turn.").toBeGreaterThan(firstUrlAt);
198
+ // The header is a DIFFERENT SHAPE, not just a different number — a shape
199
+ // survives a skim and a number does not.
200
+ expect(screen).toContain("4 of 5 assets");
201
+ // The bad id is echoed VERBATIM so an agent can diff against what it sent.
202
+ expect(screen).toContain("lib_ffffffffffffffff");
203
+ // "Nothing was written." is present — `add` is a verb that SOUNDS like it
204
+ // writes files, and this is the honest statement of "the platform informs,
205
+ // never performs" (B35).
206
+ expect(screen).toContain("Nothing was written.");
207
+ });
208
+ it("(d) a TRANSPORT failure exits 4, with an empty stdout and the B36 paragraph on stderr", async () => {
209
+ const h = harness({
210
+ search: async () => {
211
+ throw new LibraryUnreachableError(new Error("ECONNREFUSED"));
212
+ },
213
+ });
214
+ const code = await assetsSearch(h.deps, { query: "pastel pumpkin", json: false });
215
+ expect(code).toBe(4);
216
+ expect(h.stdout, "stdout is not empty on a failure. Under --json an agent pipes stdout into a parser; " +
217
+ "any human line there corrupts the document.").toEqual([]);
218
+ const screen = h.stderr.join("\n").toLowerCase();
219
+ expect(screen.includes("publish") || screen.includes("preview") || screen.includes("build"), "the unreachable screen does not carry the B36 paragraph. A creator whose library call " +
220
+ "just failed needs to know that build, preview and publish are unaffected — otherwise " +
221
+ "an f5 outage reads as a toolchain outage:\n" + h.stderr.join("\n")).toBe(true);
222
+ });
223
+ it("(e) a 503 library_unavailable exits 4 and is NEVER rendered as a zero-result answer", async () => {
224
+ const h = harness({
225
+ search: async () => {
226
+ throw new LibraryError("library search failed (503)", 503, "library_unavailable");
227
+ },
228
+ });
229
+ const code = await assetsSearch(h.deps, { query: "pastel pumpkin", json: false });
230
+ expect(code).toBe(4);
231
+ expect(h.stdout).toEqual([]);
232
+ const screen = h.stderr.join("\n");
233
+ expect(screen.includes("No assets match"), "a 503 rendered as the ZERO-RESULT screen. \"we could not look\" and \"there is nothing " +
234
+ "there\" must never collapse: the agent's recovery from an honest empty (stop asking, " +
235
+ "build from props) is the WRONG move when the truth is that the corpus failed to load.").toBe(false);
236
+ });
237
+ it("the pair that must never collapse: (c) is 1 and (d)/(e) are 4, and they DIFFER", async () => {
238
+ const partial = harness({
239
+ batch: async () => batch({ resolved: [PUMPKIN], unknown: ["lib_ffffffffffffffff"] }),
240
+ });
241
+ const partialCode = await assetsAdd(partial.deps, {
242
+ ids: [PUMPKIN.id, "lib_ffffffffffffffff"],
243
+ json: false,
244
+ download: false,
245
+ force: false,
246
+ });
247
+ const down = harness({
248
+ batch: async () => {
249
+ throw new LibraryUnreachableError();
250
+ },
251
+ });
252
+ const downCode = await assetsAdd(down.deps, {
253
+ ids: [PUMPKIN.id, TRIO.id],
254
+ json: false,
255
+ download: false,
256
+ force: false,
257
+ });
258
+ expect(partialCode).toBe(1);
259
+ expect(downCode).toBe(4);
260
+ expect(partialCode, "the partial-resolution and did-not-answer exit codes are EQUAL. They must differ: " +
261
+ "\"we answered and something is missing\" is re-search, \"we did not answer\" is retry " +
262
+ "later, and an agent that cannot tell them apart takes the wrong recovery every time.").not.toBe(downCode);
263
+ });
264
+ });
265
+ // ---------------------------------------------------------------------------
266
+ // QA-F5-C2 · Ids and only ids, batching at 2+, and writing nothing
267
+ // ---------------------------------------------------------------------------
268
+ describe("QA-F5-C2 · the CLI accepts ids and only ids, and writes nothing", () => {
269
+ it("(a) a URL is an arg-parse error with ZERO network calls — the CONTROL", async () => {
270
+ const h = harness({});
271
+ const code = await assetsAdd(h.deps, {
272
+ ids: ["https://assets.vincentt.studio/library/v1/props/x.png"],
273
+ json: false,
274
+ download: false,
275
+ force: false,
276
+ });
277
+ expect(code).not.toBe(0);
278
+ expect(h.calls, "a caller-supplied URL reached the network layer. THIS IS THE CONTROL, and the prose in " +
279
+ "AGENTS.md is advice: under print-by-default the agent handles URLs as strings BY " +
280
+ "DESIGN, so a parser that refuses one is the only thing standing between a " +
281
+ "hallucinated URL and a fetch.").toEqual([]);
282
+ expect(h.stderr.join("\n")).toContain("ids, not URLs");
283
+ // There is NO --url flag anywhere in the accepted set. Asserted over the
284
+ // parser's own predicate rather than over a help string, because the help
285
+ // string is not what admits an argument.
286
+ //
287
+ // The set below is the catalog's scope: SCHEME-FUL and SCHEME-RELATIVE forms,
288
+ // i.e. the shapes that are unmistakably a URL and that a fetch would follow.
289
+ for (const u of [
290
+ "https://assets.vincentt.studio/library/v1/props/x.png",
291
+ "http://evil.test/x.png",
292
+ "//assets.vincentt.studio/x.png",
293
+ "file:///etc/passwd",
294
+ "ftp://evil.test/x.png",
295
+ ]) {
296
+ expect(looksLikeUrl(u), `${u} is not recognized as a URL by the parser`).toBe(true);
297
+ expect(parseIdArgs([u]).ok, `${u} was accepted as an id`).toBe(false);
298
+ }
299
+ // THE STRONGER FORM OF THE SAME CONTROL, and the one that actually closes the
300
+ // hole: whatever the parser admits, the only string that ever reaches a fetch
301
+ // is a SERVER-RETURNED record.url.
302
+ //
303
+ // A `data:` or bare-path argument is NOT refused by looksLikeUrl (it has no
304
+ // `//`), and that is acceptable precisely because of this: it goes to the
305
+ // server as an id, comes back in unknown[], and never reaches download(). The
306
+ // refusal above is the cheap first line; this is the property.
307
+ const smuggleCwd = await tempCwd();
308
+ const smuggle = harness({ batch: async () => batch({ resolved: [PUMPKIN], unknown: ["data:image/png;base64,AAAA"] }) }, smuggleCwd);
309
+ await assetsAdd(smuggle.deps, {
310
+ ids: [PUMPKIN.id, "data:image/png;base64,AAAA"],
311
+ json: false,
312
+ download: true,
313
+ force: false,
314
+ });
315
+ for (const url of smuggle.downloaded) {
316
+ expect(url, `download() was called with ${url}, which is not a url the SERVER returned. Every ` +
317
+ `byte fetched by --download must come from a record the API resolved; a ` +
318
+ `caller-supplied string reaching a fetch is the hallucinated-URL failure this ` +
319
+ `control exists to prevent.`).toBe(PUMPKIN.url);
320
+ }
321
+ });
322
+ it("(b) a MALFORMED id goes to unknown[] via the SERVER, not a client-side 400", async () => {
323
+ const h = harness({
324
+ batch: async () => batch({ resolved: [PUMPKIN], unknown: ["pastel-pumpkin.png"] }),
325
+ });
326
+ const code = await assetsAdd(h.deps, {
327
+ ids: [PUMPKIN.id, "pastel-pumpkin.png"],
328
+ json: false,
329
+ download: false,
330
+ force: false,
331
+ });
332
+ expect(code).toBe(1);
333
+ // The id REACHED the wire. A merely malformed id is NOT a shape the CLI must
334
+ // refuse to transmit: refusing it client-side would destroy its siblings,
335
+ // which D-multi-asset-is-mandatory forbids.
336
+ expect(h.calls.some((c) => c.startsWith("batch:") && c.includes("pastel-pumpkin.png")), `the malformed id never reached the server. calls=${JSON.stringify(h.calls)}`).toBe(true);
337
+ // The teaching line prints — advice that AGREES with a control, at the moment
338
+ // the agent is actually making the mistake.
339
+ expect(h.stderr.join("\n")).toContain("Ids look like lib_");
340
+ });
341
+ it("(c) three ids issue EXACTLY ONE batch call, never three fetches", async () => {
342
+ const h = harness({ batch: async () => batch({ resolved: [PUMPKIN, TRIO, LANTERN] }) });
343
+ await assetsAdd(h.deps, {
344
+ ids: [PUMPKIN.id, TRIO.id, LANTERN.id],
345
+ json: false,
346
+ download: false,
347
+ force: false,
348
+ });
349
+ const batchCalls = h.calls.filter((c) => c.startsWith("batch:"));
350
+ const fetchCalls = h.calls.filter((c) => c === "fetchOne");
351
+ expect(batchCalls.length, `expected ONE batch call, got calls=${JSON.stringify(h.calls)}. N sequential fetches is ` +
352
+ `the behavior D-multi-asset-is-mandatory exists to eliminate — and the limiter's ` +
353
+ `ceil(n/2) discount only rewards batching if the CLI actually batches.`).toBe(1);
354
+ expect(fetchCalls).toHaveLength(0);
355
+ });
356
+ it("(d) duplicates go out AS GIVEN and are de-duplicated for DISPLAY only", async () => {
357
+ const h = harness({ batch: async () => batch({ resolved: [PUMPKIN, PUMPKIN, TRIO] }) });
358
+ await assetsAdd(h.deps, {
359
+ ids: [PUMPKIN.id, PUMPKIN.id, TRIO.id],
360
+ json: false,
361
+ download: false,
362
+ force: false,
363
+ });
364
+ const call = h.calls.find((c) => c.startsWith("batch:"));
365
+ expect(call, "no batch call was recorded").toBeDefined();
366
+ expect(call, "the duplicate was stripped BEFORE the wire. The server charges PRE-DEDUP, so trimming " +
367
+ "here hides the cost from the operator — and it is the CLI deciding what the caller " +
368
+ "pays.").toBe(`batch:${PUMPKIN.id},${PUMPKIN.id},${TRIO.id}`);
369
+ // Displayed once.
370
+ const urlCount = h.stderr.filter((l) => l.includes(PUMPKIN.url)).length;
371
+ expect(urlCount).toBe(1);
372
+ });
373
+ it("(e) without --download, ZERO files are written anywhere under the cwd", async () => {
374
+ const cwd = await tempCwd();
375
+ const before = await fs.readdir(cwd);
376
+ const h = harness({ batch: async () => batch({ resolved: [PUMPKIN, TRIO] }) }, cwd);
377
+ const code = await assetsAdd(h.deps, {
378
+ ids: [PUMPKIN.id, TRIO.id],
379
+ json: false,
380
+ download: false,
381
+ force: false,
382
+ });
383
+ expect(code).toBe(0);
384
+ const after = await fs.readdir(cwd);
385
+ expect(after, "`add` wrote to the creator's project without --download. B35: the platform INFORMS, " +
386
+ "never PERFORMS — no code, no generation, no writes into creator source.").toEqual(before);
387
+ expect(h.downloaded, "download() was called without --download").toEqual([]);
388
+ expect(h.stderr.join("\n")).toContain("Nothing was written.");
389
+ });
390
+ it("(f) --download writes once, names the path, then REFUSES with the B37 reason", async () => {
391
+ const cwd = await tempCwd();
392
+ const first = harness({ batch: async () => batch({ resolved: [PUMPKIN, TRIO] }) }, cwd);
393
+ const firstCode = await assetsAdd(first.deps, {
394
+ ids: [PUMPKIN.id, TRIO.id],
395
+ json: false,
396
+ download: true,
397
+ force: false,
398
+ });
399
+ expect(firstCode).toBe(0);
400
+ // Every downloaded url is one the SERVER returned — never a string the
401
+ // caller typed.
402
+ expect(first.downloaded.sort()).toEqual([PUMPKIN.url, TRIO.url].sort());
403
+ // The written paths are ANNOUNCED: a state-changing command names its side
404
+ // effects on the creator's disk.
405
+ expect(first.stderr.join("\n")).toContain("✓ Wrote");
406
+ const second = harness({ batch: async () => batch({ resolved: [PUMPKIN, TRIO] }) }, cwd);
407
+ const secondCode = await assetsAdd(second.deps, {
408
+ ids: [PUMPKIN.id, TRIO.id],
409
+ json: false,
410
+ download: true,
411
+ force: false,
412
+ });
413
+ expect(secondCode).toBe(1);
414
+ const screen = second.stderr.join("\n");
415
+ // It names the B37 REASON, not "file exists". The library never changes a
416
+ // file at a URL, so a re-download can only ever produce the same bytes —
417
+ // which is why the refusal is about YOUR edits, not about our bytes.
418
+ expect(/never changes|already have|your (own )?(copy|edits|file)/i.test(screen), "the overwrite refusal does not name the B37 reason. \"file exists\" tells a creator to " +
419
+ "pass --force; the real reason is that the library never changes a file at a URL, so " +
420
+ "the only thing an overwrite can destroy is the creator's OWN edit:\n" + screen).toBe(true);
421
+ });
422
+ it("(g) `show` with 2+ ids issues ONE batch call, same as add", async () => {
423
+ const h = harness({ batch: async () => batch({ resolved: [PUMPKIN, TRIO] }) });
424
+ await assetsShow(h.deps, { ids: [PUMPKIN.id, TRIO.id], json: false });
425
+ expect(h.calls.filter((c) => c.startsWith("batch:"))).toHaveLength(1);
426
+ expect(h.calls.filter((c) => c === "fetchOne")).toHaveLength(0);
427
+ });
428
+ it("a SINGLE id uses the fetch route — batching at 2+ is the rule, not at 1", async () => {
429
+ const h = harness({ fetchOne: async () => PUMPKIN });
430
+ await assetsShow(h.deps, { ids: [PUMPKIN.id], json: false });
431
+ expect(h.calls).toEqual(["fetchOne"]);
432
+ });
433
+ });
@@ -0,0 +1 @@
1
+ export {};