@vincentt-xr/cli 0.7.0 → 0.9.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/dist/cli/args.d.ts +18 -1
- package/dist/cli/args.js +40 -1
- package/dist/cli/index.js +208 -10
- package/dist/dx/command.d.ts +59 -2
- package/dist/dx/command.js +150 -11
- package/dist/dx/enablement.d.ts +51 -0
- package/dist/dx/enablement.js +97 -0
- package/dist/dx/qa.f7.fixtures.d.ts +73 -0
- package/dist/dx/qa.f7.fixtures.js +58 -0
- package/dist/dx/skeleton.js +1 -0
- package/dist/lib/initCommand.d.ts +28 -1
- package/dist/lib/initCommand.js +91 -16
- package/dist/lib/initFrom.d.ts +48 -0
- package/dist/lib/initFrom.js +198 -0
- package/dist/lib/sourceRef.d.ts +46 -0
- package/dist/lib/sourceRef.js +107 -0
- package/dist/lib/sourceRefCopy.d.ts +11 -0
- package/dist/lib/sourceRefCopy.js +80 -0
- package/dist/lib/version.d.ts +20 -1
- package/dist/lib/version.js +41 -5
- package/dist/library/assetsCommand.d.ts +32 -0
- package/dist/library/assetsCommand.js +264 -0
- package/dist/library/client.d.ts +42 -0
- package/dist/library/client.js +137 -0
- package/dist/library/download.d.ts +46 -0
- package/dist/library/download.js +98 -0
- package/dist/library/parse.d.ts +43 -0
- package/dist/library/parse.js +109 -0
- package/dist/library/qa.f5-b32.spec.d.ts +1 -0
- package/dist/library/qa.f5-b32.spec.js +202 -0
- package/dist/library/qa.f5-cli.spec.d.ts +1 -0
- package/dist/library/qa.f5-cli.spec.js +433 -0
- package/dist/library/qa.f5-contract.spec.d.ts +1 -0
- package/dist/library/qa.f5-contract.spec.js +206 -0
- package/dist/library/render.d.ts +39 -0
- package/dist/library/render.js +305 -0
- package/dist/library/wire.d.ts +96 -0
- package/dist/library/wire.js +167 -0
- package/dist/publish/api.d.ts +20 -0
- package/dist/publish/command.d.ts +3 -0
- package/dist/publish/command.js +39 -4
- package/dist/publish/copy.d.ts +29 -0
- package/dist/publish/copy.js +86 -0
- package/dist/publish/prompt.js +17 -2
- package/dist/scaffold/index.d.ts +9 -0
- package/dist/scaffold/index.js +13 -1
- package/dist/shared/config.d.ts +27 -0
- package/dist/shared/config.js +64 -9
- package/package.json +1 -1
|
@@ -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 {};
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// QA-F5-G6 · The cross-repo contract — the API's REAL output through the CLI's
|
|
2
|
+
// REAL parser. EXEMPT from the guardrail budget and always written.
|
|
3
|
+
//
|
|
4
|
+
// This is HALF the case. The other half is
|
|
5
|
+
// api/internal/httpapi/qa_f5_contract_test.go, which boots the real router over
|
|
6
|
+
// the real corpus and writes the API's ACTUAL response bytes into
|
|
7
|
+
// __fixtures__/api-golden/. This file feeds those exact bytes to the CLI's real
|
|
8
|
+
// parser and renderers — never to a hand-written stub.
|
|
9
|
+
//
|
|
10
|
+
// WHY. A per-repo suite green against its OWN stub hides a param-name or casing
|
|
11
|
+
// mismatch. On f1 the CLI emitted `redirect` while the console read
|
|
12
|
+
// `redirect_uri`: every suite was green and login was broken end to end, and it
|
|
13
|
+
// surfaced only at the security pass. f5 is the highest-probability casing bug
|
|
14
|
+
// in the product, because it is the ONE response in this API with a mixed
|
|
15
|
+
// convention — camelCase envelope, snake_case record — in a single body.
|
|
16
|
+
//
|
|
17
|
+
// ⚠ THE GOLDEN FILES ARE NOT HAND-EDITABLE. Editing one turns this case back
|
|
18
|
+
// into a per-repo stub test, which is precisely the failure it exists to catch.
|
|
19
|
+
// Regenerate them by running the Go half.
|
|
20
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
import { describe, expect, it } from "vitest";
|
|
24
|
+
import { searchLines, showLines, unknownBlockLines } from "./render.js";
|
|
25
|
+
import { parseBatchResponse, parseFetchResponse, parseSearchResponse, } from "./wire.js";
|
|
26
|
+
const GOLDEN = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "__fixtures__", "api-golden");
|
|
27
|
+
/**
|
|
28
|
+
* Read one captured response.
|
|
29
|
+
*
|
|
30
|
+
* A MISSING file FAILS rather than skips. A contract case that quietly tests
|
|
31
|
+
* five of seven arms is worse than no case: it reports green while the two arms
|
|
32
|
+
* that were dropped are exactly the ones nobody looked at.
|
|
33
|
+
*/
|
|
34
|
+
function golden(name) {
|
|
35
|
+
const file = path.join(GOLDEN, `${name}.json`);
|
|
36
|
+
if (!existsSync(file)) {
|
|
37
|
+
throw new Error(`the captured API response ${name}.json is MISSING from ${GOLDEN}.\n\n` +
|
|
38
|
+
`Regenerate it from the api worktree:\n` +
|
|
39
|
+
` go test -C <api> ./internal/httpapi/ -run TestQAF5G6 -count=1\n\n` +
|
|
40
|
+
`These files are the API's ACTUAL bytes. Do NOT hand-write one to make this pass — ` +
|
|
41
|
+
`a hand-written fixture turns the cross-repo contract case back into a per-repo stub ` +
|
|
42
|
+
`test, which is the exact failure it exists to catch.`);
|
|
43
|
+
}
|
|
44
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
45
|
+
}
|
|
46
|
+
/** The raw object, for key-presence assertions a typed parse cannot make. */
|
|
47
|
+
function rawGolden(name) {
|
|
48
|
+
return golden(name);
|
|
49
|
+
}
|
|
50
|
+
describe("QA-F5-G6 · every arm of the contract table round-trips", () => {
|
|
51
|
+
it("the capture manifest is complete — a missing arm FAILS rather than skips", () => {
|
|
52
|
+
const manifest = rawGolden("manifest");
|
|
53
|
+
expect(manifest.arms, "the capture manifest names no arms").toBeDefined();
|
|
54
|
+
for (const arm of manifest.arms ?? []) {
|
|
55
|
+
expect(existsSync(path.join(GOLDEN, `${arm}.json`)), `the manifest names ${arm} but the capture is missing`).toBe(true);
|
|
56
|
+
}
|
|
57
|
+
expect(manifest.arms?.length).toBeGreaterThanOrEqual(7);
|
|
58
|
+
});
|
|
59
|
+
it("(a) search WITH results — the camelCase envelope and the snake_case record BOTH survive", () => {
|
|
60
|
+
const raw = rawGolden("search-results");
|
|
61
|
+
// THE ENVELOPE IS camelCase. Asserted on the RAW bytes, because the parser
|
|
62
|
+
// would happily read either spelling and report nothing.
|
|
63
|
+
expect(Object.keys(raw), "the API's search envelope does not carry `resultCount`").toContain("resultCount");
|
|
64
|
+
expect(Object.keys(raw), "the API's search envelope carries a snake_case `result_count`. THE ENVELOPE IS " +
|
|
65
|
+
"camelCase and the RECORD is snake_case, in ONE body. Someone normalized both layers " +
|
|
66
|
+
"to one convention — which reads like a fix and silently breaks every agent prompt in " +
|
|
67
|
+
"the wild that was told the field is `usage_hint`.").not.toContain("result_count");
|
|
68
|
+
const res = parseSearchResponse(raw);
|
|
69
|
+
expect(res.resultCount).toBeGreaterThan(0);
|
|
70
|
+
expect(res.results.length).toBe(res.resultCount);
|
|
71
|
+
expect(res.gaps, "`gaps` is absent on a non-empty result set").toEqual([]);
|
|
72
|
+
// THE RECORD IS snake_case, and each of these is a field the renderer reads.
|
|
73
|
+
const rec = raw.results[0];
|
|
74
|
+
for (const k of ["id", "url", "has_alpha", "usage_hint", "occupies_canvas"]) {
|
|
75
|
+
expect(Object.keys(rec), `the API's record does not carry ${k}`).toContain(k);
|
|
76
|
+
}
|
|
77
|
+
for (const k of ["hasAlpha", "usageHint", "occupiesCanvas", "textInImage"]) {
|
|
78
|
+
expect(Object.keys(rec), `the API's record carries the camelCase key ${k}. The record is snake_case.`).not.toContain(k);
|
|
79
|
+
}
|
|
80
|
+
// The PARSER carried the values through, not merely the keys. A parser that
|
|
81
|
+
// read a key and dropped its value passes every key assertion above.
|
|
82
|
+
const parsed = res.results[0];
|
|
83
|
+
expect(parsed.id).toBe(rec.id);
|
|
84
|
+
expect(parsed.url).toBe(rec.url);
|
|
85
|
+
expect(parsed.usage_hint).toBe(rec.usage_hint);
|
|
86
|
+
expect(parsed.has_alpha).toBe(rec.has_alpha);
|
|
87
|
+
expect(parsed.occupies_canvas).toBe(rec.occupies_canvas);
|
|
88
|
+
// And the RENDERER prints the url — the one thing `add`/`search` exist to
|
|
89
|
+
// produce.
|
|
90
|
+
const screen = searchLines(res, "pumpkin").join("\n");
|
|
91
|
+
expect(screen).toContain(parsed.url);
|
|
92
|
+
});
|
|
93
|
+
it("(b) search with ZERO results — gaps[].tag AND gaps[].message BOTH survive", () => {
|
|
94
|
+
const raw = rawGolden("search-zero-gaps");
|
|
95
|
+
const res = parseSearchResponse(raw);
|
|
96
|
+
expect(res.resultCount).toBe(0);
|
|
97
|
+
expect(res.results).toEqual([]);
|
|
98
|
+
expect(res.gaps.length, "the API's zero-result response carried NO gaps through the CLI's parser. The honest " +
|
|
99
|
+
"empty is the whole feature: without gaps, an unexplained empty is what teaches an " +
|
|
100
|
+
"agent to invent a URL.").toBeGreaterThan(0);
|
|
101
|
+
for (const g of res.gaps) {
|
|
102
|
+
expect(g.tag, "a gap survived without its `tag`. An agent BRANCHES on tag and QUOTES message; " +
|
|
103
|
+
"shipping one means it branches on prose.").toBeTruthy();
|
|
104
|
+
expect(g.message, "a gap survived without its `message`").toBeTruthy();
|
|
105
|
+
}
|
|
106
|
+
// VERBATIM: the rendered sentence is byte-identical to the corpus's, not one
|
|
107
|
+
// the CLI composed.
|
|
108
|
+
const rawGaps = raw.gaps;
|
|
109
|
+
const screen = searchLines(res, "christmas face filter").join("\n");
|
|
110
|
+
// The renderer wraps at 70 columns, so compare on the collapsed whitespace.
|
|
111
|
+
const collapsed = screen.replace(/\s+/g, " ");
|
|
112
|
+
expect(collapsed, "the rendered zero-result screen does not carry the corpus's gap message VERBATIM. The " +
|
|
113
|
+
"moment the CLI edits this string it is COMPOSING an inventory claim rather than " +
|
|
114
|
+
"REPORTING one — and closing a gap stops being a corpus change.").toContain(rawGaps[0].message.replace(/\s+/g, " "));
|
|
115
|
+
});
|
|
116
|
+
it("(c) the 503 body carries NO gaps, NO results and NO resultCount — the CLI cannot render it as an empty answer", () => {
|
|
117
|
+
const raw = rawGolden("search-503");
|
|
118
|
+
for (const forbidden of ["gaps", "results", "resultCount"]) {
|
|
119
|
+
expect(Object.keys(raw), `the API's 503 body carries ${forbidden}. A LOAD FAILURE MUST NOT WEAR AN INVENTORY ` +
|
|
120
|
+
`FACT'S CLOTHES: with a \`gaps\` key present the CLI could render "the library has ` +
|
|
121
|
+
`no wearable assets" about a ~500-record library whose corpus merely failed to load.`).not.toContain(forbidden);
|
|
122
|
+
}
|
|
123
|
+
expect(raw.code).toBe("library_unavailable");
|
|
124
|
+
});
|
|
125
|
+
it("(d) batch PARTIAL — resolved and unknown are disjoint, and unknown echoes VERBATIM", () => {
|
|
126
|
+
const raw = rawGolden("batch-partial");
|
|
127
|
+
expect(Object.keys(raw)).toContain("resolved");
|
|
128
|
+
expect(Object.keys(raw)).toContain("unknown");
|
|
129
|
+
const res = parseBatchResponse(raw);
|
|
130
|
+
expect(res.resolved.length).toBeGreaterThan(0);
|
|
131
|
+
expect(res.unknown.length).toBeGreaterThan(0);
|
|
132
|
+
const resolvedIds = new Set(res.resolved.map((r) => r.id));
|
|
133
|
+
for (const u of res.unknown) {
|
|
134
|
+
expect(resolvedIds.has(u), `${u} is in BOTH arrays`).toBe(false);
|
|
135
|
+
}
|
|
136
|
+
// The echo is the caller's own input, verbatim, so an agent can diff against
|
|
137
|
+
// what it sent without parsing our prose.
|
|
138
|
+
expect(res.unknown).toContain("nope");
|
|
139
|
+
expect(res.unknown).toContain("lib_ffffffffffffffff");
|
|
140
|
+
// The renderer does not throw on a partial and names the offending strings.
|
|
141
|
+
const block = unknownBlockLines(res.unknown, res.resolved.length, true).join("\n");
|
|
142
|
+
expect(block).toContain("nope");
|
|
143
|
+
});
|
|
144
|
+
it("(e) fetch-one RETIRED — `retired` rides fetch and survives the parser", () => {
|
|
145
|
+
const raw = rawGolden("fetch-retired");
|
|
146
|
+
expect(Object.keys(raw), "the fetch response for a RETIRED asset carries no `retired` key. Without it a caller " +
|
|
147
|
+
"cannot tell a de-listed asset from a current one, which is the one fact they need to " +
|
|
148
|
+
"decide whether to keep using it — and the URL keeps serving forever either way (B37).").toContain("retired");
|
|
149
|
+
const rec = parseFetchResponse(raw);
|
|
150
|
+
expect(rec).toBeDefined();
|
|
151
|
+
expect(rec?.retired).toBe(true);
|
|
152
|
+
expect(() => showLines(rec)).not.toThrow();
|
|
153
|
+
});
|
|
154
|
+
it("(f) a record with NO kind renders FEWER lines and does NOT throw", () => {
|
|
155
|
+
const raw = rawGolden("fetch-no-kind");
|
|
156
|
+
expect(Object.keys(raw), "the unclassifiable record carries a `kind` key. It must be ABSENT — not \"\", not " +
|
|
157
|
+
"\"unknown\". `kind: \"\"` is a PLACEMENT CONTRACT that reads as a value, and an agent " +
|
|
158
|
+
"branching on it places an unclassifiable asset as a prop.").not.toContain("kind");
|
|
159
|
+
const rec = parseFetchResponse(raw);
|
|
160
|
+
expect(rec).toBeDefined();
|
|
161
|
+
expect(rec?.kind).toBeUndefined();
|
|
162
|
+
// The renderer READS EVERY FIELD DEFENSIVELY: a missing optional field must
|
|
163
|
+
// degrade to a MISSING LINE, never to an exception. The CLI and the API are
|
|
164
|
+
// versioned separately on a creator's machine — `npx vincentt` fetches
|
|
165
|
+
// whatever the registry has and points it at whatever API the config
|
|
166
|
+
// resolves to.
|
|
167
|
+
let lines = [];
|
|
168
|
+
expect(() => {
|
|
169
|
+
lines = showLines(rec);
|
|
170
|
+
}).not.toThrow();
|
|
171
|
+
const full = parseFetchResponse(rawGolden("fetch-retired"));
|
|
172
|
+
const fullLines = showLines(full);
|
|
173
|
+
expect(lines.length, "the record with no kind rendered the SAME number of lines as a full record — the " +
|
|
174
|
+
"renderer is emitting a line for a field that is not there.").toBeLessThan(fullLines.length);
|
|
175
|
+
});
|
|
176
|
+
it("(g) stage's 202 carries every field the operator's process must transport", () => {
|
|
177
|
+
const raw = rawGolden("stage-202");
|
|
178
|
+
// The camelCase envelope of the admin surface. `confirmationDigest` is the
|
|
179
|
+
// ONE wire value both sides COMPUTE rather than copy, which is why a
|
|
180
|
+
// differing key order or float format silently makes EVERY commit fail.
|
|
181
|
+
for (const k of ["revisionId", "counts", "diff", "confirmationDigest", "ratio", "expiresAt"]) {
|
|
182
|
+
expect(Object.keys(raw), `the stage response omits ${k}`).toContain(k);
|
|
183
|
+
}
|
|
184
|
+
expect(typeof raw.confirmationDigest).toBe("string");
|
|
185
|
+
expect(raw.confirmationDigest.length).toBeGreaterThan(16);
|
|
186
|
+
// The diff's own shape, which the ratio tripwire and the digest are computed
|
|
187
|
+
// over.
|
|
188
|
+
const diff = raw.diff;
|
|
189
|
+
for (const k of ["added", "removed", "changed", "ratio"]) {
|
|
190
|
+
expect(Object.keys(diff), `the diff omits ${k}`).toContain(k);
|
|
191
|
+
}
|
|
192
|
+
// The server's ratio and the envelope's agree. Two numbers for one fact is
|
|
193
|
+
// how a client ends up trusting the wrong one.
|
|
194
|
+
expect(raw.ratio).toBe(diff.ratio);
|
|
195
|
+
// ⚠ THE CLI DOES NOT RECOMPUTE THE DIGEST IN v1.
|
|
196
|
+
//
|
|
197
|
+
// The catalog's (g) called for the CLI to recompute confirmationDigest from
|
|
198
|
+
// the diff bytes and match the server's, pinned to a golden vector. That arm
|
|
199
|
+
// was RETIRED with `vincentt library publish`
|
|
200
|
+
// (D-cli-does-not-publish-the-corpus-admin-panel-will): there is no CLI code
|
|
201
|
+
// that computes a digest, so there is nothing to compare. The SERVER-side
|
|
202
|
+
// half of the pin — that the value is present, stable and computed over the
|
|
203
|
+
// diff — is asserted here and in the Go contract test; the CLIENT-side half
|
|
204
|
+
// belongs to the admin panel's groom.
|
|
205
|
+
});
|
|
206
|
+
});
|