@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 +142 -16
- package/dist/harness/coverage.d.ts +7 -1
- package/dist/harness/coverage.js +8 -2
- package/dist/harness/prepared-images.d.ts +14 -0
- package/dist/harness/prepared-images.js +18 -0
- package/dist/harness/reach.d.ts +39 -0
- package/dist/harness/reach.js +159 -0
- package/dist/project-files.d.ts +6 -1
- package/dist/project-files.js +19 -1
- package/package.json +1 -1
- package/src/daemon.ts +144 -17
- package/src/harness/coverage.ts +13 -2
- package/src/harness/prepared-images.test.ts +22 -0
- package/src/harness/prepared-images.ts +26 -0
- package/src/harness/reach.test.ts +69 -0
- package/src/harness/reach.ts +171 -0
- package/src/project-files.ts +20 -1
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
|
|
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) =>
|
|
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
|
|
3529
|
-
|
|
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
|
-
|
|
5440
|
-
|
|
5441
|
-
|
|
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). */
|
package/dist/harness/coverage.js
CHANGED
|
@@ -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(
|
|
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
|
+
}
|
package/dist/project-files.d.ts
CHANGED
|
@@ -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
|
|
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. */
|
package/dist/project-files.js
CHANGED
|
@@ -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
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
|
|
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
|
|
4192
|
-
|
|
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
|
-
|
|
6536
|
-
exclusive(state, "inFlightBootstrap", "bootstrap already in progress", async () =>
|
|
6537
|
-
|
|
6538
|
-
|
|
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?`,
|
package/src/harness/coverage.ts
CHANGED
|
@@ -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(
|
|
157
|
-
|
|
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
|
+
}
|
package/src/project-files.ts
CHANGED
|
@@ -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;
|