@specific.dev/spectest 0.85.1 → 0.86.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.
package/dist/daemon.js CHANGED
@@ -15,15 +15,16 @@
15
15
  // One sandbox = one daemon. Concurrency between tests is achieved by
16
16
  // forking the sandbox; inside a single daemon we never run two tests at
17
17
  // once — that keeps stdout capture and timeouts simple.
18
+ import { preparedImages } from "./harness/prepared-images.js";
18
19
  import { execFile, spawn } from "node:child_process";
19
- import { randomUUID } from "node:crypto";
20
+ import { createHash, randomUUID } from "node:crypto";
20
21
  import { existsSync, promises as fs, readFileSync } from "node:fs";
21
22
  import net from "node:net";
22
23
  import path from "node:path";
23
24
  import { pathToFileURL } from "node:url";
24
25
  import { ENVIRONMENT_CA_PATH as CA_PATH } from "./environment-ca.js";
25
26
  import { assert, expect, expectRaw, lowerIngress, dnsName as makeDnsDecl, isWildcard, validateServiceImage, proxy as makeProxyDecl, } from "./index.js";
26
- import { COVERAGE_CONTAINER_DIR, coverageBundleRef, coverageHostDir, encodeCoverageBundle, isEmptyReport, readCoverageDir, applyCoverageDelta, walkProjectFiles, } from "./harness/coverage.js";
27
+ import { COVERAGE_CONTAINER_DIR, coverageBundleRef, coverageHostDir, encodeCoverageBundle, isEmptyReport, readCoverageDir, applyCoverageDelta, walkProjectFiles, ENVIRONMENT_SERVICE, REACH_REPORT, } from "./harness/coverage.js";
27
28
  import { configureBrowserCoverage } from "./browser-coverage.js";
28
29
  import { INVENTORY_REPORT, NODE_COVERAGE_MARKERS_SUBDIR, NODE_COVERAGE_SCRIPTS_SUBDIR, applyCoverageAdapters, coverageAdapters, coverageReportsMode, validateCoverage, } from "./coverage.js";
29
30
  import { serviceForHost as hostToService } from "./harness/browser-coverage.js";
@@ -42,7 +43,8 @@ import { resolveHostPath as resolveVolumeHostPath, sanitizeSegment, } from "./ha
42
43
  import { pollUntilReady } from "./harness/ready-poll.js";
43
44
  import { runWrapperRules } from "./harness/wrapper-rules.js";
44
45
  import { cpus } from "node:os";
45
- import { APP_DIR, WORKSPACE, resolveExistingProjectPath, resolveProjectPath } from "./project-files.js";
46
+ import { APP_DIR, WORKSPACE, resolveExistingProjectPath, resolveProjectPath, takeProjectReads } from "./project-files.js";
47
+ import { projectReach, renderReach, renderReadsLcov } from "./harness/reach.js";
46
48
  import { installFetchWrapper, isTransportError } from "./harness/fetch.js";
47
49
  import { bindInstrumentationScope, createInstrumentationScope, runInstrumented, runUninstrumented, } from "./harness/instrumentation-scope.js";
48
50
  import { encodeRegistry } from "./harness/names-registry.js";
@@ -2946,23 +2948,31 @@ function logBootstrapTimings(t) {
2946
2948
  }
2947
2949
  console.log(`[bootstrap] total ${t.totalMs}ms across ${t.services.length} service(s)`);
2948
2950
  }
