@specific.dev/spectest 0.83.0 → 0.85.1

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 (46) hide show
  1. package/dist/components/index.d.ts +1 -0
  2. package/dist/components/index.js +1 -0
  3. package/dist/components/postgres-tls.d.ts +10 -0
  4. package/dist/components/postgres-tls.js +9 -0
  5. package/dist/components/postgres.d.ts +59 -1
  6. package/dist/components/postgres.js +5 -2
  7. package/dist/components/supabase-config.d.ts +5 -0
  8. package/dist/components/supabase-config.js +12 -4
  9. package/dist/components/supabase-webhooks.d.ts +1 -0
  10. package/dist/components/supabase-webhooks.js +262 -0
  11. package/dist/components/supabase.d.ts +36 -11
  12. package/dist/components/supabase.js +75 -18
  13. package/dist/components/temporal.d.ts +89 -0
  14. package/dist/components/temporal.js +185 -0
  15. package/dist/coverage.d.ts +72 -0
  16. package/dist/coverage.js +232 -8
  17. package/dist/daemon.js +71 -5
  18. package/dist/environment-ca.d.ts +8 -0
  19. package/dist/environment-ca.js +16 -0
  20. package/dist/harness/container-run.js +3 -0
  21. package/dist/harness/coverage.d.ts +15 -4
  22. package/dist/harness/coverage.js +48 -6
  23. package/dist/index.d.ts +1 -0
  24. package/dist/index.js +1 -0
  25. package/package.json +1 -1
  26. package/src/components/index.ts +3 -0
  27. package/src/components/postgres-tls.test.ts +23 -0
  28. package/src/components/postgres-tls.ts +11 -0
  29. package/src/components/postgres.ts +8 -2
  30. package/src/components/supabase-config.ts +11 -2
  31. package/src/components/supabase-db.test.ts +64 -0
  32. package/src/components/supabase-dir.test.ts +71 -0
  33. package/src/components/supabase-webhooks.ts +263 -0
  34. package/src/components/supabase.ts +108 -28
  35. package/src/components/temporal.test.ts +86 -0
  36. package/src/components/temporal.ts +188 -0
  37. package/src/coverage.test.ts +78 -2
  38. package/src/coverage.ts +276 -8
  39. package/src/daemon.ts +68 -3
  40. package/src/environment-ca.test.ts +44 -0
  41. package/src/environment-ca.ts +20 -0
  42. package/src/harness/container-run.test.ts +14 -2
  43. package/src/harness/container-run.ts +3 -0
  44. package/src/harness/coverage.test.ts +32 -1
  45. package/src/harness/coverage.ts +43 -6
  46. 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) {
@@ -53,10 +57,14 @@ export function validateCoverage(service, cov) {
53
57
  const obj = typeof cov === "object" && cov !== null ? cov : null;
54
58
  const adapters = obj?.adapters;
55
59
  const reports = obj?.reports;
56
- const knownKeys = obj !== null && Object.keys(obj).every((k) => k === "adapters" || k === "reports");
60
+ // `explains` is what `applyCoverageAdapters` adds for the wire; the
61
+ // config is validated again after that, so it must pass here.
62
+ const explains = obj?.explains;
63
+ const knownKeys = obj !== null && Object.keys(obj).every((k) => k === "adapters" || k === "reports" || k === "explains");
57
64
  const okReports = reports === undefined || reports === "delta" || reports === "cumulative";
58
65
  const okAdapters = adapters === undefined || Array.isArray(adapters);
59
- if (obj !== null && knownKeys && okReports && okAdapters && (adapters !== undefined || reports !== undefined)) {
66
+ const okExplains = explains === undefined || (Array.isArray(explains) && explains.every((e) => typeof e === "string"));
67
+ if (obj !== null && knownKeys && okReports && okAdapters && okExplains && (adapters !== undefined || reports !== undefined)) {
60
68
  for (const a of adapters ?? []) {
61
69
  const ok = typeof a === "object" &&
62
70
  a !== null &&
@@ -65,10 +73,13 @@ export function validateCoverage(service, cov) {
65
73
  (a.reports === undefined ||
66
74
  a.reports === "delta" ||
67
75
  a.reports === "cumulative") &&
68
- ["configure", "load", "capture"].every((k) => a[k] === undefined ||
76
+ (a.explains === undefined ||
77
+ (Array.isArray(a.explains) &&
78
+ a.explains.every((e) => typeof e === "string"))) &&
79
+ ["configure", "load", "capture", "inventory"].every((k) => a[k] === undefined ||
69
80
  typeof a[k] === "function");
70
81
  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)`);
82
+ 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
83
  }
73
84
  }
74
85
  return;
@@ -97,7 +108,14 @@ export function applyCoverageAdapters(key, svc) {
97
108
  out = a.configure(out, { key });
98
109
  }
99
110
  const mode = svc.coverage.reports;
100
- const wire = { adapters: adapters.map((a) => withWireName(a)), ...(mode ? { reports: mode } : {}) };
111
+ // `explains` rides the wire config so the control plane's selector can
112
+ // read it without knowing any adapter.
113
+ const explains = [...new Set(adapters.flatMap((a) => a.explains ?? []))];
114
+ const wire = {
115
+ adapters: adapters.map((a) => withWireName(a)),
116
+ ...(mode ? { reports: mode } : {}),
117
+ ...(explains.length > 0 ? { explains } : {}),
118
+ };
101
119
  return { ...out, coverage: wire };
102
120
  }
103
121
  function withWireName(a) {
@@ -153,6 +171,12 @@ export const NODE_COVERAGE_DUMP_PREFIX = "node-";
153
171
  * counters: every dump is the delta since the previous take. No
154
172
  * signal is used: signals are claimed by frameworks (SIGUSR2 stops a
155
173
  * Temporal worker, restarts nodemon), a socket is nobody's.
174
+ * - Reports **dependencies as units**: a ran script under `node_modules`
175
+ * becomes `npm:<name>@<version>` (from the nearest package.json, or the
176
+ * path's text) in the dump's `units`; a bundled script's map sources
177
+ * under `node_modules` become `sourceUnits` beside the map in its
178
+ * record. The control plane compares unit ids by equality and never
179
+ * learns what `node_modules` is.
156
180
  * - Writes a **dump** `node-<pid>-<thread>-<ms>-<n>.json`: the app's own
157
181
  * scripts (`file://`, outside `node_modules` and `.cache`, not the
158
182
  * hook) in which some range count is above zero, and of each only the
@@ -224,6 +248,48 @@ export const NODE_COVERAGE_HOOK = `"use strict";
224
248
  function isApp(u) {
225
249
  return typeof u === "string" && u.startsWith("file://") && u.indexOf("/node_modules/") < 0 && u.indexOf("/.cache/") < 0 && u !== SELF;
226
250
  }
251
+ function isDep(u) {
252
+ return typeof u === "string" && u.startsWith("file://") && u.indexOf("/node_modules/") >= 0 && u !== SELF;
253
+ }
254
+ // A dependency's unit id, npm:<name>@<version>, from the nearest
255
+ // package.json above a path inside node_modules (read once per
256
+ // directory); when the file is not on disk (a bundled dependency whose
257
+ // sources a multi-stage image dropped), from the path's text.
258
+ const UNIT_CACHE = new Map();
259
+ function unitFromText(p) {
260
+ let m = /\\/node_modules\\/\\.pnpm\\/((?:@[^/@+]+\\+)?[^/@]+)@([^/_]+)/.exec(p);
261
+ if (m) return "npm:" + m[1].replace("+", "/") + "@" + m[2];
262
+ m = /\\/node_modules\\/(@[^/]+\\/[^/]+|[^/@.][^/]*)(?:\\/|$)/.exec(p);
263
+ return m ? "npm:" + m[1] : null;
264
+ }
265
+ function unitOf(p) {
266
+ if (p.indexOf("/node_modules/") < 0) return null;
267
+ // Walk up to the innermost node_modules boundary and no further: the
268
+ // directories above it are shared with every other package, so a
269
+ // cached answer must never sit on them.
270
+ const visited = [];
271
+ let d = path.dirname(p);
272
+ while (d && d !== "/" && path.basename(d) !== "node_modules") {
273
+ if (UNIT_CACHE.has(d)) {
274
+ const hit = UNIT_CACHE.get(d);
275
+ for (const v of visited) UNIT_CACHE.set(v, hit);
276
+ return hit;
277
+ }
278
+ visited.push(d);
279
+ try {
280
+ const pj = JSON.parse(fs.readFileSync(path.join(d, "package.json"), "utf8"));
281
+ if (pj && typeof pj.name === "string" && pj.name) {
282
+ const u = "npm:" + pj.name + (typeof pj.version === "string" && pj.version ? "@" + pj.version : "");
283
+ for (const v of visited) UNIT_CACHE.set(v, u);
284
+ return u;
285
+ }
286
+ } catch (e) {}
287
+ d = path.dirname(d);
288
+ }
289
+ const u = unitFromText(p);
290
+ for (const v of visited) UNIT_CACHE.set(v, u);
291
+ return u;
292
+ }
227
293
  function ran(f) {
228
294
  return f.ranges.some(function (r) { return r.count > 0; });
229
295
  }
@@ -279,7 +345,29 @@ export const NODE_COVERAGE_HOOK = `"use strict";
279
345
  }
280
346
  if (!data || typeof data !== "object" || !("mappings" in data)) return null;
281
347
  delete data.sourcesContent;
282
- return { url: mapUrl, data: data };
348
+ // A source inside node_modules is a dependency: its unit id rides
349
+ // beside the map (\`sourceUnits[i]\`, null for the project's own), so
350
+ // the control plane never has to know what node_modules is.
351
+ const mapDir = mapUrl ? path.dirname(url.fileURLToPath(mapUrl)) : path.dirname(scriptPath);
352
+ const root = typeof data.sourceRoot === "string" && data.sourceRoot ? data.sourceRoot : "";
353
+ const sourceUnits = (Array.isArray(data.sources) ? data.sources : []).map(function (src) {
354
+ if (typeof src !== "string") return null;
355
+ let abs;
356
+ if (src.startsWith("file://")) {
357
+ try {
358
+ abs = url.fileURLToPath(src);
359
+ } catch (e) {
360
+ return null;
361
+ }
362
+ } else if (/^[a-z][a-z0-9+.-]*:/i.test(src)) {
363
+ return src.indexOf("/node_modules/") >= 0 ? unitFromText(src) : null;
364
+ } else {
365
+ const rel = root && !src.startsWith("/") ? root.replace(/\\/$/, "") + "/" + src : src;
366
+ abs = path.resolve(mapDir, rel);
367
+ }
368
+ return abs.indexOf("/node_modules/") >= 0 ? unitOf(abs) : null;
369
+ });
370
+ return { url: mapUrl, data: data, sourceUnits: sourceUnits };
283
371
  }
284
372
  function writeAtomic(file, text) {
285
373
  const tmp = file + "." + process.pid + "-" + wt.threadId + ".tmp";
@@ -310,10 +398,19 @@ export const NODE_COVERAGE_HOOK = `"use strict";
310
398
  const res = post("Profiler.takePreciseCoverage");
311
399
  if (!res || !Array.isArray(res.result)) return;
312
400
  const kept = [];
401
+ const units = new Set();
313
402
  for (const s of res.result) {
314
- if (isApp(s.url) && Array.isArray(s.functions) && s.functions.some(ran)) kept.push(s);
403
+ if (!Array.isArray(s.functions) || !s.functions.some(ran)) continue;
404
+ if (isApp(s.url)) kept.push(s);
405
+ else if (isDep(s.url)) {
406
+ let u = null;
407
+ try {
408
+ u = unitOf(url.fileURLToPath(s.url));
409
+ } catch (e) {}
410
+ if (u) units.add(u);
411
+ }
315
412
  }
316
- if (kept.length === 0) return;
413
+ if (kept.length === 0 && units.size === 0) return;
317
414
  const fresh = [];
318
415
  for (const s of kept) {
319
416
  if (known.has(s.url)) continue;
@@ -341,6 +438,7 @@ export const NODE_COVERAGE_HOOK = `"use strict";
341
438
  // script record, joined on the extent.
342
439
  const doc = {
343
440
  spectestVersion: 2,
441
+ units: Array.from(units).sort(),
344
442
  result: kept.map(function (s) {
345
443
  return {
346
444
  url: s.url,
@@ -413,10 +511,136 @@ export const NODE_COVERAGE_HOOK = `"use strict";
413
511
  * only its own files: what an earlier capture on this branch shipped is
414
512
  * removed first (module memory, forks with the environment).
415
513
  */
514
+ /** Manifests and lockfiles whose changes the node inventory accounts for. */
515
+ export const NODE_EXPLAINS = [
516
+ "**/package.json",
517
+ "**/package-lock.json",
518
+ "**/npm-shrinkwrap.json",
519
+ "**/pnpm-lock.yaml",
520
+ "**/pnpm-workspace.yaml",
521
+ "**/yarn.lock",
522
+ "**/bun.lock",
523
+ "**/bun.lockb",
524
+ ];
525
+ /** package.json keys that describe the package rather than shape what
526
+ * runs: a change confined to them does not move the manifest's unit. */
527
+ export const NPM_MANIFEST_METADATA_KEYS = [
528
+ "version",
529
+ "description",
530
+ "keywords",
531
+ "author",
532
+ "contributors",
533
+ "maintainers",
534
+ "license",
535
+ "repository",
536
+ "homepage",
537
+ "bugs",
538
+ "funding",
539
+ "private",
540
+ "publishConfig",
541
+ "readme",
542
+ "readmeFilename",
543
+ "gitHead",
544
+ "_id",
545
+ ];
546
+ /** JSON with every object's keys sorted, at every depth. */
547
+ function canonicalJson(v) {
548
+ if (Array.isArray(v))
549
+ return `[${v.map(canonicalJson).join(",")}]`;
550
+ if (v && typeof v === "object") {
551
+ const o = v;
552
+ return `{${Object.keys(o).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(o[k])}`).join(",")}}`;
553
+ }
554
+ return JSON.stringify(v);
555
+ }
556
+ /** `manifest:<path>#<hash>` over a package.json minus its metadata keys,
557
+ * keys sorted, so formatting and a version bump leave it unchanged and a
558
+ * `scripts`, `exports`, `bin` or dependency change moves it. A file that
559
+ * is not JSON hashes as text. */
560
+ export function npmManifestUnit(projectPath, text) {
561
+ let canon = text;
562
+ try {
563
+ const doc = JSON.parse(text);
564
+ if (doc && typeof doc === "object" && !Array.isArray(doc)) {
565
+ for (const k of NPM_MANIFEST_METADATA_KEYS)
566
+ delete doc[k];
567
+ canon = canonicalJson(doc);
568
+ }
569
+ }
570
+ catch {
571
+ // not JSON: the text is the fingerprint
572
+ }
573
+ const hash = createHash("sha256").update(canon).digest("hex").slice(0, 16);
574
+ return `manifest:${projectPath}#${hash}`;
575
+ }
576
+ /**
577
+ * The node script the inventory runs inside the container: every package
578
+ * installed in a `node_modules` on the path from the working directory up
579
+ * to `/` — hoisted (`node_modules/<name>`, `@scope/<name>`), nested, and
580
+ * pnpm's store (`node_modules/.pnpm/<pkg>/node_modules/<name>`, the real
581
+ * directories only; the symlinks beside them point back into the store).
582
+ * Prints one `npm:<name>@<version>` per line.
583
+ */
584
+ export const NODE_INVENTORY_SCRIPT = `"use strict";
585
+ const fs = require("node:fs"), path = require("node:path");
586
+ const out = new Set();
587
+ function isRealDir(p) { try { return fs.lstatSync(p).isDirectory(); } catch (e) { return false; } }
588
+ function readPkg(d) {
589
+ try {
590
+ const j = JSON.parse(fs.readFileSync(path.join(d, "package.json"), "utf8"));
591
+ if (j && typeof j.name === "string" && j.name) out.add("npm:" + j.name + (typeof j.version === "string" && j.version ? "@" + j.version : ""));
592
+ } catch (e) {}
593
+ }
594
+ function visit(d, depth) {
595
+ readPkg(d);
596
+ if (depth < 3) { const nested = path.join(d, "node_modules"); if (isRealDir(nested)) walkNm(nested, depth + 1); }
597
+ }
598
+ function walkNm(nm, depth) {
599
+ let names; try { names = fs.readdirSync(nm); } catch (e) { return; }
600
+ for (const n of names) {
601
+ if (n === ".bin" || n === ".cache" || n === ".modules.yaml") continue;
602
+ if (n === ".pnpm") {
603
+ let ps; try { ps = fs.readdirSync(path.join(nm, ".pnpm")); } catch (e) { continue; }
604
+ for (const p of ps) { const inner = path.join(nm, ".pnpm", p, "node_modules"); if (isRealDir(inner)) walkNm(inner, depth + 1); }
605
+ continue;
606
+ }
607
+ if (n.startsWith("@")) {
608
+ let ss; try { ss = fs.readdirSync(path.join(nm, n)); } catch (e) { continue; }
609
+ for (const s of ss) { const d = path.join(nm, n, s); if (isRealDir(d)) visit(d, depth); }
610
+ } else { const d = path.join(nm, n); if (isRealDir(d)) visit(d, depth); }
611
+ }
612
+ }
613
+ let d = process.cwd();
614
+ for (;;) {
615
+ const nm = path.join(d, "node_modules");
616
+ if (isRealDir(nm)) walkNm(nm, 0);
617
+ const up = path.dirname(d);
618
+ if (up === d) break;
619
+ d = up;
620
+ }
621
+ process.stdout.write(Array.from(out).sort().join("\\n") + (out.size ? "\\n" : ""));
622
+ `;
416
623
  export function node() {
417
624
  return {
418
625
  name: "node",
419
626
  reports: "delta",
627
+ explains: NODE_EXPLAINS,
628
+ async inventory(ctx) {
629
+ // Installed packages, from inside the container (the script goes
630
+ // in through stdin so no quoting crosses the shell); manifest
631
+ // fingerprints from the project's own package.json files.
632
+ const b64 = Buffer.from(NODE_INVENTORY_SCRIPT).toString("base64");
633
+ const r = await ctx.exec(`echo ${b64} | base64 -d | node -`);
634
+ const units = new Set(r.stdout.split("\n").map((l) => l.trim()).filter((l) => l.startsWith("npm:")));
635
+ for (const f of await ctx.projectFiles()) {
636
+ if (f !== "package.json" && !f.endsWith("/package.json"))
637
+ continue;
638
+ const text = await ctx.readProjectFile(f);
639
+ if (text !== null)
640
+ units.add(npmManifestUnit(f, text));
641
+ }
642
+ return [...units].sort();
643
+ },
420
644
  configure(svc) {
421
645
  const env = appendEnvFlag(svc.env, "NODE_OPTIONS", `--require ${NODE_COVERAGE_HOOK_PATH}`);
422
646
  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
- async function captureServiceCoverage() {
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
@@ -67,12 +67,23 @@ export declare function isV8CoverageJson(text: string): boolean;
67
67
  * (`scripts/<id>.json`: `{ "spectestScript": { url, lineLengths,
68
68
  * functions, sourceMap } }`), the static side of its dumps. */
69
69
  export declare function isSpectestScriptRecord(text: string): boolean;
70
- /** True when `text` is a report spectest stores: lcov, V8 JSON, or a
71
- * node() script record. */
72
- export declare function isWellFormedReport(text: string): boolean;
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;
73
79
  /** True when a well-formed report names no source file at all (an lcov
74
- * 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). */
75
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[]>;
76
87
  /** Every regular file under `dir`, recursively, as paths relative to it.
77
88
  * Hidden files are skipped (a tool's own bookkeeping — `.seq`, `.lock`,
78
89
  * a tmp file mid-write — is not a report). */
@@ -70,18 +70,60 @@ export function isV8CoverageJson(text) {
70
70
  export function isSpectestScriptRecord(text) {
71
71
  return /^\s*\{\s*"spectestScript"\s*:/.test(text);
72
72
  }
73
- /** True when `text` is a report spectest stores: lcov, V8 JSON, or a
74
- * node() script record. */
75
- export function isWellFormedReport(text) {
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);
76
86
  return isLcovShaped(text) || isV8CoverageJson(text) || isSpectestScriptRecord(text);
77
87
  }
78
88
  /** True when a well-formed report names no source file at all (an lcov
79
- * 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). */
80
91
  export function isEmptyReport(text) {
81
92
  if (isV8CoverageJson(text))
82
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;
83
98
  return !hasLcovSourceFile(text);
84
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
+ }
85
127
  /** Every regular file under `dir`, recursively, as paths relative to it.
86
128
  * Hidden files are skipped (a tool's own bookkeeping — `.seq`, `.lock`,
87
129
  * a tmp file mid-write — is not a report). */
@@ -144,7 +186,7 @@ export async function readCoverageDir(service, dir) {
144
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`,
145
187
  };
146
188
  }
147
- if (isWellFormedReport(content))
189
+ if (isWellFormedReport(content, name))
148
190
  anyReport = true;
149
191
  else
150
192
  malformed.push(name);
@@ -154,7 +196,7 @@ export async function readCoverageDir(service, dir) {
154
196
  return {
155
197
  service,
156
198
  reports,
157
- 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 or a V8 coverage JSON document — only those two formats are read`,
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`,
158
200
  };
159
201
  }
160
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`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.83.0",
3
+ "version": "0.85.1",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,4 +1,7 @@
1
1
  // Reusable, pre-built service definitions for common dependencies.
2
+ export { temporal, temporalClient, type TemporalOptions, type TemporalClient,
3
+ type TemporalNamespace, type TemporalWorkerOptions, type TemporalPoller,
4
+ type TemporalSearchAttributeType, type TemporalTaskType } from "./temporal.js";
2
5
  //
3
6
  // Each component returns a fully-formed `ServiceDefinition` ready to
4
7
  // drop into an environment's `services` map. Beyond the container
@@ -0,0 +1,23 @@
1
+ import { expect, test } from "bun:test";
2
+ import { postgres } from "./postgres.js";
3
+ import { supabase } from "./supabase.js";
4
+
5
+ test("Postgres provides TLS by default, preserves custom arguments and gives the key to postgres", () => {
6
+ const db = postgres({ database: "app", user: "app", password: "app", args: ["postgres", "-c", "max_connections=200"] });
7
+ expect(db.args).toContain("max_connections=200");
8
+ expect(db.args).toContain("ssl=on");
9
+ expect(db.certificates?.[0]).toMatchObject({ user: "postgres", hostnames: ["{{SPECTEST_SERVICE}}", "{{SPECTEST_SERVICE}}.internal"] });
10
+ });
11
+
12
+ test("Postgres TLS can be disabled for custom images", () => {
13
+ const db = postgres({ database: "app", user: "app", password: "app", tls: false });
14
+ expect(db.certificates).toBeUndefined();
15
+ expect(db.args).toBeUndefined();
16
+ });
17
+
18
+ test("Supabase connection URLs verify TLS by default and encode credentials", () => {
19
+ const sb = supabase({ dbPassword: "a@b/c" });
20
+ expect(sb.dbUrl).toContain("a%40b%2Fc");
21
+ expect(sb.dbUrl).toContain("sslmode=verify-full");
22
+ expect(supabase({ db: { tls: false } }).dbUrl).not.toContain("sslmode");
23
+ });