@specific.dev/spectest 0.82.0 → 0.85.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/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/dist/components/postgres-tls.d.ts +10 -0
- package/dist/components/postgres-tls.js +9 -0
- package/dist/components/postgres.d.ts +59 -1
- package/dist/components/postgres.js +5 -2
- package/dist/components/supabase-config.d.ts +5 -0
- package/dist/components/supabase-config.js +12 -4
- package/dist/components/supabase-webhooks.d.ts +1 -0
- package/dist/components/supabase-webhooks.js +262 -0
- package/dist/components/supabase.d.ts +36 -11
- package/dist/components/supabase.js +75 -18
- package/dist/components/temporal.d.ts +89 -0
- package/dist/components/temporal.js +185 -0
- package/dist/coverage.d.ts +78 -3
- package/dist/coverage.js +251 -11
- package/dist/daemon.js +71 -5
- package/dist/environment-ca.d.ts +8 -0
- package/dist/environment-ca.js +16 -0
- package/dist/harness/container-run.js +3 -0
- package/dist/harness/coverage.d.ts +19 -7
- package/dist/harness/coverage.js +52 -9
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
- package/src/components/index.ts +3 -0
- package/src/components/postgres-tls.test.ts +23 -0
- package/src/components/postgres-tls.ts +11 -0
- package/src/components/postgres.ts +8 -2
- package/src/components/supabase-config.ts +11 -2
- package/src/components/supabase-db.test.ts +64 -0
- package/src/components/supabase-dir.test.ts +71 -0
- package/src/components/supabase-webhooks.ts +263 -0
- package/src/components/supabase.ts +108 -28
- package/src/components/temporal.test.ts +86 -0
- package/src/components/temporal.ts +188 -0
- package/src/coverage.test.ts +93 -13
- package/src/coverage.ts +295 -11
- package/src/daemon.ts +68 -3
- package/src/environment-ca.test.ts +44 -0
- package/src/environment-ca.ts +20 -0
- package/src/harness/container-run.test.ts +14 -2
- package/src/harness/container-run.ts +3 -0
- package/src/harness/coverage.test.ts +32 -1
- package/src/harness/coverage.ts +47 -9
- package/src/index.ts +1 -0
package/dist/coverage.js
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
//
|
|
27
27
|
// import * as coverage from "@specific.dev/spectest/coverage";
|
|
28
28
|
// coverage: { adapters: [coverage.node(), coverage.browser()] }
|
|
29
|
+
import { createHash } from "node:crypto";
|
|
29
30
|
import { harvestAllBrowserCoverage, takeBrowserCoverageReports } from "./browser-coverage.js";
|
|
30
31
|
import { COVERAGE_CONTAINER_DIR } from "./harness/coverage.js";
|
|
31
32
|
export { COVERAGE_CONTAINER_DIR };
|
|
@@ -36,6 +37,9 @@ export const NODE_COVERAGE_EMPTY_REPORT = "coverage-spectest-empty.json";
|
|
|
36
37
|
/** Prefix of the per-process sockets the node hook answers on, relative
|
|
37
38
|
* to the coverage dir: `.ctl-<pid>`. */
|
|
38
39
|
export const NODE_COVERAGE_SOCKET_PREFIX = ".ctl-";
|
|
40
|
+
/** The report name the harness writes an adapter's inventory to, in the
|
|
41
|
+
* bring-up capture only. */
|
|
42
|
+
export const INVENTORY_REPORT = "inventory.units";
|
|
39
43
|
/** The `reports` mode of a `coverage` value, for the reports no adapter
|
|
40
44
|
* wrote (the program's own, a `command`'s). */
|
|
41
45
|
export function coverageReportsMode(cov) {
|
|
@@ -65,10 +69,13 @@ export function validateCoverage(service, cov) {
|
|
|
65
69
|
(a.reports === undefined ||
|
|
66
70
|
a.reports === "delta" ||
|
|
67
71
|
a.reports === "cumulative") &&
|
|
68
|
-
|
|
72
|
+
(a.explains === undefined ||
|
|
73
|
+
(Array.isArray(a.explains) &&
|
|
74
|
+
a.explains.every((e) => typeof e === "string"))) &&
|
|
75
|
+
["configure", "load", "capture", "inventory"].every((k) => a[k] === undefined ||
|
|
69
76
|
typeof a[k] === "function");
|
|
70
77
|
if (!ok) {
|
|
71
|
-
throw new Error(`service "${service}" has an invalid coverage adapter — an adapter is \`{ name, reports?, configure?, load?, capture? }\` (see \`defineAdapter\` in @specific.dev/spectest/coverage)`);
|
|
78
|
+
throw new Error(`service "${service}" has an invalid coverage adapter — an adapter is \`{ name, reports?, explains?, configure?, load?, capture?, inventory? }\` (see \`defineAdapter\` in @specific.dev/spectest/coverage)`);
|
|
72
79
|
}
|
|
73
80
|
}
|
|
74
81
|
return;
|
|
@@ -97,7 +104,14 @@ export function applyCoverageAdapters(key, svc) {
|
|
|
97
104
|
out = a.configure(out, { key });
|
|
98
105
|
}
|
|
99
106
|
const mode = svc.coverage.reports;
|
|
100
|
-
|
|
107
|
+
// `explains` rides the wire config so the control plane's selector can
|
|
108
|
+
// read it without knowing any adapter.
|
|
109
|
+
const explains = [...new Set(adapters.flatMap((a) => a.explains ?? []))];
|
|
110
|
+
const wire = {
|
|
111
|
+
adapters: adapters.map((a) => withWireName(a)),
|
|
112
|
+
...(mode ? { reports: mode } : {}),
|
|
113
|
+
...(explains.length > 0 ? { explains } : {}),
|
|
114
|
+
};
|
|
101
115
|
return { ...out, coverage: wire };
|
|
102
116
|
}
|
|
103
117
|
function withWireName(a) {
|
|
@@ -153,12 +167,21 @@ export const NODE_COVERAGE_DUMP_PREFIX = "node-";
|
|
|
153
167
|
* counters: every dump is the delta since the previous take. No
|
|
154
168
|
* signal is used: signals are claimed by frameworks (SIGUSR2 stops a
|
|
155
169
|
* Temporal worker, restarts nodemon), a socket is nobody's.
|
|
170
|
+
* - Reports **dependencies as units**: a ran script under `node_modules`
|
|
171
|
+
* becomes `npm:<name>@<version>` (from the nearest package.json, or the
|
|
172
|
+
* path's text) in the dump's `units`; a bundled script's map sources
|
|
173
|
+
* under `node_modules` become `sourceUnits` beside the map in its
|
|
174
|
+
* record. The control plane compares unit ids by equality and never
|
|
175
|
+
* learns what `node_modules` is.
|
|
156
176
|
* - Writes a **dump** `node-<pid>-<thread>-<ms>-<n>.json`: the app's own
|
|
157
177
|
* scripts (`file://`, outside `node_modules` and `.cache`, not the
|
|
158
178
|
* hook) in which some range count is above zero, and of each only the
|
|
159
|
-
* functions that ran,
|
|
160
|
-
*
|
|
161
|
-
* `
|
|
179
|
+
* functions that ran, each one flat array: `[start, length, count]`
|
|
180
|
+
* for its extent (V8's first range), then `[offset from start,
|
|
181
|
+
* length, count]` per block range in V8's order. Name and block flag
|
|
182
|
+
* are in the record, joined on the extent. Nothing when nothing ran.
|
|
183
|
+
* `{ spectestVersion: 2, result: [{ url, spectestScript, functions:
|
|
184
|
+
* [[s, l, c, o, l, c, …], …] }] }`.
|
|
162
185
|
* - Writes, **once per script per branch**, a **script record**
|
|
163
186
|
* `scripts/<id>.json` (`id` = sha256 of the URL): the length of every
|
|
164
187
|
* line of the source V8 compiled (`Debugger.getScriptSource`, so a
|
|
@@ -221,6 +244,48 @@ export const NODE_COVERAGE_HOOK = `"use strict";
|
|
|
221
244
|
function isApp(u) {
|
|
222
245
|
return typeof u === "string" && u.startsWith("file://") && u.indexOf("/node_modules/") < 0 && u.indexOf("/.cache/") < 0 && u !== SELF;
|
|
223
246
|
}
|
|
247
|
+
function isDep(u) {
|
|
248
|
+
return typeof u === "string" && u.startsWith("file://") && u.indexOf("/node_modules/") >= 0 && u !== SELF;
|
|
249
|
+
}
|
|
250
|
+
// A dependency's unit id, npm:<name>@<version>, from the nearest
|
|
251
|
+
// package.json above a path inside node_modules (read once per
|
|
252
|
+
// directory); when the file is not on disk (a bundled dependency whose
|
|
253
|
+
// sources a multi-stage image dropped), from the path's text.
|
|
254
|
+
const UNIT_CACHE = new Map();
|
|
255
|
+
function unitFromText(p) {
|
|
256
|
+
let m = /\\/node_modules\\/\\.pnpm\\/((?:@[^/@+]+\\+)?[^/@]+)@([^/_]+)/.exec(p);
|
|
257
|
+
if (m) return "npm:" + m[1].replace("+", "/") + "@" + m[2];
|
|
258
|
+
m = /\\/node_modules\\/(@[^/]+\\/[^/]+|[^/@.][^/]*)(?:\\/|$)/.exec(p);
|
|
259
|
+
return m ? "npm:" + m[1] : null;
|
|
260
|
+
}
|
|
261
|
+
function unitOf(p) {
|
|
262
|
+
if (p.indexOf("/node_modules/") < 0) return null;
|
|
263
|
+
// Walk up to the innermost node_modules boundary and no further: the
|
|
264
|
+
// directories above it are shared with every other package, so a
|
|
265
|
+
// cached answer must never sit on them.
|
|
266
|
+
const visited = [];
|
|
267
|
+
let d = path.dirname(p);
|
|
268
|
+
while (d && d !== "/" && path.basename(d) !== "node_modules") {
|
|
269
|
+
if (UNIT_CACHE.has(d)) {
|
|
270
|
+
const hit = UNIT_CACHE.get(d);
|
|
271
|
+
for (const v of visited) UNIT_CACHE.set(v, hit);
|
|
272
|
+
return hit;
|
|
273
|
+
}
|
|
274
|
+
visited.push(d);
|
|
275
|
+
try {
|
|
276
|
+
const pj = JSON.parse(fs.readFileSync(path.join(d, "package.json"), "utf8"));
|
|
277
|
+
if (pj && typeof pj.name === "string" && pj.name) {
|
|
278
|
+
const u = "npm:" + pj.name + (typeof pj.version === "string" && pj.version ? "@" + pj.version : "");
|
|
279
|
+
for (const v of visited) UNIT_CACHE.set(v, u);
|
|
280
|
+
return u;
|
|
281
|
+
}
|
|
282
|
+
} catch (e) {}
|
|
283
|
+
d = path.dirname(d);
|
|
284
|
+
}
|
|
285
|
+
const u = unitFromText(p);
|
|
286
|
+
for (const v of visited) UNIT_CACHE.set(v, u);
|
|
287
|
+
return u;
|
|
288
|
+
}
|
|
224
289
|
function ran(f) {
|
|
225
290
|
return f.ranges.some(function (r) { return r.count > 0; });
|
|
226
291
|
}
|
|
@@ -276,7 +341,29 @@ export const NODE_COVERAGE_HOOK = `"use strict";
|
|
|
276
341
|
}
|
|
277
342
|
if (!data || typeof data !== "object" || !("mappings" in data)) return null;
|
|
278
343
|
delete data.sourcesContent;
|
|
279
|
-
|
|
344
|
+
// A source inside node_modules is a dependency: its unit id rides
|
|
345
|
+
// beside the map (\`sourceUnits[i]\`, null for the project's own), so
|
|
346
|
+
// the control plane never has to know what node_modules is.
|
|
347
|
+
const mapDir = mapUrl ? path.dirname(url.fileURLToPath(mapUrl)) : path.dirname(scriptPath);
|
|
348
|
+
const root = typeof data.sourceRoot === "string" && data.sourceRoot ? data.sourceRoot : "";
|
|
349
|
+
const sourceUnits = (Array.isArray(data.sources) ? data.sources : []).map(function (src) {
|
|
350
|
+
if (typeof src !== "string") return null;
|
|
351
|
+
let abs;
|
|
352
|
+
if (src.startsWith("file://")) {
|
|
353
|
+
try {
|
|
354
|
+
abs = url.fileURLToPath(src);
|
|
355
|
+
} catch (e) {
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
} else if (/^[a-z][a-z0-9+.-]*:/i.test(src)) {
|
|
359
|
+
return src.indexOf("/node_modules/") >= 0 ? unitFromText(src) : null;
|
|
360
|
+
} else {
|
|
361
|
+
const rel = root && !src.startsWith("/") ? root.replace(/\\/$/, "") + "/" + src : src;
|
|
362
|
+
abs = path.resolve(mapDir, rel);
|
|
363
|
+
}
|
|
364
|
+
return abs.indexOf("/node_modules/") >= 0 ? unitOf(abs) : null;
|
|
365
|
+
});
|
|
366
|
+
return { url: mapUrl, data: data, sourceUnits: sourceUnits };
|
|
280
367
|
}
|
|
281
368
|
function writeAtomic(file, text) {
|
|
282
369
|
const tmp = file + "." + process.pid + "-" + wt.threadId + ".tmp";
|
|
@@ -307,10 +394,19 @@ export const NODE_COVERAGE_HOOK = `"use strict";
|
|
|
307
394
|
const res = post("Profiler.takePreciseCoverage");
|
|
308
395
|
if (!res || !Array.isArray(res.result)) return;
|
|
309
396
|
const kept = [];
|
|
397
|
+
const units = new Set();
|
|
310
398
|
for (const s of res.result) {
|
|
311
|
-
if (
|
|
399
|
+
if (!Array.isArray(s.functions) || !s.functions.some(ran)) continue;
|
|
400
|
+
if (isApp(s.url)) kept.push(s);
|
|
401
|
+
else if (isDep(s.url)) {
|
|
402
|
+
let u = null;
|
|
403
|
+
try {
|
|
404
|
+
u = unitOf(url.fileURLToPath(s.url));
|
|
405
|
+
} catch (e) {}
|
|
406
|
+
if (u) units.add(u);
|
|
407
|
+
}
|
|
312
408
|
}
|
|
313
|
-
if (kept.length === 0) return;
|
|
409
|
+
if (kept.length === 0 && units.size === 0) return;
|
|
314
410
|
const fresh = [];
|
|
315
411
|
for (const s of kept) {
|
|
316
412
|
if (known.has(s.url)) continue;
|
|
@@ -331,10 +427,28 @@ export const NODE_COVERAGE_HOOK = `"use strict";
|
|
|
331
427
|
}
|
|
332
428
|
post("Debugger.disable");
|
|
333
429
|
}
|
|
430
|
+
// A ran function is one flat array: [start, length, count] for its
|
|
431
|
+
// extent (V8's first range), then [offset from start, length, count]
|
|
432
|
+
// per block range in V8's order. Small numbers, so the dump gzips to
|
|
433
|
+
// less than half of V8's own JSON; name and block flag live in the
|
|
434
|
+
// script record, joined on the extent.
|
|
334
435
|
const doc = {
|
|
335
|
-
spectestVersion:
|
|
436
|
+
spectestVersion: 2,
|
|
437
|
+
units: Array.from(units).sort(),
|
|
336
438
|
result: kept.map(function (s) {
|
|
337
|
-
return {
|
|
439
|
+
return {
|
|
440
|
+
url: s.url,
|
|
441
|
+
spectestScript: idOf(s.url),
|
|
442
|
+
functions: s.functions.filter(ran).map(function (f) {
|
|
443
|
+
const first = f.ranges[0];
|
|
444
|
+
const out = [first.startOffset, first.endOffset - first.startOffset, first.count];
|
|
445
|
+
for (let i = 1; i < f.ranges.length; i++) {
|
|
446
|
+
const r = f.ranges[i];
|
|
447
|
+
out.push(r.startOffset - first.startOffset, r.endOffset - r.startOffset, r.count);
|
|
448
|
+
}
|
|
449
|
+
return out;
|
|
450
|
+
}),
|
|
451
|
+
};
|
|
338
452
|
}),
|
|
339
453
|
};
|
|
340
454
|
const name = ${JSON.stringify(NODE_COVERAGE_DUMP_PREFIX)} + process.pid + "-" + wt.threadId + "-" + Date.now() + "-" + seq++ + ".json";
|
|
@@ -393,10 +507,136 @@ export const NODE_COVERAGE_HOOK = `"use strict";
|
|
|
393
507
|
* only its own files: what an earlier capture on this branch shipped is
|
|
394
508
|
* removed first (module memory, forks with the environment).
|
|
395
509
|
*/
|
|
510
|
+
/** Manifests and lockfiles whose changes the node inventory accounts for. */
|
|
511
|
+
export const NODE_EXPLAINS = [
|
|
512
|
+
"**/package.json",
|
|
513
|
+
"**/package-lock.json",
|
|
514
|
+
"**/npm-shrinkwrap.json",
|
|
515
|
+
"**/pnpm-lock.yaml",
|
|
516
|
+
"**/pnpm-workspace.yaml",
|
|
517
|
+
"**/yarn.lock",
|
|
518
|
+
"**/bun.lock",
|
|
519
|
+
"**/bun.lockb",
|
|
520
|
+
];
|
|
521
|
+
/** package.json keys that describe the package rather than shape what
|
|
522
|
+
* runs: a change confined to them does not move the manifest's unit. */
|
|
523
|
+
export const NPM_MANIFEST_METADATA_KEYS = [
|
|
524
|
+
"version",
|
|
525
|
+
"description",
|
|
526
|
+
"keywords",
|
|
527
|
+
"author",
|
|
528
|
+
"contributors",
|
|
529
|
+
"maintainers",
|
|
530
|
+
"license",
|
|
531
|
+
"repository",
|
|
532
|
+
"homepage",
|
|
533
|
+
"bugs",
|
|
534
|
+
"funding",
|
|
535
|
+
"private",
|
|
536
|
+
"publishConfig",
|
|
537
|
+
"readme",
|
|
538
|
+
"readmeFilename",
|
|
539
|
+
"gitHead",
|
|
540
|
+
"_id",
|
|
541
|
+
];
|
|
542
|
+
/** JSON with every object's keys sorted, at every depth. */
|
|
543
|
+
function canonicalJson(v) {
|
|
544
|
+
if (Array.isArray(v))
|
|
545
|
+
return `[${v.map(canonicalJson).join(",")}]`;
|
|
546
|
+
if (v && typeof v === "object") {
|
|
547
|
+
const o = v;
|
|
548
|
+
return `{${Object.keys(o).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(o[k])}`).join(",")}}`;
|
|
549
|
+
}
|
|
550
|
+
return JSON.stringify(v);
|
|
551
|
+
}
|
|
552
|
+
/** `manifest:<path>#<hash>` over a package.json minus its metadata keys,
|
|
553
|
+
* keys sorted, so formatting and a version bump leave it unchanged and a
|
|
554
|
+
* `scripts`, `exports`, `bin` or dependency change moves it. A file that
|
|
555
|
+
* is not JSON hashes as text. */
|
|
556
|
+
export function npmManifestUnit(projectPath, text) {
|
|
557
|
+
let canon = text;
|
|
558
|
+
try {
|
|
559
|
+
const doc = JSON.parse(text);
|
|
560
|
+
if (doc && typeof doc === "object" && !Array.isArray(doc)) {
|
|
561
|
+
for (const k of NPM_MANIFEST_METADATA_KEYS)
|
|
562
|
+
delete doc[k];
|
|
563
|
+
canon = canonicalJson(doc);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
catch {
|
|
567
|
+
// not JSON: the text is the fingerprint
|
|
568
|
+
}
|
|
569
|
+
const hash = createHash("sha256").update(canon).digest("hex").slice(0, 16);
|
|
570
|
+
return `manifest:${projectPath}#${hash}`;
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* The node script the inventory runs inside the container: every package
|
|
574
|
+
* installed in a `node_modules` on the path from the working directory up
|
|
575
|
+
* to `/` — hoisted (`node_modules/<name>`, `@scope/<name>`), nested, and
|
|
576
|
+
* pnpm's store (`node_modules/.pnpm/<pkg>/node_modules/<name>`, the real
|
|
577
|
+
* directories only; the symlinks beside them point back into the store).
|
|
578
|
+
* Prints one `npm:<name>@<version>` per line.
|
|
579
|
+
*/
|
|
580
|
+
export const NODE_INVENTORY_SCRIPT = `"use strict";
|
|
581
|
+
const fs = require("node:fs"), path = require("node:path");
|
|
582
|
+
const out = new Set();
|
|
583
|
+
function isRealDir(p) { try { return fs.lstatSync(p).isDirectory(); } catch (e) { return false; } }
|
|
584
|
+
function readPkg(d) {
|
|
585
|
+
try {
|
|
586
|
+
const j = JSON.parse(fs.readFileSync(path.join(d, "package.json"), "utf8"));
|
|
587
|
+
if (j && typeof j.name === "string" && j.name) out.add("npm:" + j.name + (typeof j.version === "string" && j.version ? "@" + j.version : ""));
|
|
588
|
+
} catch (e) {}
|
|
589
|
+
}
|
|
590
|
+
function visit(d, depth) {
|
|
591
|
+
readPkg(d);
|
|
592
|
+
if (depth < 3) { const nested = path.join(d, "node_modules"); if (isRealDir(nested)) walkNm(nested, depth + 1); }
|
|
593
|
+
}
|
|
594
|
+
function walkNm(nm, depth) {
|
|
595
|
+
let names; try { names = fs.readdirSync(nm); } catch (e) { return; }
|
|
596
|
+
for (const n of names) {
|
|
597
|
+
if (n === ".bin" || n === ".cache" || n === ".modules.yaml") continue;
|
|
598
|
+
if (n === ".pnpm") {
|
|
599
|
+
let ps; try { ps = fs.readdirSync(path.join(nm, ".pnpm")); } catch (e) { continue; }
|
|
600
|
+
for (const p of ps) { const inner = path.join(nm, ".pnpm", p, "node_modules"); if (isRealDir(inner)) walkNm(inner, depth + 1); }
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
if (n.startsWith("@")) {
|
|
604
|
+
let ss; try { ss = fs.readdirSync(path.join(nm, n)); } catch (e) { continue; }
|
|
605
|
+
for (const s of ss) { const d = path.join(nm, n, s); if (isRealDir(d)) visit(d, depth); }
|
|
606
|
+
} else { const d = path.join(nm, n); if (isRealDir(d)) visit(d, depth); }
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
let d = process.cwd();
|
|
610
|
+
for (;;) {
|
|
611
|
+
const nm = path.join(d, "node_modules");
|
|
612
|
+
if (isRealDir(nm)) walkNm(nm, 0);
|
|
613
|
+
const up = path.dirname(d);
|
|
614
|
+
if (up === d) break;
|
|
615
|
+
d = up;
|
|
616
|
+
}
|
|
617
|
+
process.stdout.write(Array.from(out).sort().join("\\n") + (out.size ? "\\n" : ""));
|
|
618
|
+
`;
|
|
396
619
|
export function node() {
|
|
397
620
|
return {
|
|
398
621
|
name: "node",
|
|
399
622
|
reports: "delta",
|
|
623
|
+
explains: NODE_EXPLAINS,
|
|
624
|
+
async inventory(ctx) {
|
|
625
|
+
// Installed packages, from inside the container (the script goes
|
|
626
|
+
// in through stdin so no quoting crosses the shell); manifest
|
|
627
|
+
// fingerprints from the project's own package.json files.
|
|
628
|
+
const b64 = Buffer.from(NODE_INVENTORY_SCRIPT).toString("base64");
|
|
629
|
+
const r = await ctx.exec(`echo ${b64} | base64 -d | node -`);
|
|
630
|
+
const units = new Set(r.stdout.split("\n").map((l) => l.trim()).filter((l) => l.startsWith("npm:")));
|
|
631
|
+
for (const f of await ctx.projectFiles()) {
|
|
632
|
+
if (f !== "package.json" && !f.endsWith("/package.json"))
|
|
633
|
+
continue;
|
|
634
|
+
const text = await ctx.readProjectFile(f);
|
|
635
|
+
if (text !== null)
|
|
636
|
+
units.add(npmManifestUnit(f, text));
|
|
637
|
+
}
|
|
638
|
+
return [...units].sort();
|
|
639
|
+
},
|
|
400
640
|
configure(svc) {
|
|
401
641
|
const env = appendEnvFlag(svc.env, "NODE_OPTIONS", `--require ${NODE_COVERAGE_HOOK_PATH}`);
|
|
402
642
|
env[NODE_COVERAGE_DIR_ENV] = COVERAGE_CONTAINER_DIR;
|
package/dist/daemon.js
CHANGED
|
@@ -21,10 +21,11 @@ import { existsSync, promises as fs, readFileSync } from "node:fs";
|
|
|
21
21
|
import net from "node:net";
|
|
22
22
|
import path from "node:path";
|
|
23
23
|
import { pathToFileURL } from "node:url";
|
|
24
|
+
import { ENVIRONMENT_CA_PATH as CA_PATH } from "./environment-ca.js";
|
|
24
25
|
import { assert, expect, expectRaw, lowerIngress, dnsName as makeDnsDecl, isWildcard, validateServiceImage, proxy as makeProxyDecl, } from "./index.js";
|
|
25
|
-
import { COVERAGE_CONTAINER_DIR, coverageBundleRef, coverageHostDir, encodeCoverageBundle, isEmptyReport, readCoverageDir, applyCoverageDelta, } from "./harness/coverage.js";
|
|
26
|
+
import { COVERAGE_CONTAINER_DIR, coverageBundleRef, coverageHostDir, encodeCoverageBundle, isEmptyReport, readCoverageDir, applyCoverageDelta, walkProjectFiles, } from "./harness/coverage.js";
|
|
26
27
|
import { configureBrowserCoverage } from "./browser-coverage.js";
|
|
27
|
-
import { NODE_COVERAGE_MARKERS_SUBDIR, NODE_COVERAGE_SCRIPTS_SUBDIR, applyCoverageAdapters, coverageAdapters, coverageReportsMode, validateCoverage, } from "./coverage.js";
|
|
28
|
+
import { INVENTORY_REPORT, NODE_COVERAGE_MARKERS_SUBDIR, NODE_COVERAGE_SCRIPTS_SUBDIR, applyCoverageAdapters, coverageAdapters, coverageReportsMode, validateCoverage, } from "./coverage.js";
|
|
28
29
|
import { serviceForHost as hostToService } from "./harness/browser-coverage.js";
|
|
29
30
|
import { acquirePersistentBrowser, mobileKey } from "./browser.js";
|
|
30
31
|
import { isMobileApp, openPersistentMobile } from "./mobile.js";
|
|
@@ -124,7 +125,6 @@ const BUN_BIN = "/usr/local/bin/bun";
|
|
|
124
125
|
// (see base.rs::BASE_SETUP_SH). Bind-mounted into every service
|
|
125
126
|
// container so apps can verify HTTPS to the daemon's fakes, and
|
|
126
127
|
// referenced when we layer it into each image's system trust store.
|
|
127
|
-
const CA_PATH = process.env.SPECTEST_CA_PATH ?? "/etc/spectest/ca.crt";
|
|
128
128
|
const CA_KEY_PATH = process.env.SPECTEST_CA_KEY_PATH ?? "/etc/spectest/ca.key";
|
|
129
129
|
// Public roots ⧺ the spectest CA, materialized by `ensureCaBundle` and
|
|
130
130
|
// bind-mounted into every container. `SSL_CERT_FILE`/`REQUESTS_CA_BUNDLE`/
|
|
@@ -3458,7 +3458,65 @@ async function coverageRoot(service) {
|
|
|
3458
3458
|
COVERAGE_ROOTS.set(service, root);
|
|
3459
3459
|
return root;
|
|
3460
3460
|
}
|
|
3461
|
-
|
|
3461
|
+
/** Budget for one service's inventory adapters, all of them. */
|
|
3462
|
+
const COVERAGE_INVENTORY_TIMEOUT_MS = 120_000;
|
|
3463
|
+
/**
|
|
3464
|
+
* Run one service's `inventory` adapters and write the union as the
|
|
3465
|
+
* `inventory.units` report — for the bring-up capture only: the
|
|
3466
|
+
* inventory is a property of the environment, not of a case. The file
|
|
3467
|
+
* is removed after the read so no later capture ships it again. A
|
|
3468
|
+
* failure is the capture's error, like a failing `capture`.
|
|
3469
|
+
*/
|
|
3470
|
+
async function writeCoverageInventory(svc) {
|
|
3471
|
+
const adapters = coverageAdapters(svc.coverage).filter((a) => a.inventory);
|
|
3472
|
+
if (adapters.length === 0)
|
|
3473
|
+
return undefined;
|
|
3474
|
+
const ac = new AbortController();
|
|
3475
|
+
const timer = setTimeout(() => ac.abort(), COVERAGE_INVENTORY_TIMEOUT_MS);
|
|
3476
|
+
const reportDir = coverageHostDir(WORKSPACE, svc.name);
|
|
3477
|
+
const ctx = {
|
|
3478
|
+
service: svc.name,
|
|
3479
|
+
signal: ac.signal,
|
|
3480
|
+
async exec(command) {
|
|
3481
|
+
const r = await docker(["exec", svc.name, "sh", "-c", command], COVERAGE_INVENTORY_TIMEOUT_MS);
|
|
3482
|
+
if (r.code !== 0) {
|
|
3483
|
+
const tail = capMiddle((r.stderr || r.stdout).trim(), 2000).value;
|
|
3484
|
+
throw new Error(`${JSON.stringify(command)} exited ${r.code}${tail ? `\n${tail}` : ""}`);
|
|
3485
|
+
}
|
|
3486
|
+
return { stdout: r.stdout, stderr: r.stderr };
|
|
3487
|
+
},
|
|
3488
|
+
projectFiles: () => walkProjectFiles(WORKSPACE),
|
|
3489
|
+
async readProjectFile(p) {
|
|
3490
|
+
try {
|
|
3491
|
+
return await fs.readFile(resolveProjectPath(p), "utf8");
|
|
3492
|
+
}
|
|
3493
|
+
catch {
|
|
3494
|
+
return null;
|
|
3495
|
+
}
|
|
3496
|
+
},
|
|
3497
|
+
};
|
|
3498
|
+
const units = new Set();
|
|
3499
|
+
try {
|
|
3500
|
+
for (const a of adapters) {
|
|
3501
|
+
try {
|
|
3502
|
+
for (const u of await a.inventory(ctx))
|
|
3503
|
+
units.add(u);
|
|
3504
|
+
}
|
|
3505
|
+
catch (err) {
|
|
3506
|
+
return `coverage adapter "${a.name}" on service "${svc.name}" inventory failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
3507
|
+
}
|
|
3508
|
+
}
|
|
3509
|
+
}
|
|
3510
|
+
finally {
|
|
3511
|
+
clearTimeout(timer);
|
|
3512
|
+
}
|
|
3513
|
+
const file = path.join(reportDir, INVENTORY_REPORT);
|
|
3514
|
+
await fs.mkdir(reportDir, { recursive: true });
|
|
3515
|
+
await fs.writeFile(file, [...units].sort().join("\n") + (units.size ? "\n" : ""));
|
|
3516
|
+
console.log(`[coverage] ${svc.name}: inventory of ${units.size} unit(s)`);
|
|
3517
|
+
return undefined;
|
|
3518
|
+
}
|
|
3519
|
+
async function captureServiceCoverage(baseline = false) {
|
|
3462
3520
|
const l = loaded;
|
|
3463
3521
|
if (!l)
|
|
3464
3522
|
return [];
|
|
@@ -3472,7 +3530,15 @@ async function captureServiceCoverage() {
|
|
|
3472
3530
|
const { error, deltaReports } = await runCoverageAdapters(svc);
|
|
3473
3531
|
if (error)
|
|
3474
3532
|
return { service: svc.name, reports: [], error };
|
|
3533
|
+
if (baseline) {
|
|
3534
|
+
const invError = await writeCoverageInventory(svc);
|
|
3535
|
+
if (invError)
|
|
3536
|
+
return { service: svc.name, reports: [], error: invError };
|
|
3537
|
+
}
|
|
3475
3538
|
const read = await readCoverageDir(svc.name, coverageHostDir(WORKSPACE, svc.name));
|
|
3539
|
+
if (baseline) {
|
|
3540
|
+
await fs.rm(path.join(coverageHostDir(WORKSPACE, svc.name), INVENTORY_REPORT), { force: true }).catch(() => { });
|
|
3541
|
+
}
|
|
3476
3542
|
const root = await coverageRoot(svc.name);
|
|
3477
3543
|
if (root)
|
|
3478
3544
|
read.root = root;
|
|
@@ -5408,7 +5474,7 @@ export function harnessMethods(state) {
|
|
|
5408
5474
|
// the control plane logs it.
|
|
5409
5475
|
let coverage;
|
|
5410
5476
|
try {
|
|
5411
|
-
const captures = await captureServiceCoverage();
|
|
5477
|
+
const captures = await captureServiceCoverage(true);
|
|
5412
5478
|
for (const c of captures) {
|
|
5413
5479
|
// After bring-up the code that started the service has run, so a
|
|
5414
5480
|
// report set that names no file at all is a misconfigured tool —
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** @internal Shared by certificate issuance, trust mounts and the public reader. */
|
|
2
|
+
export declare const ENVIRONMENT_CA_PATH: string;
|
|
3
|
+
/** Read the environment's public CA certificate as a PEM string.
|
|
4
|
+
* The base VM creates it before importing the environment definition, so it
|
|
5
|
+
* can be used directly in service env, file contents or runtime client options.
|
|
6
|
+
* Normal TLS trust is automatic; use this only when explicit CA data is needed.
|
|
7
|
+
*/
|
|
8
|
+
export declare function ca(): string;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
/** @internal Shared by certificate issuance, trust mounts and the public reader. */
|
|
3
|
+
export const ENVIRONMENT_CA_PATH = process.env.SPECTEST_CA_PATH ?? "/etc/spectest/ca.crt";
|
|
4
|
+
/** Read the environment's public CA certificate as a PEM string.
|
|
5
|
+
* The base VM creates it before importing the environment definition, so it
|
|
6
|
+
* can be used directly in service env, file contents or runtime client options.
|
|
7
|
+
* Normal TLS trust is automatic; use this only when explicit CA data is needed.
|
|
8
|
+
*/
|
|
9
|
+
export function ca() {
|
|
10
|
+
try {
|
|
11
|
+
return readFileSync(ENVIRONMENT_CA_PATH, "utf8");
|
|
12
|
+
}
|
|
13
|
+
catch (cause) {
|
|
14
|
+
throw new Error(`ca(): cannot read the environment CA at ${ENVIRONMENT_CA_PATH}; run this code inside a Spectest environment`, { cause });
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -136,6 +136,9 @@ export function runContainerArgs(input) {
|
|
|
136
136
|
args.push("-e", `SSL_CERT_FILE=${bundle}`);
|
|
137
137
|
args.push("-e", `REQUESTS_CA_BUNDLE=${bundle}`);
|
|
138
138
|
args.push("-e", `AWS_CA_BUNDLE=${bundle}`);
|
|
139
|
+
// libpq does not use the system trust store by default. Supply the
|
|
140
|
+
// same combined roots to psql/psycopg without changing their SSL mode.
|
|
141
|
+
args.push("-e", `PGSSLROOTCERT=${bundle}`);
|
|
139
142
|
// The system store itself, for clients that read no variable at all.
|
|
140
143
|
// Only a real bundle: the caller falls back to the bare CA when the
|
|
141
144
|
// guest's roots are unreadable, and the bare CA over the system store
|
|
@@ -57,21 +57,33 @@ export declare function hasLcovSourceFile(text: string): boolean;
|
|
|
57
57
|
export declare function isLcovShaped(text: string): boolean;
|
|
58
58
|
/**
|
|
59
59
|
* True when `text` looks like a V8 coverage JSON document — what
|
|
60
|
-
* `NODE_V8_COVERAGE` / `v8.takeCoverage()` write
|
|
61
|
-
* `
|
|
62
|
-
*
|
|
60
|
+
* `NODE_V8_COVERAGE` / `v8.takeCoverage()` write, and the shape the
|
|
61
|
+
* `node()` hook's dumps keep: an object with a `result` array of
|
|
62
|
+
* scripts. A cheap shape check, not a parse (a report can be
|
|
63
|
+
* megabytes); the control plane parses for real.
|
|
63
64
|
*/
|
|
64
65
|
export declare function isV8CoverageJson(text: string): boolean;
|
|
65
66
|
/** True when `text` is a script record the `node()` hook writes
|
|
66
67
|
* (`scripts/<id>.json`: `{ "spectestScript": { url, lineLengths,
|
|
67
68
|
* functions, sourceMap } }`), the static side of its dumps. */
|
|
68
69
|
export declare function isSpectestScriptRecord(text: string): boolean;
|
|
69
|
-
/** True when `text` is a report
|
|
70
|
-
*
|
|
71
|
-
|
|
70
|
+
/** True when `text` is a `.units` report: one unit id per non-blank
|
|
71
|
+
* line, `<scheme>:<rest>` (`npm:picocolors@1.1.1`,
|
|
72
|
+
* `manifest:packages/cli/package.json#3f9a…`). An empty file is a
|
|
73
|
+
* report that names no unit. What a unit means is the adapter's; here
|
|
74
|
+
* and on the control plane an id is an opaque string. */
|
|
75
|
+
export declare function isUnitsReport(text: string): boolean;
|
|
76
|
+
/** True when `text` is a report spectest stores: lcov, V8 JSON, a node()
|
|
77
|
+
* script record, or (by name) a `.units` list. */
|
|
78
|
+
export declare function isWellFormedReport(text: string, name?: string): boolean;
|
|
72
79
|
/** True when a well-formed report names no source file at all (an lcov
|
|
73
|
-
* with no `SF:`, a V8 document with an empty `result
|
|
80
|
+
* with no `SF:`, a V8 document with an empty `result`, a units list
|
|
81
|
+
* with no unit). */
|
|
74
82
|
export declare function isEmptyReport(text: string): boolean;
|
|
83
|
+
/** Every regular file under `root`, recursively, as `/`-separated paths
|
|
84
|
+
* relative to it, with the directories no project file lives in
|
|
85
|
+
* skipped. The harness's project file list for an adapter's inventory. */
|
|
86
|
+
export declare function walkProjectFiles(root: string): Promise<string[]>;
|
|
75
87
|
/** Every regular file under `dir`, recursively, as paths relative to it.
|
|
76
88
|
* Hidden files are skipped (a tool's own bookkeeping — `.seq`, `.lock`,
|
|
77
89
|
* a tmp file mid-write — is not a report). */
|
package/dist/harness/coverage.js
CHANGED
|
@@ -56,9 +56,10 @@ export function isLcovShaped(text) {
|
|
|
56
56
|
}
|
|
57
57
|
/**
|
|
58
58
|
* True when `text` looks like a V8 coverage JSON document — what
|
|
59
|
-
* `NODE_V8_COVERAGE` / `v8.takeCoverage()` write
|
|
60
|
-
* `
|
|
61
|
-
*
|
|
59
|
+
* `NODE_V8_COVERAGE` / `v8.takeCoverage()` write, and the shape the
|
|
60
|
+
* `node()` hook's dumps keep: an object with a `result` array of
|
|
61
|
+
* scripts. A cheap shape check, not a parse (a report can be
|
|
62
|
+
* megabytes); the control plane parses for real.
|
|
62
63
|
*/
|
|
63
64
|
export function isV8CoverageJson(text) {
|
|
64
65
|
return /^\s*\{/.test(text) && /"result"\s*:\s*\[/.test(text);
|
|
@@ -69,18 +70,60 @@ export function isV8CoverageJson(text) {
|
|
|
69
70
|
export function isSpectestScriptRecord(text) {
|
|
70
71
|
return /^\s*\{\s*"spectestScript"\s*:/.test(text);
|
|
71
72
|
}
|
|
72
|
-
/** True when `text` is a report
|
|
73
|
-
*
|
|
74
|
-
|
|
73
|
+
/** True when `text` is a `.units` report: one unit id per non-blank
|
|
74
|
+
* line, `<scheme>:<rest>` (`npm:picocolors@1.1.1`,
|
|
75
|
+
* `manifest:packages/cli/package.json#3f9a…`). An empty file is a
|
|
76
|
+
* report that names no unit. What a unit means is the adapter's; here
|
|
77
|
+
* and on the control plane an id is an opaque string. */
|
|
78
|
+
export function isUnitsReport(text) {
|
|
79
|
+
return text.split(/\r?\n/).every((l) => l.trim().length === 0 || /^[a-z][a-z0-9+.-]*:\S+$/.test(l.trim()));
|
|
80
|
+
}
|
|
81
|
+
/** True when `text` is a report spectest stores: lcov, V8 JSON, a node()
|
|
82
|
+
* script record, or (by name) a `.units` list. */
|
|
83
|
+
export function isWellFormedReport(text, name = "") {
|
|
84
|
+
if (name.endsWith(".units"))
|
|
85
|
+
return isUnitsReport(text);
|
|
75
86
|
return isLcovShaped(text) || isV8CoverageJson(text) || isSpectestScriptRecord(text);
|
|
76
87
|
}
|
|
77
88
|
/** True when a well-formed report names no source file at all (an lcov
|
|
78
|
-
* with no `SF:`, a V8 document with an empty `result
|
|
89
|
+
* with no `SF:`, a V8 document with an empty `result`, a units list
|
|
90
|
+
* with no unit). */
|
|
79
91
|
export function isEmptyReport(text) {
|
|
80
92
|
if (isV8CoverageJson(text))
|
|
81
93
|
return /"result"\s*:\s*\[\s*\]/.test(text);
|
|
94
|
+
if (isSpectestScriptRecord(text))
|
|
95
|
+
return false;
|
|
96
|
+
if (isUnitsReport(text) && !isLcovShaped(text))
|
|
97
|
+
return text.trim().length === 0;
|
|
82
98
|
return !hasLcovSourceFile(text);
|
|
83
99
|
}
|
|
100
|
+
/** Every regular file under `root`, recursively, as `/`-separated paths
|
|
101
|
+
* relative to it, with the directories no project file lives in
|
|
102
|
+
* skipped. The harness's project file list for an adapter's inventory. */
|
|
103
|
+
export async function walkProjectFiles(root) {
|
|
104
|
+
const skip = new Set(["node_modules", ".git", ".spectest", "target", "__pycache__", ".venv", "dist", "build", ".next"]);
|
|
105
|
+
const out = [];
|
|
106
|
+
async function walk(d, rel) {
|
|
107
|
+
let entries;
|
|
108
|
+
try {
|
|
109
|
+
entries = await fs.readdir(d, { withFileTypes: true });
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
for (const e of entries) {
|
|
115
|
+
if (skip.has(e.name))
|
|
116
|
+
continue;
|
|
117
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
118
|
+
if (e.isDirectory())
|
|
119
|
+
await walk(path.join(d, e.name), r);
|
|
120
|
+
else if (e.isFile())
|
|
121
|
+
out.push(r);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
await walk(root, "");
|
|
125
|
+
return out.sort();
|
|
126
|
+
}
|
|
84
127
|
/** Every regular file under `dir`, recursively, as paths relative to it.
|
|
85
128
|
* Hidden files are skipped (a tool's own bookkeeping — `.seq`, `.lock`,
|
|
86
129
|
* a tmp file mid-write — is not a report). */
|
|
@@ -143,7 +186,7 @@ export async function readCoverageDir(service, dir) {
|
|
|
143
186
|
error: `service "${service}" has more than ${COVERAGE_MAX_BYTES_PER_SERVICE >> 20} MiB of reports in ${COVERAGE_CONTAINER_DIR}/ — that is a runaway, not coverage`,
|
|
144
187
|
};
|
|
145
188
|
}
|
|
146
|
-
if (isWellFormedReport(content))
|
|
189
|
+
if (isWellFormedReport(content, name))
|
|
147
190
|
anyReport = true;
|
|
148
191
|
else
|
|
149
192
|
malformed.push(name);
|
|
@@ -153,7 +196,7 @@ export async function readCoverageDir(service, dir) {
|
|
|
153
196
|
return {
|
|
154
197
|
service,
|
|
155
198
|
reports,
|
|
156
|
-
error: `service "${service}" wrote ${reports.length} file(s) to ${COVERAGE_CONTAINER_DIR}/ but ${anyReport ? `${malformed.slice(0, 5).join(", ")} ${malformed.length === 1 ? "is" : "are"} neither` : "none is"} an lcov report
|
|
199
|
+
error: `service "${service}" wrote ${reports.length} file(s) to ${COVERAGE_CONTAINER_DIR}/ but ${anyReport ? `${malformed.slice(0, 5).join(", ")} ${malformed.length === 1 ? "is" : "are"} neither` : "none is"} an lcov report, a V8 coverage JSON document, or a \`.units\` list — only those are read`,
|
|
157
200
|
};
|
|
158
201
|
}
|
|
159
202
|
return { service, reports };
|
package/dist/index.d.ts
CHANGED
|
@@ -23,6 +23,7 @@ export type { Terminal, TerminalOpts, TerminalResult } from "./terminal.js";
|
|
|
23
23
|
import type { Terminal, TerminalOpts, TerminalResult } from "./terminal.js";
|
|
24
24
|
export { type CoverageAdapter, type CoverageCaptureContext, type CoverageConfigureInfo, type CoverageLoadContext, type ServiceCoverage, } from "./coverage.js";
|
|
25
25
|
import { type ServiceCoverage } from "./coverage.js";
|
|
26
|
+
export { ca } from "./environment-ca.js";
|
|
26
27
|
export { certificate, dnsName, proxy, provides, lowerIngress, isWildcard, SELF_SERVICE_TOKEN, } from "./ingress.js";
|
|
27
28
|
export type { CertificateDecl, DnsDecl, ProxyDecl, IngressDecl, DnsTarget, LoweredIngress, } from "./ingress.js";
|
|
28
29
|
import type { DnsTarget } from "./ingress.js";
|
package/dist/index.js
CHANGED
|
@@ -44,6 +44,7 @@ import { containsText, containsTextArray, escapeInvisible, matchesText, matchesT
|
|
|
44
44
|
import { describeUrlPattern, matchesUrl } from "./url-match.js";
|
|
45
45
|
import { applyCoverageAdapters, validateCoverage } from "./coverage.js";
|
|
46
46
|
import { resolveExistingProjectPath } from "./project-files.js";
|
|
47
|
+
export { ca } from "./environment-ca.js";
|
|
47
48
|
// Low-level ingress primitives + the framework lowering that the friendly
|
|
48
49
|
// `tls` / `hostnames` fields and `defineFake(...)` are built on. See
|
|
49
50
|
// `ingress.ts`.
|