@vincentt-xr/cli 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +40 -13
  2. package/dist/cli/args.d.ts +22 -0
  3. package/dist/cli/args.js +71 -0
  4. package/dist/cli/index.d.ts +1 -1
  5. package/dist/cli/index.js +359 -91
  6. package/dist/cli/usage-text.d.ts +1 -0
  7. package/dist/cli/usage-text.js +97 -0
  8. package/dist/cli/verb-surface.d.ts +24 -0
  9. package/dist/cli/verb-surface.js +49 -0
  10. package/dist/dx/clusters.d.ts +0 -17
  11. package/dist/dx/clusters.js +50 -1
  12. package/dist/dx/skeleton.js +1 -0
  13. package/dist/lib/backend.d.ts +11 -0
  14. package/dist/lib/backend.js +23 -0
  15. package/dist/lib/classifyTarget.d.ts +42 -0
  16. package/dist/lib/classifyTarget.js +83 -0
  17. package/dist/lib/feedback.d.ts +46 -5
  18. package/dist/lib/feedback.js +76 -19
  19. package/dist/lib/feedbackCopy.d.ts +70 -0
  20. package/dist/lib/feedbackCopy.js +163 -0
  21. package/dist/lib/initCommand.d.ts +28 -1
  22. package/dist/lib/initCommand.js +91 -16
  23. package/dist/lib/initFrom.d.ts +70 -0
  24. package/dist/lib/initFrom.js +222 -0
  25. package/dist/lib/lifecycle.d.ts +28 -3
  26. package/dist/lib/lifecycle.js +99 -7
  27. package/dist/lib/listProjects.d.ts +41 -0
  28. package/dist/lib/listProjects.js +77 -0
  29. package/dist/lib/projects.d.ts +64 -8
  30. package/dist/lib/projects.js +316 -24
  31. package/dist/lib/sourceRef.d.ts +46 -0
  32. package/dist/lib/sourceRef.js +107 -0
  33. package/dist/lib/sourceRefCopy.d.ts +30 -0
  34. package/dist/lib/sourceRefCopy.js +116 -0
  35. package/dist/preview/command.d.ts +11 -0
  36. package/dist/preview/command.js +19 -1
  37. package/dist/preview/copy.d.ts +7 -0
  38. package/dist/preview/copy.js +8 -1
  39. package/dist/preview/runner.d.ts +6 -0
  40. package/dist/preview/runner.js +1 -0
  41. package/dist/preview/stream.d.ts +11 -0
  42. package/dist/preview/stream.js +13 -0
  43. package/dist/publish/api.d.ts +20 -0
  44. package/dist/publish/command.d.ts +3 -0
  45. package/dist/publish/command.js +39 -4
  46. package/dist/publish/copy.d.ts +29 -0
  47. package/dist/publish/copy.js +86 -0
  48. package/dist/relay/annotations.d.ts +28 -4
  49. package/dist/relay/annotations.js +23 -7
  50. package/dist/relay/qa.f12.fixtures.d.ts +154 -0
  51. package/dist/relay/qa.f12.fixtures.js +358 -0
  52. package/dist/relay/server.d.ts +41 -3
  53. package/dist/relay/server.js +90 -32
  54. package/dist/scaffold/index.d.ts +9 -0
  55. package/dist/scaffold/index.js +13 -1
  56. package/package.json +2 -2
  57. package/dist/library/qa.f5-b32.spec.d.ts +0 -1
  58. package/dist/library/qa.f5-b32.spec.js +0 -202
  59. package/dist/library/qa.f5-cli.spec.d.ts +0 -1
  60. package/dist/library/qa.f5-cli.spec.js +0 -433
  61. package/dist/library/qa.f5-contract.spec.d.ts +0 -1
  62. package/dist/library/qa.f5-contract.spec.js +0 -206
  63. package/dist/relay/injectors.d.ts +0 -29
  64. package/dist/relay/injectors.js +0 -72
@@ -5,17 +5,17 @@
5
5
  // store.ts, the reverse-channel store in annotations.ts. One relay per preview run.
6
6
  import { createServer } from "node:http";
7
7
  import { randomBytes } from "node:crypto";
8
- import path from "node:path";
9
8
  import { WebSocketServer } from "ws";
10
9
  import { EventStore } from "./store.js";
11
10
  import { TesterRegistry, isTesterId } from "./testers.js";
12
- import { AnnotationStore, annotationsDir } from "./annotations.js";
13
- import { claudeCodeInjector, createDispatcher, pasteInjector, } from "./injectors.js";
11
+ import { AnnotationStore } from "./annotations.js";
14
12
  import { VIEWER_LABEL_RE, VIEWER_STAMP_HEADER } from "@vincentt-xr/harness/tunnel";
