@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.
- package/README.md +40 -13
- package/dist/cli/args.d.ts +22 -0
- package/dist/cli/args.js +71 -0
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +359 -91
- package/dist/cli/usage-text.d.ts +1 -0
- package/dist/cli/usage-text.js +97 -0
- package/dist/cli/verb-surface.d.ts +24 -0
- package/dist/cli/verb-surface.js +49 -0
- package/dist/dx/clusters.d.ts +0 -17
- package/dist/dx/clusters.js +50 -1
- package/dist/dx/skeleton.js +1 -0
- package/dist/lib/backend.d.ts +11 -0
- package/dist/lib/backend.js +23 -0
- package/dist/lib/classifyTarget.d.ts +42 -0
- package/dist/lib/classifyTarget.js +83 -0
- package/dist/lib/feedback.d.ts +46 -5
- package/dist/lib/feedback.js +76 -19
- package/dist/lib/feedbackCopy.d.ts +70 -0
- package/dist/lib/feedbackCopy.js +163 -0
- package/dist/lib/initCommand.d.ts +28 -1
- package/dist/lib/initCommand.js +91 -16
- package/dist/lib/initFrom.d.ts +70 -0
- package/dist/lib/initFrom.js +222 -0
- package/dist/lib/lifecycle.d.ts +28 -3
- package/dist/lib/lifecycle.js +99 -7
- package/dist/lib/listProjects.d.ts +41 -0
- package/dist/lib/listProjects.js +77 -0
- package/dist/lib/projects.d.ts +64 -8
- package/dist/lib/projects.js +316 -24
- package/dist/lib/sourceRef.d.ts +46 -0
- package/dist/lib/sourceRef.js +107 -0
- package/dist/lib/sourceRefCopy.d.ts +30 -0
- package/dist/lib/sourceRefCopy.js +116 -0
- package/dist/preview/command.d.ts +11 -0
- package/dist/preview/command.js +19 -1
- package/dist/preview/copy.d.ts +7 -0
- package/dist/preview/copy.js +8 -1
- package/dist/preview/runner.d.ts +6 -0
- package/dist/preview/runner.js +1 -0
- package/dist/preview/stream.d.ts +11 -0
- package/dist/preview/stream.js +13 -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/relay/annotations.d.ts +28 -4
- package/dist/relay/annotations.js +23 -7
- package/dist/relay/qa.f12.fixtures.d.ts +154 -0
- package/dist/relay/qa.f12.fixtures.js +358 -0
- package/dist/relay/server.d.ts +41 -3
- package/dist/relay/server.js +90 -32
- package/dist/scaffold/index.d.ts +9 -0
- package/dist/scaffold/index.js +13 -1
- package/package.json +2 -2
- package/dist/library/qa.f5-b32.spec.d.ts +0 -1
- package/dist/library/qa.f5-b32.spec.js +0 -202
- package/dist/library/qa.f5-cli.spec.d.ts +0 -1
- package/dist/library/qa.f5-cli.spec.js +0 -433
- package/dist/library/qa.f5-contract.spec.d.ts +0 -1
- package/dist/library/qa.f5-contract.spec.js +0 -206
- package/dist/relay/injectors.d.ts +0 -29
- package/dist/relay/injectors.js +0 -72
package/dist/relay/server.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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(
|
|
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
|
|
181
|
-
//
|
|
182
|
-
//
|
|
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
|
-
//
|
|
185
|
-
//
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
200
|
-
|
|
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,
|
|
210
|
-
.then((
|
|
211
|
-
if (
|
|
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) {
|
package/dist/scaffold/index.d.ts
CHANGED
|
@@ -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`. */
|
package/dist/scaffold/index.js
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
|
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 {};
|