2949
- async function bootstrap() {
2951
+ async function bootstrap(prepared) {
2950
2952
  // Tee everything this bootstrap logs — ours and every service `setup`
2951
2953
  // hook's — into the boot log, so GET /boot-log can explain a slow or
2952
2954
  // finished boot. Restored in the finally at the end.
2953
2955
  const restoreBootLog = captureConsole(BOOT_LOG_SINK);
2954
2956
  try {
2955
- return await bootstrapInner();
2957
+ return await bootstrapInner(prepared);
2956
2958
  }
2957
2959
  finally {
2958
2960
  restoreBootLog();
2959
2961
  }
2960
2962
  }
2961
- async function bootstrapInner() {
2963
+ async function imageInput(svc) {
2964
+ const input = svc.image.type === "registry" ? svc.image : await resolveDockerfileBuild(svc);
2965
+ return createHash("sha256").update(JSON.stringify(input)).digest("hex");
2966
+ }
2967
+ async function bootstrapInner(prepared, identity) {
2962
2968
  const bootStart = Date.now();
2963
2969
  const cfg = requireLoaded().project.environment;
2964
2970
  const services = namedServices(cfg);
2965
2971
  const timings = new Map();
2972
+ if (prepared && (prepared.version !== 1 || !prepared.images ||
2973
+ Object.keys(prepared.images).sort().join("\n") !== services.map(s => s.name).sort().join("\n"))) {
2974
+ throw new Error("prepared image manifest does not match the environment's services");
2975
+ }
2966
2976
  progressInit(services);
2967
2977
  // Build dedup is only valid within one workspace generation — a fresh
2968
2978
  // bootstrap may follow a workspace re-upload with the same dockerfile
@@ -3009,11 +3019,25 @@ async function bootstrapInner() {
3009
3019
  const tags = new Map();
3010
3020
  // Bootstrap is the only dedup scope: all its builds share one
3011
3021
  // /workspace generation (see prepareServiceImage).
3012
- const prepImage = (svc) => prepareServiceImage(svc, { dedup: true });
3022
+ const prepImage = async (svc) => {
3023
+ if (!prepared)
3024
+ return prepareServiceImage(svc, { dedup: true });
3025
+ const entry = prepared.images[svc.name];
3026
+ if (!entry || entry.input !== await imageInput(svc) || !/^sha256:[a-f0-9]{64}$/.test(entry.image)) {
3027
+ throw new Error(`prepared image inputs changed for ${svc.name}`);
3028
+ }
3029
+ const found = await docker(["image", "inspect", "--format", "{{.Id}}", entry.image], 30_000);
3030
+ if (found.code !== 0 || found.stdout.trim() !== entry.image) {
3031
+ throw new Error(`prepared image missing for ${svc.name}: ${entry.image}`);
3032
+ }
3033
+ return { tag: entry.image, buildSteps: entry.buildSteps };
3034
+ };
3013
3035
  const prep = new Map();
3036
+ const prepOffsets = new Map();
3014
3037
  for (const svc of services) {
3015
3038
  const p = (async () => {
3016
3039
  const t0 = Date.now();
3040
+ prepOffsets.set(svc.name, Date.now() - bootStart);
3017
3041
  const { tag, buildSteps } = await prepImage(svc);
3018
3042
  progressService(svc.name, { status: "prepared", detail: undefined });
3019
3043
  tags.set(svc.name, tag);
@@ -3031,6 +3055,21 @@ async function bootstrapInner() {
3031
3055
  p.catch(() => undefined);
3032
3056
  prep.set(svc.name, p);
3033
3057
  }
3058
+ if (identity !== undefined) {
3059
+ // No application services, setup hooks, ingress, or runtime state in the builder.
3060
+ await Promise.all(prep.values());
3061
+ const images = Object.create(null);
3062
+ for (const svc of services) {
3063
+ const out = await docker(["image", "inspect", "--format", "{{.Id}}", tags.get(svc.name)], 30_000);
3064
+ const image = out.stdout.trim();
3065
+ if (out.code !== 0 || !/^sha256:[a-f0-9]{64}$/.test(image)) {
3066
+ throw new Error(`cannot resolve prepared image for ${svc.name}: ${out.stderr}`);
3067
+ }
3068
+ const ti = timings.get(svc.name);
3069
+ images[svc.name] = { input: await imageInput(svc), image, prepMs: ti.prepMs, startMs: prepOffsets.get(svc.name), buildSteps: ti.buildSteps };
3070
+ }
3071
+ return { version: 1, identity, images };
3072
+ }
3034
3073
  // Ingress (fakes + service-tls proxies) comes up BEFORE services so
3035
3074
  // that any service that calls a fake URL during its own startup
3036
3075
  // probe finds it answering. Service-tls proxies will return 502
@@ -3487,8 +3526,13 @@ async function writeCoverageInventory(svc) {
3487
3526
  },
3488
3527
  projectFiles: () => walkProjectFiles(WORKSPACE),
3489
3528
  async readProjectFile(p) {
3529
+ // Not recorded as a read: an inventory fingerprinting every
3530
+ // manifest is bookkeeping, not the suite depending on the files.
3531
+ // Recording them made the setup set name every package.json as a
3532
+ // whole file, so a version bump was "ran during environment
3533
+ // setup" — a full run, the exact case the inventory exists for.
3490
3534
  try {
3491
- return await fs.readFile(resolveProjectPath(p), "utf8");
3535
+ return await fs.readFile(resolveProjectPath(p, { record: false }), "utf8");
3492
3536
  }
3493
3537
  catch {
3494
3538
  return null;
@@ -3516,6 +3560,74 @@ async function writeCoverageInventory(svc) {
3516
3560
  console.log(`[coverage] ${svc.name}: inventory of ${units.size} unit(s)`);
3517
3561
  return undefined;
3518
3562
  }
3563
+ /**
3564
+ * The project's reach (`harness/reach.ts`): every project file some
3565
+ * service's build context includes, some volume mounts, or a build
3566
+ * reads. Computed from the same resolution the builds use, once at the
3567
+ * bring-up capture. The resolution's own path lookups are bookkeeping,
3568
+ * not reads: they are taken out of the read set again afterwards.
3569
+ */
3570
+ async function computeProjectReach(all) {
3571
+ const before = takeProjectReads();
3572
+ try {
3573
+ return await computeProjectReachInner(all);
3574
+ }
3575
+ finally {
3576
+ // Discard what the resolution recorded, restore what was there.
3577
+ takeProjectReads();
3578
+ for (const p of before)
3579
+ resolveProjectPath(p);
3580
+ }
3581
+ }
3582
+ async function computeProjectReachInner(all) {
3583
+ const files = await walkProjectFiles(WORKSPACE, [".git"]);
3584
+ const sources = [];
3585
+ for (const svc of all) {
3586
+ const src = {
3587
+ volumeSources: (svc.volumes ?? []).flatMap((v) => (v.source ? [v.source] : [])),
3588
+ };
3589
+ if (svc.image.type === "dockerfile") {
3590
+ try {
3591
+ const b = await resolveDockerfileBuild(svc);
3592
+ src.context = b.context;
3593
+ src.ignore = b.ignore;
3594
+ if (typeof svc.image.path === "string")
3595
+ src.extraFiles = [svc.image.path];
3596
+ }
3597
+ catch {
3598
+ // Unresolvable here: count the whole project as its context, the
3599
+ // conservative answer.
3600
+ src.context = ".";
3601
+ src.ignore = "";
3602
+ }
3603
+ }
3604
+ sources.push(src);
3605
+ }
3606
+ return projectReach(files, sources);
3607
+ }
3608
+ /**
3609
+ * The synthetic `__environment__` capture: what the environment itself
3610
+ * knows — the project's reach (bring-up only) and the project files the
3611
+ * harness read through the SDK since the previous capture, as whole
3612
+ * lcov records. Present whenever some service opted in, so the two ride
3613
+ * the same bundle as the services' reports.
3614
+ */
3615
+ async function captureEnvironmentCoverage(all, baseline) {
3616
+ const reports = [];
3617
+ if (baseline) {
3618
+ try {
3619
+ const reach = await computeProjectReach(all);
3620
+ reports.push({ name: REACH_REPORT, content: renderReach(reach) });
3621
+ console.log(`[coverage] project reach: ${reach.length} file(s) some build or volume can carry`);
3622
+ }
3623
+ catch (err) {
3624
+ console.warn(`[coverage] project reach not computed: ${err instanceof Error ? err.message : String(err)}`);
3625
+ }
3626
+ }
3627
+ const reads = takeProjectReads();
3628
+ reports.push({ name: "reads.lcov", content: renderReadsLcov(reads) });
3629
+ return { service: ENVIRONMENT_SERVICE, reports };
3630
+ }
3519
3631
  async function captureServiceCoverage(baseline = false) {
3520
3632
  const l = loaded;
3521
3633
  if (!l)
@@ -3525,8 +3637,12 @@ async function captureServiceCoverage(baseline = false) {
3525
3637
  byName.set(s.name, s);
3526
3638
  for (const [name, s] of RUNTIME_SERVICES)
3527
3639
  byName.set(name, s);
3528
- const services = [...byName.values()].filter((s) => s.coverage !== undefined);
3529
- return Promise.all(services.map(async (svc) => {
3640
+ const all = [...byName.values()];
3641
+ const services = all.filter((s) => s.coverage !== undefined);
3642
+ if (services.length === 0)
3643
+ return [];
3644
+ const environment = captureEnvironmentCoverage(all, baseline);
3645
+ const captures = await Promise.all(services.map(async (svc) => {
3530
3646
  const { error, deltaReports } = await runCoverageAdapters(svc);
3531
3647
  if (error)
3532
3648
  return { service: svc.name, reports: [], error };
@@ -3548,6 +3664,7 @@ async function captureServiceCoverage(baseline = false) {
3548
3664
  // files and V8 JSON pass through.
3549
3665
  return applyCoverageDelta(read, coverageReportsMode(svc.coverage), deltaReports);
3550
3666
  }));
3667
+ return [...captures, await environment];
3551
3668
  }
3552
3669
  /**
3553
3670
  * Mint a session id. `idScope` (the running test's case id; `"eval"`
@@ -5337,7 +5454,7 @@ function loadedSummary(l) {
5337
5454
  */
5338
5455
  export function harnessMethods(state) {
5339
5456
  return {
5340
- health: async () => ({ ok: true }),
5457
+ health: async () => ({ ok: true, capabilities: ["prepared-images-v1"] }),
5341
5458
  // Live bootstrap progress, polled by the control plane during
5342
5459
  // `bootstrap` and streamed into the test-run row. `{}` before the
5343
5460
  // first bootstrap of this harness process.
@@ -5436,10 +5553,19 @@ export function harnessMethods(state) {
5436
5553
  }
5437
5554
  return { refs: [...refs] };
5438
5555
  },
5439
- bootstrap: async () => exclusive(state, "inFlightBootstrap", "bootstrap already in progress", async () => ({
5440
- ok: true,
5441
- timings: await bootstrap(),
5442
- })),
5556
+ prepareImages: async (params) => exclusive(state, "inFlightBootstrap", "bootstrap already in progress", async () => {
5557
+ const restore = captureConsole(BOOT_LOG_SINK);
5558
+ try {
5559
+ return await bootstrapInner(undefined, requireString(params, "identity"));
5560
+ }
5561
+ finally {
5562
+ restore();
5563
+ }
5564
+ }),
5565
+ bootstrap: async (params) => exclusive(state, "inFlightBootstrap", "bootstrap already in progress", async () => {
5566
+ const prepared = params.prepared === undefined ? undefined : preparedImages(params.prepared, params.identity, namedServices(requireLoaded().project.environment).map(s => s.name));
5567
+ return { ok: true, timings: await bootstrap(prepared) };
5568
+ }),
5443
5569
  projectSetup: async () => exclusive(state, "inFlightProjectSetup", "project-setup already in progress", () => runProjectSetup()),
5444
5570
  eval: async (params) => {
5445
5571
  const code = requireString(params, "code");
@@ -5479,7 +5605,7 @@ export function harnessMethods(state) {
5479
5605
  // After bring-up the code that started the service has run, so a
5480
5606
  // report set that names no file at all is a misconfigured tool —
5481
5607
  // visible in the boot log, never a failure (there is no case).
5482
- if (!c.error && c.reports.length > 0 && c.reports.every((r) => isEmptyReport(r.content))) {
5608
+ if (c.service !== ENVIRONMENT_SERVICE && !c.error && c.reports.length > 0 && c.reports.every((r) => isEmptyReport(r.content))) {
5483
5609
  // eslint-disable-next-line no-console
5484
5610
  console.warn(`[coverage] service "${c.service}": every report is empty after bring-up — is coverage enabled at process start?`);
5485
5611
  }
@@ -1,5 +1,11 @@
1
1
  /** Where the directory is mounted inside an opted-in container. */
2
2
  export declare const COVERAGE_CONTAINER_DIR = "/spectest/coverage";
3
+ /** The synthetic service the environment's own reports ride under: the
4
+ * project's reach (bring-up) and the project files the harness read. */
5
+ export declare const ENVIRONMENT_SERVICE = "__environment__";
6
+ /** The bring-up report listing the project's reach, one project-relative
7
+ * path per line (`harness/reach.ts`). */
8
+ export declare const REACH_REPORT = "reach.paths";
3
9
  /** Raw bytes of reports one service may ship per capture. Reports are
4
10
  * bounded by code size times chain depth, so anything past this is a
5
11
  * runaway (a tool writing a new file per request), not coverage. */
@@ -83,7 +89,7 @@ export declare function isEmptyReport(text: string): boolean;
83
89
  /** Every regular file under `root`, recursively, as `/`-separated paths
84
90
  * relative to it, with the directories no project file lives in
85
91
  * skipped. The harness's project file list for an adapter's inventory. */
86
- export declare function walkProjectFiles(root: string): Promise<string[]>;
92
+ export declare function walkProjectFiles(root: string, skipNames?: readonly string[]): Promise<string[]>;
87
93
  /** Every regular file under `dir`, recursively, as paths relative to it.
88
94
  * Hidden files are skipped (a tool's own bookkeeping — `.seq`, `.lock`,
89
95
  * a tmp file mid-write — is not a report). */
@@ -33,6 +33,12 @@ import path from "node:path";
33
33
  import { gzipSync } from "node:zlib";
34
34
  /** Where the directory is mounted inside an opted-in container. */
35
35
  export const COVERAGE_CONTAINER_DIR = "/spectest/coverage";
36
+ /** The synthetic service the environment's own reports ride under: the
37
+ * project's reach (bring-up) and the project files the harness read. */
38
+ export const ENVIRONMENT_SERVICE = "__environment__";
39
+ /** The bring-up report listing the project's reach, one project-relative
40
+ * path per line (`harness/reach.ts`). */
41
+ export const REACH_REPORT = "reach.paths";
36
42
  /** Raw bytes of reports one service may ship per capture. Reports are
37
43
  * bounded by code size times chain depth, so anything past this is a
38
44
  * runaway (a tool writing a new file per request), not coverage. */
@@ -100,8 +106,8 @@ export function isEmptyReport(text) {
100
106
  /** Every regular file under `root`, recursively, as `/`-separated paths
101
107
  * relative to it, with the directories no project file lives in
102
108
  * 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"]);
109
+ export async function walkProjectFiles(root, skipNames = ["node_modules", ".git", ".spectest", "target", "__pycache__", ".venv", "dist", "build", ".next"]) {
110
+ const skip = new Set(skipNames);
105
111
  const out = [];
106
112
  async function walk(d, rel) {
107
113
  let entries;
@@ -0,0 +1,14 @@
1
+ import type { BuildStep } from "./buildkit-progress.js";
2
+ export interface PreparedImages {
3
+ version: 1;
4
+ identity: string;
5
+ images: Record<string, {
6
+ input: string;
7
+ image: string;
8
+ prepMs: number;
9
+ startMs?: number;
10
+ buildSteps?: BuildStep[];
11
+ }>;
12
+ }
13
+ /** Build outputs are required inputs, never a hint to rebuild or pull again. */
14
+ export declare function preparedImages(value: unknown, identity: unknown, names: string[]): PreparedImages;
@@ -0,0 +1,18 @@
1
+ /** Build outputs are required inputs, never a hint to rebuild or pull again. */
2
+ export function preparedImages(value, identity, names) {
3
+ const manifest = value;
4
+ if (!manifest || manifest.version !== 1 || typeof identity !== "string" || !identity || manifest.identity !== identity) {
5
+ throw new Error("prepared image source/SDK identity mismatch");
6
+ }
7
+ if (!manifest.images || typeof manifest.images !== "object" ||
8
+ Object.keys(manifest.images).sort().join("\n") !== [...names].sort().join("\n")) {
9
+ throw new Error("prepared image manifest does not match the environment's services");
10
+ }
11
+ for (const name of names) {
12
+ const entry = manifest.images[name];
13
+ if (!entry || !/^sha256:[a-f0-9]{64}$/.test(entry.image) || !/^[a-f0-9]{64}$/.test(entry.input)) {
14
+ throw new Error(`invalid prepared image for ${name}`);
15
+ }
16
+ }
17
+ return manifest;
18
+ }
@@ -0,0 +1,39 @@
1
+ /** One compiled pattern: its regex over the context-relative path, and
2
+ * whether it is an exception (`!pattern`). */
3
+ interface Pattern {
4
+ re: RegExp;
5
+ exception: boolean;
6
+ }
7
+ /** Compile a `.dockerignore` text. */
8
+ export declare function compileDockerignore(text: string): Pattern[];
9
+ /** Is `rel` (a file path relative to the context root) excluded from the
10
+ * context? The last matching pattern decides; a pattern matching any
11
+ * parent directory of the file counts as matching the file. */
12
+ export declare function isExcluded(patterns: Pattern[], rel: string): boolean;
13
+ /** One service's contribution to the reach. */
14
+ export interface ReachSource {
15
+ /** Build context directory, project-relative (`.` for the root); absent
16
+ * for a registry image. */
17
+ context?: string;
18
+ /** The composed `.dockerignore` text of the build. */
19
+ ignore?: string;
20
+ /** Files the build reads outside the context rules: the Dockerfile
21
+ * named by `path` (project-relative). */
22
+ extraFiles?: string[];
23
+ /** Volume sources, project-relative; an absolute source is not a
24
+ * project path and is skipped. */
25
+ volumeSources?: string[];
26
+ }
27
+ /**
28
+ * The project's reach: every project file (`files`, project-relative)
29
+ * some service's build context includes or some volume mounts, plus the
30
+ * Dockerfiles the builds read. Sorted, unique.
31
+ */
32
+ export declare function projectReach(files: readonly string[], sources: readonly ReachSource[]): string[];
33
+ /** The `reach.paths` report: one path per line. */
34
+ export declare function renderReach(paths: readonly string[]): string;
35
+ /** An lcov document that names `paths` as files with no line data —
36
+ * "whole" records, which the index reads as every line unknown, so any
37
+ * change to the file selects the case that read it. */
38
+ export declare function renderReadsLcov(paths: readonly string[]): string;
39
+ export {};
@@ -0,0 +1,159 @@
1
+ // What a change to a project file can reach — the pure part.
2
+ //
3
+ // A project file affects a test only through one of three doors: it is in
4
+ // a service's **build context** (so an image may carry it), it is the
5
+ // **source of a volume** a service mounts, or a test **reads** it through
6
+ // the SDK. The first two are known from the environment config before any
7
+ // test runs; the harness computes their union here — the project's
8
+ // **reach** — and ships it with the bring-up capture (`reach.paths`, one
9
+ // project-relative path per line, under the synthetic `__environment__`
10
+ // service). The subset selector then treats a changed file outside the
11
+ // reach as one that cannot affect the suite: `site/**` behind a
12
+ // `.dockerignore`, `.github/**` outside every context. The third door is
13
+ // recorded as it happens (`project-files.ts`, `takeProjectReads`).
14
+ //
15
+ // The context of a build is what `docker build` would send: every file
16
+ // under the context directory minus what the composed `.dockerignore`
17
+ // (the project's own rules plus the service's `exclude`) matches, with
18
+ // Docker's semantics — a pattern is matched against the path relative to
19
+ // the context root, `*` and `?` stay inside one segment, `**` spans
20
+ // segments, `!` re-includes, and a pattern that matches a directory
21
+ // excludes everything below it. When in doubt the answer is "reachable":
22
+ // over-approximating the reach only costs a full run.
23
+ /** Docker's `.dockerignore` pattern → a regex over a `/`-separated path,
24
+ * after moby's `patternmatcher`. */
25
+ function compilePattern(raw) {
26
+ let p = raw.trim().replace(/\\/g, "/");
27
+ while (p.startsWith("./"))
28
+ p = p.slice(2);
29
+ p = p.replace(/^\/+/, "").replace(/\/+$/, "");
30
+ let out = "^";
31
+ let i = 0;
32
+ while (i < p.length) {
33
+ const c = p[i];
34
+ if (c === "*") {
35
+ if (p[i + 1] === "*") {
36
+ // "**": swallow a trailing separator; at the end of the pattern
37
+ // it matches everything below, elsewhere zero or more segments.
38
+ i += 2;
39
+ if (p[i] === "/")
40
+ i += 1;
41
+ out += i >= p.length ? ".*" : "(?:.*/)?";
42
+ continue;
43
+ }
44
+ out += "[^/]*";
45
+ }
46
+ else if (c === "?") {
47
+ out += "[^/]";
48
+ }
49
+ else if (c === "[") {
50
+ const close = p.indexOf("]", i);
51
+ if (close < 0) {
52
+ out += "\\[";
53
+ }
54
+ else {
55
+ out += p.slice(i, close + 1);
56
+ i = close;
57
+ }
58
+ }
59
+ else if (".+()|{}$^".includes(c)) {
60
+ out += `\\${c}`;
61
+ }
62
+ else {
63
+ out += c;
64
+ }
65
+ i += 1;
66
+ }
67
+ out += "$";
68
+ return new RegExp(out);
69
+ }
70
+ /** Compile a `.dockerignore` text. */
71
+ export function compileDockerignore(text) {
72
+ const out = [];
73
+ for (const raw of text.split(/\r?\n/)) {
74
+ const line = raw.trim();
75
+ if (line.length === 0 || line.startsWith("#"))
76
+ continue;
77
+ const exception = line.startsWith("!");
78
+ const body = exception ? line.slice(1).trim() : line;
79
+ if (body.length === 0)
80
+ continue;
81
+ out.push({ re: compilePattern(body), exception });
82
+ }
83
+ return out;
84
+ }
85
+ /** Is `rel` (a file path relative to the context root) excluded from the
86
+ * context? The last matching pattern decides; a pattern matching any
87
+ * parent directory of the file counts as matching the file. */
88
+ export function isExcluded(patterns, rel) {
89
+ const candidates = [rel];
90
+ let cut = rel.lastIndexOf("/");
91
+ while (cut > 0) {
92
+ candidates.push(rel.slice(0, cut));
93
+ cut = rel.lastIndexOf("/", cut - 1);
94
+ }
95
+ let excluded = false;
96
+ for (const pat of patterns) {
97
+ if (candidates.some((c) => pat.re.test(c)))
98
+ excluded = !pat.exception;
99
+ }
100
+ return excluded;
101
+ }
102
+ function normRel(p) {
103
+ let s = p.replace(/\\/g, "/");
104
+ while (s.startsWith("./"))
105
+ s = s.slice(2);
106
+ s = s.replace(/^\/+/, "").replace(/\/+$/, "");
107
+ return s === "." ? "" : s;
108
+ }
109
+ /**
110
+ * The project's reach: every project file (`files`, project-relative)
111
+ * some service's build context includes or some volume mounts, plus the
112
+ * Dockerfiles the builds read. Sorted, unique.
113
+ */
114
+ export function projectReach(files, sources) {
115
+ const out = new Set();
116
+ for (const s of sources) {
117
+ if (s.context !== undefined) {
118
+ const ctx = normRel(s.context);
119
+ const prefix = ctx === "" ? "" : `${ctx}/`;
120
+ const patterns = compileDockerignore(s.ignore ?? "");
121
+ for (const f of files) {
122
+ if (prefix && !f.startsWith(prefix))
123
+ continue;
124
+ const rel = f.slice(prefix.length);
125
+ // `docker build` always sends the Dockerfile and .dockerignore.
126
+ if (rel === ".dockerignore" || rel === "Dockerfile" || !isExcluded(patterns, rel))
127
+ out.add(f);
128
+ }
129
+ }
130
+ for (const e of s.extraFiles ?? []) {
131
+ const rel = normRel(e);
132
+ if (rel)
133
+ out.add(rel);
134
+ }
135
+ for (const v of s.volumeSources ?? []) {
136
+ if (v.startsWith("/"))
137
+ continue;
138
+ const src = normRel(v);
139
+ for (const f of files) {
140
+ if (f === src || f.startsWith(`${src}/`))
141
+ out.add(f);
142
+ }
143
+ }
144
+ }
145
+ return [...out].sort();
146
+ }
147
+ /** The `reach.paths` report: one path per line. */
148
+ export function renderReach(paths) {
149
+ return paths.join("\n") + (paths.length ? "\n" : "");
150
+ }
151
+ /** An lcov document that names `paths` as files with no line data —
152
+ * "whole" records, which the index reads as every line unknown, so any
153
+ * change to the file selects the case that read it. */
154
+ export function renderReadsLcov(paths) {
155
+ let out = "TN:\n";
156
+ for (const p of paths)
157
+ out += `SF:${p}\nend_of_record\n`;
158
+ return out;
159
+ }
@@ -2,6 +2,9 @@
2
2
  export declare const WORKSPACE: string;
3
3
  /** The app dir; the user's `spectest/` sits directly under it. */
4
4
  export declare const APP_DIR: string;
5
+ /** Take (and clear) the project-relative paths resolved since the last
6
+ * take. Coverage attributes them to the case as whole files. */
7
+ export declare function takeProjectReads(): string[];
5
8
  /**
6
9
  * Absolute in-VM path for a path in the user's repo. Absolute input is
7
10
  * returned as-is. A relative path resolves against the project, preferring the
@@ -11,7 +14,9 @@ export declare const APP_DIR: string;
11
14
  * the caller's own `fs` error names a real path — `readProjectFile` keeps
12
15
  * reporting ENOENT the way it always has.
13
16
  */
14
- export declare function resolveProjectPath(p: string): string;
17
+ export declare function resolveProjectPath(p: string, opts?: {
18
+ record?: boolean;
19
+ }): string;
15
20
  /** {@link resolveProjectPath}, but a missing file is an error that names every
16
21
  * place we looked. For inputs a user hands us by name — a fixture — where an
17
22
  * ENOENT on one guessed path reads as a spectest bug rather than a typo. */
@@ -49,6 +49,18 @@ function envIgnored() {
49
49
  _envIgnored = new Set(paths);
50
50
  return _envIgnored;
51
51
  }
52
+ /** Project files resolved since the last take — what a test read
53
+ * through the SDK. Module memory: forks with the environment; the
54
+ * coverage capture takes and clears it after every case, so a forked
55
+ * child starts with what its parent's capture left, which is nothing. */
56
+ const PROJECT_READS = new Set();
57
+ /** Take (and clear) the project-relative paths resolved since the last
58
+ * take. Coverage attributes them to the case as whole files. */
59
+ export function takeProjectReads() {
60
+ const out = [...PROJECT_READS].sort();
61
+ PROJECT_READS.clear();
62
+ return out;
63
+ }
52
64
  /** Repo-relative form of `p`: no leading `./`, POSIX separators. */
53
65
  function relative(p) {
54
66
  return path.normalize(p).replace(/^\.\//, "").replace(/^\/+/, "");
@@ -62,10 +74,16 @@ function relative(p) {
62
74
  * the caller's own `fs` error names a real path — `readProjectFile` keeps
63
75
  * reporting ENOENT the way it always has.
64
76
  */
65
- export function resolveProjectPath(p) {
77
+ export function resolveProjectPath(p, opts = {}) {
66
78
  if (path.isAbsolute(p))
67
79
  return p;
68
80
  const rel = relative(p);
81
+ // A read is coverage of the file for the case that made it — unless
82
+ // the caller says otherwise: the harness's own bookkeeping reads (a
83
+ // coverage inventory fingerprinting every manifest) are not the
84
+ // suite's dependence on those files.
85
+ if (opts.record !== false)
86
+ PROJECT_READS.add(rel);
69
87
  if (rel.startsWith("spectest/")) {
70
88
  const app = path.join(APP_DIR, rel);
71
89
  if (existsSync(app))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.85.1",
3
+ "version": "0.86.1",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/daemon.ts CHANGED
@@ -16,9 +16,10 @@
16
16
  // forking the sandbox; inside a single daemon we never run two tests at
17
17
  // once — that keeps stdout capture and timeouts simple.
18
18
 
19
+ import { preparedImages, type PreparedImages } from "./harness/prepared-images.js";
19
20
  import { execFile, spawn } from "node:child_process";
20
21
  import type { ChildProcess } from "node:child_process";
21
- import { randomUUID } from "node:crypto";
22
+ import { createHash, randomUUID } from "node:crypto";
22
23
  import { existsSync, promises as fs, readFileSync } from "node:fs";
23
24
  import net from "node:net";
24
25
  import path from "node:path";
@@ -45,6 +46,8 @@ import {
45
46
  readCoverageDir,
46
47
  applyCoverageDelta,
47
48
  walkProjectFiles,
49
+ ENVIRONMENT_SERVICE,
50
+ REACH_REPORT,
48
51
  type CoverageBundleRef,
49
52
  type CoverageReport,
50
53
  type ServiceCoverageCapture,
@@ -97,7 +100,8 @@ import { pollUntilReady } from "./harness/ready-poll.js";
97
100
  import { runWrapperRules } from "./harness/wrapper-rules.js";
98
101
  import type { WrapperDiagnostic } from "./harness/wrapper-rules.js";
99
102
  import { cpus, tmpdir } from "node:os";
100
- import { APP_DIR, WORKSPACE, resolveExistingProjectPath, resolveProjectPath } from "./project-files.js";
103
+ import { APP_DIR, WORKSPACE, resolveExistingProjectPath, resolveProjectPath, takeProjectReads } from "./project-files.js";
104
+ import { projectReach, renderReach, renderReadsLcov, type ReachSource } from "./harness/reach.js";
101
105
  import { installFetchWrapper, isTransportError } from "./harness/fetch.js";
102
106
  import {
103
107
  bindInstrumentationScope,
@@ -3509,23 +3513,34 @@ function logBootstrapTimings(t: BootstrapTimings): void {
3509
3513
  console.log(`[bootstrap] total ${t.totalMs}ms across ${t.services.length} service(s)`);
3510
3514
  }
3511
3515
 
3512
- async function bootstrap(): Promise<BootstrapTimings> {
3516
+ async function bootstrap(prepared?: PreparedImages): Promise<BootstrapTimings> {
3513
3517
  // Tee everything this bootstrap logs — ours and every service `setup`
3514
3518
  // hook's — into the boot log, so GET /boot-log can explain a slow or
3515
3519
  // finished boot. Restored in the finally at the end.
3516
3520
  const restoreBootLog = captureConsole(BOOT_LOG_SINK);
3517
3521
  try {
3518
- return await bootstrapInner();
3522
+ return await bootstrapInner(prepared);
3519
3523
  } finally {
3520
3524
  restoreBootLog();
3521
3525
  }
3522
3526
  }
3523
3527
 
3524
- async function bootstrapInner(): Promise<BootstrapTimings> {
3528
+ async function imageInput(svc: NamedService): Promise<string> {
3529
+ const input = svc.image.type === "registry" ? svc.image : await resolveDockerfileBuild(svc);
3530
+ return createHash("sha256").update(JSON.stringify(input)).digest("hex");
3531
+ }
3532
+
3533
+ async function bootstrapInner(prepared?: PreparedImages): Promise<BootstrapTimings>;
3534
+ async function bootstrapInner(prepared: undefined, identity: string): Promise<PreparedImages>;
3535
+ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Promise<BootstrapTimings | PreparedImages> {
3525
3536
  const bootStart = Date.now();
3526
3537
  const cfg: EnvironmentConfig = requireLoaded().project.environment;
3527
3538
  const services = namedServices(cfg);
3528
3539
  const timings = new Map<string, ServiceTiming>();
3540
+ if (prepared && (prepared.version !== 1 || !prepared.images ||
3541
+ Object.keys(prepared.images).sort().join("\n") !== services.map(s => s.name).sort().join("\n"))) {
3542
+ throw new Error("prepared image manifest does not match the environment's services");
3543
+ }
3529
3544
  progressInit(services);
3530
3545
  // Build dedup is only valid within one workspace generation — a fresh
3531
3546
  // bootstrap may follow a workspace re-upload with the same dockerfile
@@ -3576,12 +3591,24 @@ async function bootstrapInner(): Promise<BootstrapTimings> {
3576
3591
  const tags = new Map<string, string>();
3577
3592
  // Bootstrap is the only dedup scope: all its builds share one
3578
3593
  // /workspace generation (see prepareServiceImage).
3579
- const prepImage = (svc: NamedService): Promise<{ tag: string; buildSteps?: BuildStep[] }> =>
3580
- prepareServiceImage(svc, { dedup: true });
3594
+ const prepImage = async (svc: NamedService): Promise<{ tag: string; buildSteps?: BuildStep[] }> => {
3595
+ if (!prepared) return prepareServiceImage(svc, { dedup: true });
3596
+ const entry = prepared.images[svc.name];
3597
+ if (!entry || entry.input !== await imageInput(svc) || !/^sha256:[a-f0-9]{64}$/.test(entry.image)) {
3598
+ throw new Error(`prepared image inputs changed for ${svc.name}`);
3599
+ }
3600
+ const found = await docker(["image", "inspect", "--format", "{{.Id}}", entry.image], 30_000);
3601
+ if (found.code !== 0 || found.stdout.trim() !== entry.image) {
3602
+ throw new Error(`prepared image missing for ${svc.name}: ${entry.image}`);
3603
+ }
3604
+ return { tag: entry.image, buildSteps: entry.buildSteps };
3605
+ };
3581
3606
  const prep = new Map<string, Promise<void>>();
3607
+ const prepOffsets = new Map<string, number>();
3582
3608
  for (const svc of services) {
3583
3609
  const p = (async () => {
3584
3610
  const t0 = Date.now();
3611
+ prepOffsets.set(svc.name, Date.now() - bootStart);
3585
3612
  const { tag, buildSteps } = await prepImage(svc);
3586
3613
  progressService(svc.name, { status: "prepared", detail: undefined });
3587
3614
  tags.set(svc.name, tag);
@@ -3600,6 +3627,22 @@ async function bootstrapInner(): Promise<BootstrapTimings> {
3600
3627
  prep.set(svc.name, p);
3601
3628
  }
3602
3629
 
3630
+ if (identity !== undefined) {
3631
+ // No application services, setup hooks, ingress, or runtime state in the builder.
3632
+ await Promise.all(prep.values());
3633
+ const images: PreparedImages["images"] = Object.create(null);
3634
+ for (const svc of services) {
3635
+ const out = await docker(["image", "inspect", "--format", "{{.Id}}", tags.get(svc.name)!], 30_000);
3636
+ const image = out.stdout.trim();
3637
+ if (out.code !== 0 || !/^sha256:[a-f0-9]{64}$/.test(image)) {
3638
+ throw new Error(`cannot resolve prepared image for ${svc.name}: ${out.stderr}`);
3639
+ }
3640
+ const ti = timings.get(svc.name)!;
3641
+ images[svc.name] = { input: await imageInput(svc), image, prepMs: ti.prepMs, startMs: prepOffsets.get(svc.name), buildSteps: ti.buildSteps };
3642
+ }
3643
+ return { version: 1, identity, images };
3644
+ }
3645
+
3603
3646
  // Ingress (fakes + service-tls proxies) comes up BEFORE services so
3604
3647
  // that any service that calls a fake URL during its own startup
3605
3648
  // probe finds it answering. Service-tls proxies will return 502
@@ -4156,8 +4199,13 @@ async function writeCoverageInventory(svc: NamedService): Promise<string | undef
4156
4199
  },
4157
4200
  projectFiles: () => walkProjectFiles(WORKSPACE),
4158
4201
  async readProjectFile(p: string) {
4202
+ // Not recorded as a read: an inventory fingerprinting every
4203
+ // manifest is bookkeeping, not the suite depending on the files.
4204
+ // Recording them made the setup set name every package.json as a
4205
+ // whole file, so a version bump was "ran during environment
4206
+ // setup" — a full run, the exact case the inventory exists for.
4159
4207
  try {
4160
- return await fs.readFile(resolveProjectPath(p), "utf8");
4208
+ return await fs.readFile(resolveProjectPath(p, { record: false }), "utf8");
4161
4209
  } catch {
4162
4210
  return null;
4163
4211
  }
@@ -4182,14 +4230,83 @@ async function writeCoverageInventory(svc: NamedService): Promise<string | undef
4182
4230
  return undefined;
4183
4231
  }
4184
4232
 
4233
+ /**
4234
+ * The project's reach (`harness/reach.ts`): every project file some
4235
+ * service's build context includes, some volume mounts, or a build
4236
+ * reads. Computed from the same resolution the builds use, once at the
4237
+ * bring-up capture. The resolution's own path lookups are bookkeeping,
4238
+ * not reads: they are taken out of the read set again afterwards.
4239
+ */
4240
+ async function computeProjectReach(all: NamedService[]): Promise<string[]> {
4241
+ const before = takeProjectReads();
4242
+ try {
4243
+ return await computeProjectReachInner(all);
4244
+ } finally {
4245
+ // Discard what the resolution recorded, restore what was there.
4246
+ takeProjectReads();
4247
+ for (const p of before) resolveProjectPath(p);
4248
+ }
4249
+ }
4250
+
4251
+ async function computeProjectReachInner(all: NamedService[]): Promise<string[]> {
4252
+ const files = await walkProjectFiles(WORKSPACE, [".git"]);
4253
+ const sources: ReachSource[] = [];
4254
+ for (const svc of all) {
4255
+ const src: ReachSource = {
4256
+ volumeSources: (svc.volumes ?? []).flatMap((v) => (v.source ? [v.source] : [])),
4257
+ };
4258
+ if (svc.image.type === "dockerfile") {
4259
+ try {
4260
+ const b = await resolveDockerfileBuild(svc);
4261
+ src.context = b.context;
4262
+ src.ignore = b.ignore;
4263
+ if (typeof svc.image.path === "string") src.extraFiles = [svc.image.path];
4264
+ } catch {
4265
+ // Unresolvable here: count the whole project as its context, the
4266
+ // conservative answer.
4267
+ src.context = ".";
4268
+ src.ignore = "";
4269
+ }
4270
+ }
4271
+ sources.push(src);
4272
+ }
4273
+ return projectReach(files, sources);
4274
+ }
4275
+
4276
+ /**
4277
+ * The synthetic `__environment__` capture: what the environment itself
4278
+ * knows — the project's reach (bring-up only) and the project files the
4279
+ * harness read through the SDK since the previous capture, as whole
4280
+ * lcov records. Present whenever some service opted in, so the two ride
4281
+ * the same bundle as the services' reports.
4282
+ */
4283
+ async function captureEnvironmentCoverage(all: NamedService[], baseline: boolean): Promise<ServiceCoverageCapture> {
4284
+ const reports: CoverageReport[] = [];
4285
+ if (baseline) {
4286
+ try {
4287
+ const reach = await computeProjectReach(all);
4288
+ reports.push({ name: REACH_REPORT, content: renderReach(reach) });
4289
+ console.log(`[coverage] project reach: ${reach.length} file(s) some build or volume can carry`);
4290
+ } catch (err) {
4291
+ console.warn(`[coverage] project reach not computed: ${err instanceof Error ? err.message : String(err)}`);
4292
+ }
4293
+ }
4294
+ const reads = takeProjectReads();
4295
+ reports.push({ name: "reads.lcov", content: renderReadsLcov(reads) });
4296
+ return { service: ENVIRONMENT_SERVICE, reports };
4297
+ }
4298
+
4185
4299
  async function captureServiceCoverage(baseline = false): Promise<ServiceCoverageCapture[]> {
4186
4300
  const l = loaded;
4187
4301
  if (!l) return [];
4188
4302
  const byName = new Map<string, NamedService>();
4189
4303
  for (const s of namedServices(l.project.environment)) byName.set(s.name, s);
4190
4304
  for (const [name, s] of RUNTIME_SERVICES) byName.set(name, s);
4191
- const services = [...byName.values()].filter((s) => s.coverage !== undefined);
4192
- return Promise.all(
4305
+ const all = [...byName.values()];
4306
+ const services = all.filter((s) => s.coverage !== undefined);
4307
+ if (services.length === 0) return [];
4308
+ const environment = captureEnvironmentCoverage(all, baseline);
4309
+ const captures = await Promise.all(
4193
4310
  services.map(async (svc): Promise<ServiceCoverageCapture> => {
4194
4311
  const { error, deltaReports } = await runCoverageAdapters(svc);
4195
4312
  if (error) return { service: svc.name, reports: [], error };
@@ -4210,6 +4327,7 @@ async function captureServiceCoverage(baseline = false): Promise<ServiceCoverage
4210
4327
  return applyCoverageDelta(read, coverageReportsMode(svc.coverage), deltaReports);
4211
4328
  }),
4212
4329
  );
4330
+ return [...captures, await environment];
4213
4331
  }
4214
4332
 
4215
4333
  /** Per-Browser rrweb session shipped to the control plane. */
@@ -6423,7 +6541,7 @@ function loadedSummary(l: ReturnType<typeof requireLoaded>) {
6423
6541
  */
6424
6542
  export function harnessMethods(state: RouteState): MethodTable {
6425
6543
  return {
6426
- health: async () => ({ ok: true }),
6544
+ health: async () => ({ ok: true, capabilities: ["prepared-images-v1"] }),
6427
6545
 
6428
6546
  // Live bootstrap progress, polled by the control plane during
6429
6547
  // `bootstrap` and streamed into the test-run row. `{}` before the
@@ -6532,11 +6650,20 @@ export function harnessMethods(state: RouteState): MethodTable {
6532
6650
  return { refs: [...refs] };
6533
6651
  },
6534
6652
 
6535
- bootstrap: async () =>
6536
- exclusive(state, "inFlightBootstrap", "bootstrap already in progress", async () => ({
6537
- ok: true,
6538
- timings: await bootstrap(),
6539
- })),
6653
+ prepareImages: async (params) =>
6654
+ exclusive(state, "inFlightBootstrap", "bootstrap already in progress", async () => {
6655
+ const restore = captureConsole(BOOT_LOG_SINK);
6656
+ try { return await bootstrapInner(undefined, requireString(params, "identity")); }
6657
+ finally { restore(); }
6658
+ }),
6659
+
6660
+ bootstrap: async (params) =>
6661
+ exclusive(state, "inFlightBootstrap", "bootstrap already in progress", async () => {
6662
+ const prepared = params.prepared === undefined ? undefined : preparedImages(
6663
+ params.prepared, params.identity, namedServices(requireLoaded().project.environment).map(s => s.name),
6664
+ );
6665
+ return { ok: true, timings: await bootstrap(prepared) };
6666
+ }),
6540
6667
 
6541
6668
  projectSetup: async () =>
6542
6669
  exclusive(
@@ -6586,7 +6713,7 @@ export function harnessMethods(state: RouteState): MethodTable {
6586
6713
  // After bring-up the code that started the service has run, so a
6587
6714
  // report set that names no file at all is a misconfigured tool —
6588
6715
  // visible in the boot log, never a failure (there is no case).
6589
- if (!c.error && c.reports.length > 0 && c.reports.every((r) => isEmptyReport(r.content))) {
6716
+ if (c.service !== ENVIRONMENT_SERVICE && !c.error && c.reports.length > 0 && c.reports.every((r) => isEmptyReport(r.content))) {
6590
6717
  // eslint-disable-next-line no-console
6591
6718
  console.warn(
6592
6719
  `[coverage] service "${c.service}": every report is empty after bring-up — is coverage enabled at process start?`,
@@ -36,6 +36,14 @@ import { gzipSync } from "node:zlib";
36
36
  /** Where the directory is mounted inside an opted-in container. */
37
37
  export const COVERAGE_CONTAINER_DIR = "/spectest/coverage";
38
38
 
39
+ /** The synthetic service the environment's own reports ride under: the
40
+ * project's reach (bring-up) and the project files the harness read. */
41
+ export const ENVIRONMENT_SERVICE = "__environment__";
42
+
43
+ /** The bring-up report listing the project's reach, one project-relative
44
+ * path per line (`harness/reach.ts`). */
45
+ export const REACH_REPORT = "reach.paths";
46
+
39
47
  /** Raw bytes of reports one service may ship per capture. Reports are
40
48
  * bounded by code size times chain depth, so anything past this is a
41
49
  * runaway (a tool writing a new file per request), not coverage. */
@@ -153,8 +161,11 @@ export function isEmptyReport(text: string): boolean {
153
161
  /** Every regular file under `root`, recursively, as `/`-separated paths
154
162
  * relative to it, with the directories no project file lives in
155
163
  * skipped. The harness's project file list for an adapter's inventory. */
156
- export async function walkProjectFiles(root: string): Promise<string[]> {
157
- const skip = new Set(["node_modules", ".git", ".spectest", "target", "__pycache__", ".venv", "dist", "build", ".next"]);
164
+ export async function walkProjectFiles(
165
+ root: string,
166
+ skipNames: readonly string[] = ["node_modules", ".git", ".spectest", "target", "__pycache__", ".venv", "dist", "build", ".next"],
167
+ ): Promise<string[]> {
168
+ const skip = new Set(skipNames);
158
169
  const out: string[] = [];
159
170
  async function walk(d: string, rel: string): Promise<void> {
160
171
  let entries;
@@ -0,0 +1,22 @@
1
+ import { expect, test } from "bun:test";
2
+ import { preparedImages } from "./prepared-images";
3
+
4
+ const entry = { input: "a".repeat(64), image: `sha256:${"b".repeat(64)}`, prepMs: 12 };
5
+ const manifest = () => ({ version: 1, identity: "source-and-sdk", images: { api: entry } });
6
+
7
+ test("handoff pins immutable images and rejects a result from another source or SDK", () => {
8
+ expect(preparedImages(manifest(), "source-and-sdk", ["api"]).images.api.image).toBe(entry.image);
9
+ expect(() => preparedImages(manifest(), "different-source", ["api"])).toThrow(/identity/);
10
+ });
11
+
12
+ test("a missing or added service cannot silently fall back to an in-runtime build", () => {
13
+ expect(() => preparedImages(manifest(), "source-and-sdk", ["api", "worker"])).toThrow(/services/);
14
+ expect(() => preparedImages(manifest(), "source-and-sdk", [])).toThrow(/services/);
15
+ });
16
+
17
+ test("mutable image tags and malformed manifests are rejected", () => {
18
+ const m = manifest();
19
+ m.images.api = { ...entry, image: "spectest/api:latest" };
20
+ expect(() => preparedImages(m, "source-and-sdk", ["api"])).toThrow(/invalid/);
21
+ expect(() => preparedImages(null, "source-and-sdk", ["api"])).toThrow();
22
+ });
@@ -0,0 +1,26 @@
1
+ import type { BuildStep } from "./buildkit-progress.js";
2
+
3
+ export interface PreparedImages {
4
+ version: 1;
5
+ identity: string;
6
+ images: Record<string, { input: string; image: string; prepMs: number; startMs?: number; buildSteps?: BuildStep[] }>;
7
+ }
8
+
9
+ /** Build outputs are required inputs, never a hint to rebuild or pull again. */
10
+ export function preparedImages(value: unknown, identity: unknown, names: string[]): PreparedImages {
11
+ const manifest = value as PreparedImages | null;
12
+ if (!manifest || manifest.version !== 1 || typeof identity !== "string" || !identity || manifest.identity !== identity) {
13
+ throw new Error("prepared image source/SDK identity mismatch");
14
+ }
15
+ if (!manifest.images || typeof manifest.images !== "object" ||
16
+ Object.keys(manifest.images).sort().join("\n") !== [...names].sort().join("\n")) {
17
+ throw new Error("prepared image manifest does not match the environment's services");
18
+ }
19
+ for (const name of names) {
20
+ const entry = manifest.images[name];
21
+ if (!entry || !/^sha256:[a-f0-9]{64}$/.test(entry.image) || !/^[a-f0-9]{64}$/.test(entry.input)) {
22
+ throw new Error(`invalid prepared image for ${name}`);
23
+ }
24
+ }
25
+ return manifest;
26
+ }
@@ -0,0 +1,69 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { compileDockerignore, isExcluded, projectReach, renderReadsLcov } from "./reach.js";
3
+
4
+ describe("dockerignore semantics", () => {
5
+ const ex = (text: string, rel: string) => isExcluded(compileDockerignore(text), rel);
6
+ test("a bare name matches only at the context root; **/ matches at any depth", () => {
7
+ expect(ex("node_modules", "node_modules/x.js")).toBe(true);
8
+ expect(ex("node_modules", "packages/a/node_modules/x.js")).toBe(false);
9
+ expect(ex("**/node_modules/**", "packages/a/node_modules/x.js")).toBe(true);
10
+ expect(ex("**/node_modules", "packages/a/node_modules/x.js")).toBe(true);
11
+ });
12
+ test("site/** excludes the tree; a directory match covers every file below", () => {
13
+ expect(ex("site/**", "site/app/page.tsx")).toBe(true);
14
+ expect(ex("site", "site/app/page.tsx")).toBe(true);
15
+ expect(ex("site/**", "sites/x")).toBe(false);
16
+ expect(ex("docs", "docs.md")).toBe(false);
17
+ });
18
+ test("* and ? stay within a segment; [] classes; ! re-includes, last match wins", () => {
19
+ expect(ex("*.md", "README.md")).toBe(true);
20
+ expect(ex("*.md", "docs/README.md")).toBe(false);
21
+ expect(ex("**/*.md", "docs/README.md")).toBe(true);
22
+ expect(ex("a?c", "abc")).toBe(true);
23
+ expect(ex("a?c", "a/c")).toBe(false);
24
+ expect(ex("[ab]x", "ax")).toBe(true);
25
+ expect(ex("docs/**\n!docs/keep.md", "docs/keep.md")).toBe(false);
26
+ expect(ex("docs/**\n!docs/keep.md", "docs/other.md")).toBe(true);
27
+ expect(ex("!docs/keep.md\ndocs/**", "docs/keep.md")).toBe(true);
28
+ });
29
+ test("comments, blanks, ./ and leading slashes", () => {
30
+ expect(ex("# a comment\n\n./site/**\n", "site/x")).toBe(true);
31
+ expect(ex("/site/**", "site/x")).toBe(true);
32
+ });
33
+ });
34
+
35
+ describe("projectReach", () => {
36
+ const files = [
37
+ "package.json",
38
+ "packages/api/src/a.ts",
39
+ "packages/api/Dockerfile",
40
+ "packages/dashboard/src/b.tsx",
41
+ "site/app/page.tsx",
42
+ ".github/workflows/ci.yml",
43
+ "docs/x.md",
44
+ "spectest/index.ts",
45
+ "data/seed.sql",
46
+ ];
47
+ test("a root context minus its ignore, a narrowed context, a Dockerfile path, a volume source", () => {
48
+ const reach = projectReach(files, [
49
+ { context: ".", ignore: "site/**\ndocs/**\n", extraFiles: ["packages/api/Dockerfile"] },
50
+ { context: "packages/dashboard", ignore: "" },
51
+ { volumeSources: ["data", "/var/run/docker.sock"] },
52
+ ]);
53
+ expect(reach).toEqual([
54
+ ".github/workflows/ci.yml",
55
+ "data/seed.sql",
56
+ "package.json",
57
+ "packages/api/Dockerfile",
58
+ "packages/api/src/a.ts",
59
+ "packages/dashboard/src/b.tsx",
60
+ "spectest/index.ts",
61
+ ]);
62
+ });
63
+ test("a narrowed context alone reaches only its subtree", () => {
64
+ expect(projectReach(files, [{ context: "packages/dashboard", ignore: "" }])).toEqual(["packages/dashboard/src/b.tsx"]);
65
+ });
66
+ test("renderReadsLcov names whole files", () => {
67
+ expect(renderReadsLcov(["docs/x.md"])).toBe("TN:\nSF:docs/x.md\nend_of_record\n");
68
+ });
69
+ });
@@ -0,0 +1,171 @@
1
+ // What a change to a project file can reach — the pure part.
2
+ //
3
+ // A project file affects a test only through one of three doors: it is in
4
+ // a service's **build context** (so an image may carry it), it is the
5
+ // **source of a volume** a service mounts, or a test **reads** it through
6
+ // the SDK. The first two are known from the environment config before any
7
+ // test runs; the harness computes their union here — the project's
8
+ // **reach** — and ships it with the bring-up capture (`reach.paths`, one
9
+ // project-relative path per line, under the synthetic `__environment__`
10
+ // service). The subset selector then treats a changed file outside the
11
+ // reach as one that cannot affect the suite: `site/**` behind a
12
+ // `.dockerignore`, `.github/**` outside every context. The third door is
13
+ // recorded as it happens (`project-files.ts`, `takeProjectReads`).
14
+ //
15
+ // The context of a build is what `docker build` would send: every file
16
+ // under the context directory minus what the composed `.dockerignore`
17
+ // (the project's own rules plus the service's `exclude`) matches, with
18
+ // Docker's semantics — a pattern is matched against the path relative to
19
+ // the context root, `*` and `?` stay inside one segment, `**` spans
20
+ // segments, `!` re-includes, and a pattern that matches a directory
21
+ // excludes everything below it. When in doubt the answer is "reachable":
22
+ // over-approximating the reach only costs a full run.
23
+
24
+ /** One compiled pattern: its regex over the context-relative path, and
25
+ * whether it is an exception (`!pattern`). */
26
+ interface Pattern {
27
+ re: RegExp;
28
+ exception: boolean;
29
+ }
30
+
31
+ /** Docker's `.dockerignore` pattern → a regex over a `/`-separated path,
32
+ * after moby's `patternmatcher`. */
33
+ function compilePattern(raw: string): RegExp {
34
+ let p = raw.trim().replace(/\\/g, "/");
35
+ while (p.startsWith("./")) p = p.slice(2);
36
+ p = p.replace(/^\/+/, "").replace(/\/+$/, "");
37
+ let out = "^";
38
+ let i = 0;
39
+ while (i < p.length) {
40
+ const c = p[i]!;
41
+ if (c === "*") {
42
+ if (p[i + 1] === "*") {
43
+ // "**": swallow a trailing separator; at the end of the pattern
44
+ // it matches everything below, elsewhere zero or more segments.
45
+ i += 2;
46
+ if (p[i] === "/") i += 1;
47
+ out += i >= p.length ? ".*" : "(?:.*/)?";
48
+ continue;
49
+ }
50
+ out += "[^/]*";
51
+ } else if (c === "?") {
52
+ out += "[^/]";
53
+ } else if (c === "[") {
54
+ const close = p.indexOf("]", i);
55
+ if (close < 0) {
56
+ out += "\\[";
57
+ } else {
58
+ out += p.slice(i, close + 1);
59
+ i = close;
60
+ }
61
+ } else if (".+()|{}$^".includes(c)) {
62
+ out += `\\${c}`;
63
+ } else {
64
+ out += c;
65
+ }
66
+ i += 1;
67
+ }
68
+ out += "$";
69
+ return new RegExp(out);
70
+ }
71
+
72
+ /** Compile a `.dockerignore` text. */
73
+ export function compileDockerignore(text: string): Pattern[] {
74
+ const out: Pattern[] = [];
75
+ for (const raw of text.split(/\r?\n/)) {
76
+ const line = raw.trim();
77
+ if (line.length === 0 || line.startsWith("#")) continue;
78
+ const exception = line.startsWith("!");
79
+ const body = exception ? line.slice(1).trim() : line;
80
+ if (body.length === 0) continue;
81
+ out.push({ re: compilePattern(body), exception });
82
+ }
83
+ return out;
84
+ }
85
+
86
+ /** Is `rel` (a file path relative to the context root) excluded from the
87
+ * context? The last matching pattern decides; a pattern matching any
88
+ * parent directory of the file counts as matching the file. */
89
+ export function isExcluded(patterns: Pattern[], rel: string): boolean {
90
+ const candidates: string[] = [rel];
91
+ let cut = rel.lastIndexOf("/");
92
+ while (cut > 0) {
93
+ candidates.push(rel.slice(0, cut));
94
+ cut = rel.lastIndexOf("/", cut - 1);
95
+ }
96
+ let excluded = false;
97
+ for (const pat of patterns) {
98
+ if (candidates.some((c) => pat.re.test(c))) excluded = !pat.exception;
99
+ }
100
+ return excluded;
101
+ }
102
+
103
+ /** One service's contribution to the reach. */
104
+ export interface ReachSource {
105
+ /** Build context directory, project-relative (`.` for the root); absent
106
+ * for a registry image. */
107
+ context?: string;
108
+ /** The composed `.dockerignore` text of the build. */
109
+ ignore?: string;
110
+ /** Files the build reads outside the context rules: the Dockerfile
111
+ * named by `path` (project-relative). */
112
+ extraFiles?: string[];
113
+ /** Volume sources, project-relative; an absolute source is not a
114
+ * project path and is skipped. */
115
+ volumeSources?: string[];
116
+ }
117
+
118
+ function normRel(p: string): string {
119
+ let s = p.replace(/\\/g, "/");
120
+ while (s.startsWith("./")) s = s.slice(2);
121
+ s = s.replace(/^\/+/, "").replace(/\/+$/, "");
122
+ return s === "." ? "" : s;
123
+ }
124
+
125
+ /**
126
+ * The project's reach: every project file (`files`, project-relative)
127
+ * some service's build context includes or some volume mounts, plus the
128
+ * Dockerfiles the builds read. Sorted, unique.
129
+ */
130
+ export function projectReach(files: readonly string[], sources: readonly ReachSource[]): string[] {
131
+ const out = new Set<string>();
132
+ for (const s of sources) {
133
+ if (s.context !== undefined) {
134
+ const ctx = normRel(s.context);
135
+ const prefix = ctx === "" ? "" : `${ctx}/`;
136
+ const patterns = compileDockerignore(s.ignore ?? "");
137
+ for (const f of files) {
138
+ if (prefix && !f.startsWith(prefix)) continue;
139
+ const rel = f.slice(prefix.length);
140
+ // `docker build` always sends the Dockerfile and .dockerignore.
141
+ if (rel === ".dockerignore" || rel === "Dockerfile" || !isExcluded(patterns, rel)) out.add(f);
142
+ }
143
+ }
144
+ for (const e of s.extraFiles ?? []) {
145
+ const rel = normRel(e);
146
+ if (rel) out.add(rel);
147
+ }
148
+ for (const v of s.volumeSources ?? []) {
149
+ if (v.startsWith("/")) continue;
150
+ const src = normRel(v);
151
+ for (const f of files) {
152
+ if (f === src || f.startsWith(`${src}/`)) out.add(f);
153
+ }
154
+ }
155
+ }
156
+ return [...out].sort();
157
+ }
158
+
159
+ /** The `reach.paths` report: one path per line. */
160
+ export function renderReach(paths: readonly string[]): string {
161
+ return paths.join("\n") + (paths.length ? "\n" : "");
162
+ }
163
+
164
+ /** An lcov document that names `paths` as files with no line data —
165
+ * "whole" records, which the index reads as every line unknown, so any
166
+ * change to the file selects the case that read it. */
167
+ export function renderReadsLcov(paths: readonly string[]): string {
168
+ let out = "TN:\n";
169
+ for (const p of paths) out += `SF:${p}\nend_of_record\n`;
170
+ return out;
171
+ }
@@ -52,6 +52,20 @@ function envIgnored(): Set<string> {
52
52
  return _envIgnored;
53
53
  }
54
54
 
55
+ /** Project files resolved since the last take — what a test read
56
+ * through the SDK. Module memory: forks with the environment; the
57
+ * coverage capture takes and clears it after every case, so a forked
58
+ * child starts with what its parent's capture left, which is nothing. */
59
+ const PROJECT_READS = new Set<string>();
60
+
61
+ /** Take (and clear) the project-relative paths resolved since the last
62
+ * take. Coverage attributes them to the case as whole files. */
63
+ export function takeProjectReads(): string[] {
64
+ const out = [...PROJECT_READS].sort();
65
+ PROJECT_READS.clear();
66
+ return out;
67
+ }
68
+
55
69
  /** Repo-relative form of `p`: no leading `./`, POSIX separators. */
56
70
  function relative(p: string): string {
57
71
  return path.normalize(p).replace(/^\.\//, "").replace(/^\/+/, "");
@@ -66,9 +80,14 @@ function relative(p: string): string {
66
80
  * the caller's own `fs` error names a real path — `readProjectFile` keeps
67
81
  * reporting ENOENT the way it always has.
68
82
  */
69
- export function resolveProjectPath(p: string): string {
83
+ export function resolveProjectPath(p: string, opts: { record?: boolean } = {}): string {
70
84
  if (path.isAbsolute(p)) return p;
71
85
  const rel = relative(p);
86
+ // A read is coverage of the file for the case that made it — unless
87
+ // the caller says otherwise: the harness's own bookkeeping reads (a
88
+ // coverage inventory fingerprinting every manifest) are not the
89
+ // suite's dependence on those files.
90
+ if (opts.record !== false) PROJECT_READS.add(rel);
72
91
  if (rel.startsWith("spectest/")) {
73
92
  const app = path.join(APP_DIR, rel);
74
93
  if (existsSync(app)) return app;