15
13
  /** Cap an annotation POST body so a bad/huge upload can't exhaust memory. */
16
14
  const MAX_ANNOTATION_BYTES = 25 * 1024 * 1024;
17
15
  /** Clamp the long-poll hold so a request can't park forever. */
18
16
  const MAX_FEEDBACK_TIMEOUT_MS = 600_000;
17
+ /** The hold a request that names no timeout gets. */
18
+ const DEFAULT_FEEDBACK_TIMEOUT_MS = 300_000;
19
19
  export function startRelay(opts = {}) {
20
20
  const port = opts.port ?? 7331;
21
21
  const wsPath = opts.path ?? "/__harness";
@@ -24,27 +24,37 @@ export function startRelay(opts = {}) {
24
24
  const log = opts.onLog ?? (() => undefined);
25
25
  const makeId = opts.makeId ?? (() => randomBytes(3).toString("hex"));
26
26
  // Reverse channel is enabled when we know which project to bind annotations to.
27
- let annotations;
28
- let dispatcher;
29
- if (opts.projectDir || opts.annotationStore) {
30
- annotations = opts.annotationStore ?? new AnnotationStore(opts.projectDir);
31
- const queuePath = path.join(opts.projectDir ? annotationsDir(opts.projectDir) : ".", "queue.ndjson");
32
- dispatcher =
33
- opts.dispatcher ??
34
- createDispatcher([
35
- claudeCodeInjector(queuePath),
36
- pasteInjector((p) => log(`annotation ready to paste:\n${p}`)),
37
- ], log);
38
- }
27
+ const annotations = opts.annotationStore ??
28
+ (opts.projectDir === undefined ? undefined : new AnnotationStore(opts.projectDir));
29
+ // The tester a LIVE socket resolved to. A WeakMap and not a field on the
30
+ // store: this must not outlive the socket, which is the entire difference
31
+ // between it and `testersSeen`. Entries vanish with the connection.
32
+ const socketTester = new WeakMap();
33
+ const presence = () => {
34
+ const open = new Set();
35
+ let connections = 0;
36
+ for (const ws of wss.clients) {
37
+ // OPEN only. A socket mid-close is still in the set for a tick, and
38
+ // counting it would print a viewer who is already gone.
39
+ if (ws.readyState !== ws.OPEN)
40
+ continue;
41
+ connections += 1;
42
+ const t = socketTester.get(ws);
43
+ if (t !== undefined)
44
+ open.add(t);
45
+ }
46
+ return { connections, testers: open.size };
47
+ };
39
48
  const http = createServer((req, res) => handleHttp(req, res, {
40
49
  store,
41
50
  annotations,
42
- dispatcher,
43
51
  testers,
44
52
  wsPath,
45
53
  makeId,
46
54
  log,
55
+ presence,
47
56
  onCapReached: opts.onCapReached,
57
+ onAnnotation: opts.onAnnotation,
48
58
  }));
49
59
  const wss = new WebSocketServer({ server: http, path: wsPath });
50
60
  wss.on("connection", (ws, req) => {
@@ -74,6 +84,12 @@ export function startRelay(opts = {}) {
74
84
  resolved = true;
75
85
  const attribution = testers.attribute(sessionId);
76
86
  tester = attribution.tester;
87
+ // Recorded against the SOCKET so the live presence read can see it: the
88
+ // tester above is a closure local that no other scope can reach, and the
89
+ // registry is ever-seen. A key that missed the grammar records nothing and
90
+ // the connection counts as a connection with no tester.
91
+ if (tester !== undefined)
92
+ socketTester.set(ws, tester);
77
93
  if (attribution.warnCapReached)
78
94
  opts.onCapReached?.();
79
95
  };
@@ -147,9 +163,19 @@ function handleQuery(req, res, url, ctx) {
147
163
  readQuery(req, url)
148
164
  .then((q) => {
149
165
  const result = ctx.store.query(q);
166
+ // ONE BRANCH on the existing route, and the envelope is unchanged.
167
+ // Returning a RelayResult rather than a bespoke `{count}` keeps the reply
168
+ // its own liveness-and-identity proof: `isRelayResponding` recognises a
169
+ // Vincentt relay by `events` being an array and `latestSeq` a number, so a
170
+ // stranger on a reused port answers something else and the caller reads no
171
+ // count instead of trusting an integer from an unauthenticated port.
172
+ const live = q.presence === true ? ctx.presence?.() : undefined;
173
+ const body = live === undefined
174
+ ? result
175
+ : { ...result, liveConnections: live.connections, liveTesters: live.testers };
150
176
  res
151
177
  .writeHead(200, { "content-type": "application/json" })
152
- .end(JSON.stringify(result));
178
+ .end(JSON.stringify(body));
153
179
  })
154
180
  .catch((err) => {
155
181
  ctx.log(`query error: ${err}`);
@@ -177,15 +203,15 @@ function handleAnnotationPost(req, res, ctx) {
177
203
  // POSTs exhaust the registry before the creator's phone connected, and the
178
204
  // terminal only prints attributed events.
179
205
  //
180
- // The key is still hostile input either way: the annotation is appended to
181
- // a queue a running Claude Code /loop drains, so the raw string must never
182
- // survive onto the record.
206
+ // The key is still hostile input either way: the record reaches an agent's
207
+ // context on the consumer's stdout, so the raw string must never survive
208
+ // onto it.
183
209
  const annotation = await ctx.annotations.add({ ...input, sessionId: ctx.testers.resolve(input.sessionId) }, { now: Date.now(), id: ctx.makeId() });
184
- // Fire the injector chain best-effort — a failed dispatch must not fail the
185
- // POST; the long-poll consumer still sees the persisted annotation.
186
- if (ctx.dispatcher) {
187
- ctx.dispatcher.dispatch(annotation).catch((err) => ctx.log(`dispatch: ${err}`));
188
- }
210
+ // The live stream is told an annotation ARRIVED, with the resolved tester
211
+ // and nothing else. It is a fact, not a directive: the consumer is the
212
+ // long-poll, and a terminal line naming what to run would be the platform
213
+ // teaching one host's idiom.
214
+ ctx.onAnnotation?.({ tester: annotation.sessionId });
189
215
  res
190
216
  .writeHead(200, { "content-type": "application/json" })
191
217
  .end(JSON.stringify({ id: annotation.id, seq: annotation.seq }));
@@ -196,25 +222,35 @@ function handleAnnotationPost(req, res, ctx) {
196
222
  res.writeHead(400).end();
197
223
  });
198
224
  }
199
- // The agnostic consumer (`vincentt feedback --wait`) long-polls this: return the
200
- // next annotation after `after`, or 204 when the hold window elapses.
225
+ /**
226
+ * The consumer (`vincentt feedback --wait`) long-polls this: the next annotation
227
+ * after `after`, `204` when the hold window elapses, or `410` when the session
228
+ * ended under a parked reader.
229
+ *
230
+ * `410` is what separates a DEAD channel from a quiet one. Answering the parked
231
+ * request `204` on teardown is byte-identical to a timeout, so a consumer keeps
232
+ * re-parking on a channel that can never speak again.
233
+ */
201
234
  function handleFeedbackNext(res, url, ctx) {
202
235
  if (!ctx.annotations) {
203
236
  res.writeHead(503).end("reverse channel not enabled");
204
237
  return;
205
238
  }
206
239
  const after = Number(url.searchParams.get("after") ?? "0") || 0;
207
- const timeoutMs = Math.min(Number(url.searchParams.get("timeout") ?? "300000") || 300_000, MAX_FEEDBACK_TIMEOUT_MS);
208
240
  ctx.annotations
209
- .nextAfter(after, timeoutMs)
210
- .then((annotation) => {
211
- if (!annotation) {
241
+ .nextAfter(after, feedbackTimeoutMs(url.searchParams.get("timeout")))
242
+ .then((outcome) => {
243
+ if (outcome.kind === "closed") {
244
+ res.writeHead(410).end();
245
+ return;
246
+ }
247
+ if (outcome.kind === "timeout") {
212
248
  res.writeHead(204).end();
213
249
  return;
214
250
  }
215
251
  res
216
252
  .writeHead(200, { "content-type": "application/json" })
217
- .end(JSON.stringify(annotation));
253
+ .end(JSON.stringify(outcome.annotation));
218
254
  })
219
255
  .catch((err) => {
220
256
  ctx.log(`feedback error: ${err}`);
@@ -222,6 +258,23 @@ function handleFeedbackNext(res, url, ctx) {
222
258
  res.writeHead(400).end();
223
259
  });
224
260
  }
261
+ /**
262
+ * Clamp the hold to `[0, MAX]`, explicitly, with `0` meaning ANSWER NOW.
263
+ *
264
+ * `Number(t) || DEFAULT` cannot express that: `0` is falsy, so the one value the
265
+ * drain's immediate probe depends on took the five-minute fallback and silently
266
+ * defeated the batch — while `"-1"` fell through to `setTimeout` raw and worked
267
+ * only by accident. Every path here lands on a finite, non-negative, capped
268
+ * number before it reaches a timer.
269
+ */
270
+ export function feedbackTimeoutMs(raw) {
271
+ if (raw === null || raw.trim() === "")
272
+ return DEFAULT_FEEDBACK_TIMEOUT_MS;
273
+ const n = Number(raw);
274
+ if (!Number.isFinite(n))
275
+ return DEFAULT_FEEDBACK_TIMEOUT_MS;
276
+ return Math.min(Math.max(n, 0), MAX_FEEDBACK_TIMEOUT_MS);
277
+ }
225
278
  /**
226
279
  * A `tester` filter that is not a well-formed id becomes a sentinel that matches
227
280
  * no stored tag, so it filters to an EMPTY list rather than erroring or being
@@ -255,6 +308,11 @@ async function readQuery(req, url) {
255
308
  q.tester = p.get("tester");
256
309
  if (p.get("viewer"))
257
310
  q.viewer = p.get("viewer");
311
+ // Only the exact string `true` asks for presence. Any other spelling is not a
312
+ // request for it, so the reply carries no count and the caller says nothing —
313
+ // the same outcome as an older relay, by the same rule.
314
+ if (p.get("presence") === "true")
315
+ q.presence = true;
258
316
  return normalizeTester(q);
259
317
  }
260
318
  function readBody(req, maxBytes = Infinity) {
@@ -30,6 +30,15 @@ export declare function needsScaffold(dir: string): Promise<boolean>;
30
30
  * Dotfiles count. A folder holding only `.git` is a repo someone cares about.
31
31
  */
32
32
  export declare function isNonEmptyDir(dir: string): Promise<boolean>;
33
+ /**
34
+ * The clone failed because the REF does not exist, as opposed to the repo being
35
+ * unreachable. The two remedies are opposite, and a caller cloning a gallery
36
+ * source words both differently — so the split is carried by the error's type
37
+ * rather than by re-matching git's text downstream.
38
+ */
39
+ export declare class ScaffoldRefError extends Error {
40
+ constructor(message: string);
41
+ }
33
42
  export interface ScaffoldOptions {
34
43
  repo?: string;
35
44
  /** Template ref (tag) to scaffold from. Defaults to `DEFAULT_TEMPLATE_REF`. */
@@ -59,6 +59,18 @@ export async function isNonEmptyDir(dir) {
59
59
  return false;
60
60
  }
61
61
  }
62
+ /**
63
+ * The clone failed because the REF does not exist, as opposed to the repo being
64
+ * unreachable. The two remedies are opposite, and a caller cloning a gallery
65
+ * source words both differently — so the split is carried by the error's type
66
+ * rather than by re-matching git's text downstream.
67
+ */
68
+ export class ScaffoldRefError extends Error {
69
+ constructor(message) {
70
+ super(message);
71
+ this.name = "ScaffoldRefError";
72
+ }
73
+ }
62
74
  /**
63
75
  * Scaffold the template into `targetDir`. Clones to a temp dir, strips `.git`,
64
76
  * copies the files in (never overwriting anything already there), then inits a
@@ -80,7 +92,7 @@ export async function scaffoldFromTemplate(targetDir, opts = {}) {
80
92
  // real cause is a typo'd version). git names the ref in this specific case,
81
93
  // so split on that rather than reporting one cause for both.
82
94
  if (/(remote branch|Remote branch).*not found|couldn't find remote ref/i.test(msg)) {
83
- throw new Error(`The template has no version "${ref}". ` +
95
+ throw new ScaffoldRefError(`The template has no version "${ref}". ` +
84
96
  `Check the tag exists, or omit --template to use the latest release.`);
85
97
  }
86
98
  throw new Error(`Could not clone the ${repo} template (check your git access): ${msg}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincentt-xr/cli",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Vincentt CLI — the vincentt command-line dev loop (login, create, publish, preview, diagnostics, feedback, relay)",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -24,7 +24,7 @@
24
24
  "qrcode": "^1.5.4",
25
25
  "ws": "^8.18.0",
26
26
  "zod": "^3.23.8",
27
- "@vincentt-xr/harness": "1.2.0"
27
+ "@vincentt-xr/harness": "1.3.0"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@types/qrcode": "^1.5.5",
@@ -1 +0,0 @@
1
- export {};
@@ -1,202 +0,0 @@
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
- });
@@ -1 +0,0 @@
1
- export {};