@specific.dev/spectest 0.85.1 → 0.86.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/daemon.js +66 -5
- package/dist/harness/coverage.d.ts +7 -1
- package/dist/harness/coverage.js +8 -2
- package/dist/harness/reach.d.ts +39 -0
- package/dist/harness/reach.js +159 -0
- package/dist/project-files.d.ts +3 -0
- package/dist/project-files.js +13 -0
- package/package.json +1 -1
- package/src/daemon.ts +65 -4
- package/src/harness/coverage.ts +13 -2
- package/src/harness/reach.test.ts +69 -0
- package/src/harness/reach.ts +171 -0
- package/src/project-files.ts +15 -0
package/dist/daemon.js
CHANGED
|
@@ -23,7 +23,7 @@ import path from "node:path";
|
|
|
23
23
|
import { pathToFileURL } from "node:url";
|
|
24
24
|
import { ENVIRONMENT_CA_PATH as CA_PATH } from "./environment-ca.js";
|
|
25
25
|
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";
|
|
26
|
+
import { COVERAGE_CONTAINER_DIR, coverageBundleRef, coverageHostDir, encodeCoverageBundle, isEmptyReport, readCoverageDir, applyCoverageDelta, walkProjectFiles, ENVIRONMENT_SERVICE, REACH_REPORT, } from "./harness/coverage.js";
|
|
27
27
|
import { configureBrowserCoverage } from "./browser-coverage.js";
|
|
28
28
|
import { INVENTORY_REPORT, NODE_COVERAGE_MARKERS_SUBDIR, NODE_COVERAGE_SCRIPTS_SUBDIR, applyCoverageAdapters, coverageAdapters, coverageReportsMode, validateCoverage, } from "./coverage.js";
|
|
29
29
|
import { serviceForHost as hostToService } from "./harness/browser-coverage.js";
|
|
@@ -42,7 +42,8 @@ import { resolveHostPath as resolveVolumeHostPath, sanitizeSegment, } from "./ha
|
|
|
42
42
|
import { pollUntilReady } from "./harness/ready-poll.js";
|
|
43
43
|
import { runWrapperRules } from "./harness/wrapper-rules.js";
|
|
44
44
|
import { cpus } from "node:os";
|
|
45
|
-
import { APP_DIR, WORKSPACE, resolveExistingProjectPath, resolveProjectPath } from "./project-files.js";
|
|
45
|
+
import { APP_DIR, WORKSPACE, resolveExistingProjectPath, resolveProjectPath, takeProjectReads } from "./project-files.js";
|
|
46
|
+
import { projectReach, renderReach, renderReadsLcov } from "./harness/reach.js";
|
|
46
47
|
import { installFetchWrapper, isTransportError } from "./harness/fetch.js";
|
|
47
48
|
import { bindInstrumentationScope, createInstrumentationScope, runInstrumented, runUninstrumented, } from "./harness/instrumentation-scope.js";
|
|
48
49
|
import { encodeRegistry } from "./harness/names-registry.js";
|
|
@@ -3516,6 +3517,61 @@ async function writeCoverageInventory(svc) {
|
|
|
3516
3517
|
console.log(`[coverage] ${svc.name}: inventory of ${units.size} unit(s)`);
|
|
3517
3518
|
return undefined;
|
|
3518
3519
|
}
|
|
3520
|
+
/**
|
|
3521
|
+
* The project's reach (`harness/reach.ts`): every project file some
|
|
3522
|
+
* service's build context includes, some volume mounts, or a build
|
|
3523
|
+
* reads. Computed from the same resolution the builds use, once at the
|
|
3524
|
+
* bring-up capture.
|
|
3525
|
+
*/
|
|
3526
|
+
async function computeProjectReach(all) {
|
|
3527
|
+
const files = await walkProjectFiles(WORKSPACE, [".git"]);
|
|
3528
|
+
const sources = [];
|
|
3529
|
+
for (const svc of all) {
|
|
3530
|
+
const src = {
|
|
3531
|
+
volumeSources: (svc.volumes ?? []).flatMap((v) => (v.source ? [v.source] : [])),
|
|
3532
|
+
};
|
|
3533
|
+
if (svc.image.type === "dockerfile") {
|
|
3534
|
+
try {
|
|
3535
|
+
const b = await resolveDockerfileBuild(svc);
|
|
3536
|
+
src.context = b.context;
|
|
3537
|
+
src.ignore = b.ignore;
|
|
3538
|
+
if (typeof svc.image.path === "string")
|
|
3539
|
+
src.extraFiles = [svc.image.path];
|
|
3540
|
+
}
|
|
3541
|
+
catch {
|
|
3542
|
+
// Unresolvable here: count the whole project as its context, the
|
|
3543
|
+
// conservative answer.
|
|
3544
|
+
src.context = ".";
|
|
3545
|
+
src.ignore = "";
|
|
3546
|
+
}
|
|
3547
|
+
}
|
|
3548
|
+
sources.push(src);
|
|
3549
|
+
}
|
|
3550
|
+
return projectReach(files, sources);
|
|
3551
|
+
}
|
|
3552
|
+
/**
|
|
3553
|
+
* The synthetic `__environment__` capture: what the environment itself
|
|
3554
|
+
* knows — the project's reach (bring-up only) and the project files the
|
|
3555
|
+
* harness read through the SDK since the previous capture, as whole
|
|
3556
|
+
* lcov records. Present whenever some service opted in, so the two ride
|
|
3557
|
+
* the same bundle as the services' reports.
|
|
3558
|
+
*/
|
|
3559
|
+
async function captureEnvironmentCoverage(all, baseline) {
|
|
3560
|
+
const reports = [];
|
|
3561
|
+
if (baseline) {
|
|
3562
|
+
try {
|
|
3563
|
+
const reach = await computeProjectReach(all);
|
|
3564
|
+
reports.push({ name: REACH_REPORT, content: renderReach(reach) });
|
|
3565
|
+
console.log(`[coverage] project reach: ${reach.length} file(s) some build or volume can carry`);
|
|
3566
|
+
}
|
|
3567
|
+
catch (err) {
|
|
3568
|
+
console.warn(`[coverage] project reach not computed: ${err instanceof Error ? err.message : String(err)}`);
|
|
3569
|
+
}
|
|
3570
|
+
}
|
|
3571
|
+
const reads = takeProjectReads();
|
|
3572
|
+
reports.push({ name: "reads.lcov", content: renderReadsLcov(reads) });
|
|
3573
|
+
return { service: ENVIRONMENT_SERVICE, reports };
|
|
3574
|
+
}
|
|
3519
3575
|
async function captureServiceCoverage(baseline = false) {
|
|
3520
3576
|
const l = loaded;
|
|
3521
3577
|
if (!l)
|
|
@@ -3525,8 +3581,12 @@ async function captureServiceCoverage(baseline = false) {
|
|
|
3525
3581
|
byName.set(s.name, s);
|
|
3526
3582
|
for (const [name, s] of RUNTIME_SERVICES)
|
|
3527
3583
|
byName.set(name, s);
|
|
3528
|
-
const
|
|
3529
|
-
|
|
3584
|
+
const all = [...byName.values()];
|
|
3585
|
+
const services = all.filter((s) => s.coverage !== undefined);
|
|
3586
|
+
if (services.length === 0)
|
|
3587
|
+
return [];
|
|
3588
|
+
const environment = captureEnvironmentCoverage(all, baseline);
|
|
3589
|
+
const captures = await Promise.all(services.map(async (svc) => {
|
|
3530
3590
|
const { error, deltaReports } = await runCoverageAdapters(svc);
|
|
3531
3591
|
if (error)
|
|
3532
3592
|
return { service: svc.name, reports: [], error };
|
|
@@ -3548,6 +3608,7 @@ async function captureServiceCoverage(baseline = false) {
|
|
|
3548
3608
|
// files and V8 JSON pass through.
|
|
3549
3609
|
return applyCoverageDelta(read, coverageReportsMode(svc.coverage), deltaReports);
|
|
3550
3610
|
}));
|
|
3611
|
+
return [...captures, await environment];
|
|
3551
3612
|
}
|
|
3552
3613
|
/**
|
|
3553
3614
|
* Mint a session id. `idScope` (the running test's case id; `"eval"`
|
|
@@ -5479,7 +5540,7 @@ export function harnessMethods(state) {
|
|
|
5479
5540
|
// After bring-up the code that started the service has run, so a
|
|
5480
5541
|
// report set that names no file at all is a misconfigured tool —
|
|
5481
5542
|
// 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))) {
|
|
5543
|
+
if (c.service !== ENVIRONMENT_SERVICE && !c.error && c.reports.length > 0 && c.reports.every((r) => isEmptyReport(r.content))) {
|
|
5483
5544
|
// eslint-disable-next-line no-console
|
|
5484
5545
|
console.warn(`[coverage] service "${c.service}": every report is empty after bring-up — is coverage enabled at process start?`);
|
|
5485
5546
|
}
|
|
@@ -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,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
|
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(/^\/+/, "");
|
|
@@ -66,6 +78,7 @@ export function resolveProjectPath(p) {
|
|
|
66
78
|
if (path.isAbsolute(p))
|
|
67
79
|
return p;
|
|
68
80
|
const rel = relative(p);
|
|
81
|
+
PROJECT_READS.add(rel);
|
|
69
82
|
if (rel.startsWith("spectest/")) {
|
|
70
83
|
const app = path.join(APP_DIR, rel);
|
|
71
84
|
if (existsSync(app))
|
package/package.json
CHANGED
package/src/daemon.ts
CHANGED
|
@@ -45,6 +45,8 @@ import {
|
|
|
45
45
|
readCoverageDir,
|
|
46
46
|
applyCoverageDelta,
|
|
47
47
|
walkProjectFiles,
|
|
48
|
+
ENVIRONMENT_SERVICE,
|
|
49
|
+
REACH_REPORT,
|
|
48
50
|
type CoverageBundleRef,
|
|
49
51
|
type CoverageReport,
|
|
50
52
|
type ServiceCoverageCapture,
|
|
@@ -97,7 +99,8 @@ import { pollUntilReady } from "./harness/ready-poll.js";
|
|
|
97
99
|
import { runWrapperRules } from "./harness/wrapper-rules.js";
|
|
98
100
|
import type { WrapperDiagnostic } from "./harness/wrapper-rules.js";
|
|
99
101
|
import { cpus, tmpdir } from "node:os";
|
|
100
|
-
import { APP_DIR, WORKSPACE, resolveExistingProjectPath, resolveProjectPath } from "./project-files.js";
|
|
102
|
+
import { APP_DIR, WORKSPACE, resolveExistingProjectPath, resolveProjectPath, takeProjectReads } from "./project-files.js";
|
|
103
|
+
import { projectReach, renderReach, renderReadsLcov, type ReachSource } from "./harness/reach.js";
|
|
101
104
|
import { installFetchWrapper, isTransportError } from "./harness/fetch.js";
|
|
102
105
|
import {
|
|
103
106
|
bindInstrumentationScope,
|
|
@@ -4182,14 +4185,71 @@ async function writeCoverageInventory(svc: NamedService): Promise<string | undef
|
|
|
4182
4185
|
return undefined;
|
|
4183
4186
|
}
|
|
4184
4187
|
|
|
4188
|
+
/**
|
|
4189
|
+
* The project's reach (`harness/reach.ts`): every project file some
|
|
4190
|
+
* service's build context includes, some volume mounts, or a build
|
|
4191
|
+
* reads. Computed from the same resolution the builds use, once at the
|
|
4192
|
+
* bring-up capture.
|
|
4193
|
+
*/
|
|
4194
|
+
async function computeProjectReach(all: NamedService[]): Promise<string[]> {
|
|
4195
|
+
const files = await walkProjectFiles(WORKSPACE, [".git"]);
|
|
4196
|
+
const sources: ReachSource[] = [];
|
|
4197
|
+
for (const svc of all) {
|
|
4198
|
+
const src: ReachSource = {
|
|
4199
|
+
volumeSources: (svc.volumes ?? []).flatMap((v) => (v.source ? [v.source] : [])),
|
|
4200
|
+
};
|
|
4201
|
+
if (svc.image.type === "dockerfile") {
|
|
4202
|
+
try {
|
|
4203
|
+
const b = await resolveDockerfileBuild(svc);
|
|
4204
|
+
src.context = b.context;
|
|
4205
|
+
src.ignore = b.ignore;
|
|
4206
|
+
if (typeof svc.image.path === "string") src.extraFiles = [svc.image.path];
|
|
4207
|
+
} catch {
|
|
4208
|
+
// Unresolvable here: count the whole project as its context, the
|
|
4209
|
+
// conservative answer.
|
|
4210
|
+
src.context = ".";
|
|
4211
|
+
src.ignore = "";
|
|
4212
|
+
}
|
|
4213
|
+
}
|
|
4214
|
+
sources.push(src);
|
|
4215
|
+
}
|
|
4216
|
+
return projectReach(files, sources);
|
|
4217
|
+
}
|
|
4218
|
+
|
|
4219
|
+
/**
|
|
4220
|
+
* The synthetic `__environment__` capture: what the environment itself
|
|
4221
|
+
* knows — the project's reach (bring-up only) and the project files the
|
|
4222
|
+
* harness read through the SDK since the previous capture, as whole
|
|
4223
|
+
* lcov records. Present whenever some service opted in, so the two ride
|
|
4224
|
+
* the same bundle as the services' reports.
|
|
4225
|
+
*/
|
|
4226
|
+
async function captureEnvironmentCoverage(all: NamedService[], baseline: boolean): Promise<ServiceCoverageCapture> {
|
|
4227
|
+
const reports: CoverageReport[] = [];
|
|
4228
|
+
if (baseline) {
|
|
4229
|
+
try {
|
|
4230
|
+
const reach = await computeProjectReach(all);
|
|
4231
|
+
reports.push({ name: REACH_REPORT, content: renderReach(reach) });
|
|
4232
|
+
console.log(`[coverage] project reach: ${reach.length} file(s) some build or volume can carry`);
|
|
4233
|
+
} catch (err) {
|
|
4234
|
+
console.warn(`[coverage] project reach not computed: ${err instanceof Error ? err.message : String(err)}`);
|
|
4235
|
+
}
|
|
4236
|
+
}
|
|
4237
|
+
const reads = takeProjectReads();
|
|
4238
|
+
reports.push({ name: "reads.lcov", content: renderReadsLcov(reads) });
|
|
4239
|
+
return { service: ENVIRONMENT_SERVICE, reports };
|
|
4240
|
+
}
|
|
4241
|
+
|
|
4185
4242
|
async function captureServiceCoverage(baseline = false): Promise<ServiceCoverageCapture[]> {
|
|
4186
4243
|
const l = loaded;
|
|
4187
4244
|
if (!l) return [];
|
|
4188
4245
|
const byName = new Map<string, NamedService>();
|
|
4189
4246
|
for (const s of namedServices(l.project.environment)) byName.set(s.name, s);
|
|
4190
4247
|
for (const [name, s] of RUNTIME_SERVICES) byName.set(name, s);
|
|
4191
|
-
const
|
|
4192
|
-
|
|
4248
|
+
const all = [...byName.values()];
|
|
4249
|
+
const services = all.filter((s) => s.coverage !== undefined);
|
|
4250
|
+
if (services.length === 0) return [];
|
|
4251
|
+
const environment = captureEnvironmentCoverage(all, baseline);
|
|
4252
|
+
const captures = await Promise.all(
|
|
4193
4253
|
services.map(async (svc): Promise<ServiceCoverageCapture> => {
|
|
4194
4254
|
const { error, deltaReports } = await runCoverageAdapters(svc);
|
|
4195
4255
|
if (error) return { service: svc.name, reports: [], error };
|
|
@@ -4210,6 +4270,7 @@ async function captureServiceCoverage(baseline = false): Promise<ServiceCoverage
|
|
|
4210
4270
|
return applyCoverageDelta(read, coverageReportsMode(svc.coverage), deltaReports);
|
|
4211
4271
|
}),
|
|
4212
4272
|
);
|
|
4273
|
+
return [...captures, await environment];
|
|
4213
4274
|
}
|
|
4214
4275
|
|
|
4215
4276
|
/** Per-Browser rrweb session shipped to the control plane. */
|
|
@@ -6586,7 +6647,7 @@ export function harnessMethods(state: RouteState): MethodTable {
|
|
|
6586
6647
|
// After bring-up the code that started the service has run, so a
|
|
6587
6648
|
// report set that names no file at all is a misconfigured tool —
|
|
6588
6649
|
// 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))) {
|
|
6650
|
+
if (c.service !== ENVIRONMENT_SERVICE && !c.error && c.reports.length > 0 && c.reports.every((r) => isEmptyReport(r.content))) {
|
|
6590
6651
|
// eslint-disable-next-line no-console
|
|
6591
6652
|
console.warn(
|
|
6592
6653
|
`[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,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(/^\/+/, "");
|
|
@@ -69,6 +83,7 @@ function relative(p: string): string {
|
|
|
69
83
|
export function resolveProjectPath(p: string): string {
|
|
70
84
|
if (path.isAbsolute(p)) return p;
|
|
71
85
|
const rel = relative(p);
|
|
86
|
+
PROJECT_READS.add(rel);
|
|
72
87
|
if (rel.startsWith("spectest/")) {
|
|
73
88
|
const app = path.join(APP_DIR, rel);
|
|
74
89
|
if (existsSync(app)) return app;
|