@specific.dev/spectest 0.56.2 → 0.57.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 +139 -11
- package/dist/harness/coverage.d.ts +60 -0
- package/dist/harness/coverage.js +122 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.js +16 -0
- package/package.json +1 -1
- package/src/daemon.ts +165 -11
- package/src/harness/coverage.test.ts +88 -0
- package/src/harness/coverage.ts +173 -0
- package/src/index.ts +48 -0
package/dist/daemon.js
CHANGED
|
@@ -21,7 +21,8 @@ import { existsSync, promises as fs, readFileSync } from "node:fs";
|
|
|
21
21
|
import net from "node:net";
|
|
22
22
|
import path from "node:path";
|
|
23
23
|
import { pathToFileURL } from "node:url";
|
|
24
|
-
import { assert, expect, expectRaw, lowerIngress, dnsName as makeDnsDecl, isWildcard, proxy as makeProxyDecl, } from "./index.js";
|
|
24
|
+
import { assert, expect, expectRaw, lowerIngress, dnsName as makeDnsDecl, isWildcard, proxy as makeProxyDecl, validateCoverage, } from "./index.js";
|
|
25
|
+
import { COVERAGE_CONTAINER_DIR, coverageBundleRef, coverageHostDir, encodeCoverageBundle, readCoverageDir, } from "./harness/coverage.js";
|
|
25
26
|
import { acquirePersistentBrowser, mobileKey } from "./browser.js";
|
|
26
27
|
import { isMobileApp, openPersistentMobile } from "./mobile.js";
|
|
27
28
|
// Pure ingress hostname matching, ported out of this file (see
|
|
@@ -510,6 +511,23 @@ async function ensureVolumes(svc) {
|
|
|
510
511
|
}
|
|
511
512
|
return flags;
|
|
512
513
|
}
|
|
514
|
+
/**
|
|
515
|
+
* The coverage directory for an opted-in service: one host dir per service
|
|
516
|
+
* under the workspace (so the delta-restore teardown wipes it), mounted
|
|
517
|
+
* writable at `/spectest/coverage/`. World-writable because the container
|
|
518
|
+
* may run as any user and spectest creates the dir as root. The reports in
|
|
519
|
+
* it ride snapshots and forks like everything else on the rootfs, which is
|
|
520
|
+
* what makes each read cumulative along the test's ancestor chain.
|
|
521
|
+
*/
|
|
522
|
+
async function ensureCoverage(svc) {
|
|
523
|
+
if (svc.coverage === undefined)
|
|
524
|
+
return [];
|
|
525
|
+
validateCoverage(svc.name, svc.coverage);
|
|
526
|
+
const host = coverageHostDir(WORKSPACE, svc.name);
|
|
527
|
+
await fs.mkdir(host, { recursive: true });
|
|
528
|
+
await fs.chmod(host, 0o777);
|
|
529
|
+
return [`--volume=${host}:${COVERAGE_CONTAINER_DIR}`];
|
|
530
|
+
}
|
|
513
531
|
/** Keyed by image ID, not tag: `spectest/<svc>:latest` is retagged onto
|
|
514
532
|
* new content every rebuild, and a stale uid is silently wrong. */
|
|
515
533
|
const IMAGE_ID_TABLES = new Map();
|
|
@@ -2309,6 +2327,7 @@ async function startRuntimeService(spec) {
|
|
|
2309
2327
|
...(await ensureVolumes(svc)),
|
|
2310
2328
|
...(await ensureFiles(svc, tag)),
|
|
2311
2329
|
...(await ensureCertificates(svc, tag)),
|
|
2330
|
+
...(await ensureCoverage(svc)),
|
|
2312
2331
|
];
|
|
2313
2332
|
await runContainer(svc, tag, flags, aliases);
|
|
2314
2333
|
await waitForReady(svc);
|
|
@@ -2707,6 +2726,7 @@ async function bootstrapInner() {
|
|
|
2707
2726
|
...(await ensureVolumes(svc)),
|
|
2708
2727
|
...(await ensureFiles(svc, tag)),
|
|
2709
2728
|
...(await ensureCertificates(svc, tag)),
|
|
2729
|
+
...(await ensureCoverage(svc)),
|
|
2710
2730
|
];
|
|
2711
2731
|
const tRun = Date.now();
|
|
2712
2732
|
progressService(svc.name, { status: "starting", detail: undefined });
|
|
@@ -2827,18 +2847,37 @@ async function runProjectSetupInner() {
|
|
|
2827
2847
|
// bundle in guest RAM — entries older than the last few are always
|
|
2828
2848
|
// already-fetched leftovers frozen into some snapshot.
|
|
2829
2849
|
const REPLAY_BUNDLES = new Map();
|
|
2830
|
-
|
|
2831
|
-
|
|
2850
|
+
/** Coverage bundles, same side-channel and same lifetime rules; pulled
|
|
2851
|
+
* through `coverageChunk`. The env-setup baseline parks under the
|
|
2852
|
+
* control plane's synthetic `__setup__` case id. */
|
|
2853
|
+
const COVERAGE_BUNDLES = new Map();
|
|
2854
|
+
const PARKED_BUNDLES_MAX = 8;
|
|
2855
|
+
/** The control plane's synthetic case id for env-setup captures
|
|
2856
|
+
* (`storage::SETUP_CASE_ID`). Not a real case id — those are kebab slugs. */
|
|
2857
|
+
const SETUP_CASE_ID = "__setup__";
|
|
2858
|
+
function stashBundle(map, caseId, gz) {
|
|
2832
2859
|
// Re-insert to refresh recency (Map iterates in insertion order).
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
while (
|
|
2836
|
-
const oldest =
|
|
2860
|
+
map.delete(caseId);
|
|
2861
|
+
map.set(caseId, gz);
|
|
2862
|
+
while (map.size > PARKED_BUNDLES_MAX) {
|
|
2863
|
+
const oldest = map.keys().next().value;
|
|
2837
2864
|
if (oldest === undefined)
|
|
2838
2865
|
break;
|
|
2839
|
-
|
|
2866
|
+
map.delete(oldest);
|
|
2840
2867
|
}
|
|
2841
2868
|
}
|
|
2869
|
+
function stashReplayBundle(caseId, gz) {
|
|
2870
|
+
stashBundle(REPLAY_BUNDLES, caseId, gz);
|
|
2871
|
+
}
|
|
2872
|
+
/** Encode + park a case's coverage captures; returns the ref for the reply.
|
|
2873
|
+
* `undefined` when no service opted in, so uncovered projects ship nothing. */
|
|
2874
|
+
function parkCoverageBundle(caseId, captures) {
|
|
2875
|
+
if (captures.length === 0)
|
|
2876
|
+
return undefined;
|
|
2877
|
+
const gz = encodeCoverageBundle(caseId, captures);
|
|
2878
|
+
stashBundle(COVERAGE_BUNDLES, caseId, gz);
|
|
2879
|
+
return coverageBundleRef(gz, captures);
|
|
2880
|
+
}
|
|
2842
2881
|
// ────────────────────────────────────────────────────────────────────────
|
|
2843
2882
|
// Cumulative service logs (per-case deltas → S3, reconstructed on the web)
|
|
2844
2883
|
// ────────────────────────────────────────────────────────────────────────
|
|
@@ -2930,6 +2969,43 @@ async function captureServiceLogDeltas() {
|
|
|
2930
2969
|
};
|
|
2931
2970
|
}));
|
|
2932
2971
|
}
|
|
2972
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
2973
|
+
// Coverage capture (opt-in per service; see harness/coverage.ts)
|
|
2974
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
2975
|
+
//
|
|
2976
|
+
// Read after bring-up and after every test, on every case. The reports are
|
|
2977
|
+
// cumulative from process start and live on the rootfs, so what a child
|
|
2978
|
+
// fork reads already includes everything its ancestors ran — no markers,
|
|
2979
|
+
// no deltas (unlike the service logs above). A `command` runs in the
|
|
2980
|
+
// container first so the service writes a fresh report. The reports ship
|
|
2981
|
+
// verbatim; the server derives what it needs from the stored bytes.
|
|
2982
|
+
const COVERAGE_COMMAND_TIMEOUT_MS = 60_000;
|
|
2983
|
+
async function captureServiceCoverage() {
|
|
2984
|
+
const l = loaded;
|
|
2985
|
+
if (!l)
|
|
2986
|
+
return [];
|
|
2987
|
+
const byName = new Map();
|
|
2988
|
+
for (const s of namedServices(l.project.environment))
|
|
2989
|
+
byName.set(s.name, s);
|
|
2990
|
+
for (const [name, s] of RUNTIME_SERVICES)
|
|
2991
|
+
byName.set(name, s);
|
|
2992
|
+
const services = [...byName.values()].filter((s) => s.coverage !== undefined);
|
|
2993
|
+
return Promise.all(services.map(async (svc) => {
|
|
2994
|
+
const cov = svc.coverage;
|
|
2995
|
+
if (typeof cov === "object" && cov !== null) {
|
|
2996
|
+
const r = await docker(["exec", svc.name, "sh", "-c", cov.command], COVERAGE_COMMAND_TIMEOUT_MS);
|
|
2997
|
+
if (r.code !== 0) {
|
|
2998
|
+
const tail = capMiddle((r.stderr || r.stdout).trim(), 2000).value;
|
|
2999
|
+
return {
|
|
3000
|
+
service: svc.name,
|
|
3001
|
+
reports: [],
|
|
3002
|
+
error: `coverage command for service "${svc.name}" failed (exit ${r.code}): ${JSON.stringify(cov.command)}${tail ? `\n${tail}` : ""}`,
|
|
3003
|
+
};
|
|
3004
|
+
}
|
|
3005
|
+
}
|
|
3006
|
+
return readCoverageDir(svc.name, coverageHostDir(WORKSPACE, svc.name));
|
|
3007
|
+
}));
|
|
3008
|
+
}
|
|
2933
3009
|
/**
|
|
2934
3010
|
* Mint a session id. `idScope` (the running test's case id; `"eval"`
|
|
2935
3011
|
* for eval-context sessions) is baked in because `randomUUID()` alone
|
|
@@ -4128,6 +4204,33 @@ async function runOne(testCase) {
|
|
|
4128
4204
|
// eslint-disable-next-line no-console
|
|
4129
4205
|
console.warn("[service-logs] delta capture failed:", err);
|
|
4130
4206
|
}
|
|
4207
|
+
// Coverage for the opted-in services, read after the clock stops like
|
|
4208
|
+
// the log deltas. Unlike them it is NOT best-effort: a service that opted
|
|
4209
|
+
// in and produced nothing readable fails the case, so a hole in the
|
|
4210
|
+
// coverage data is visible where it happened rather than silently
|
|
4211
|
+
// widening every later subset run.
|
|
4212
|
+
let coverageCaptures = [];
|
|
4213
|
+
try {
|
|
4214
|
+
coverageCaptures = await captureServiceCoverage();
|
|
4215
|
+
}
|
|
4216
|
+
catch (err) {
|
|
4217
|
+
coverageCaptures = [
|
|
4218
|
+
{
|
|
4219
|
+
service: "*",
|
|
4220
|
+
reports: [],
|
|
4221
|
+
error: `coverage capture failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
4222
|
+
},
|
|
4223
|
+
];
|
|
4224
|
+
}
|
|
4225
|
+
const coverageErrors = coverageCaptures
|
|
4226
|
+
.filter((c) => c.error)
|
|
4227
|
+
.map((c) => c.error);
|
|
4228
|
+
if (coverageErrors.length > 0 && outcome.status === "passed") {
|
|
4229
|
+
outcome = {
|
|
4230
|
+
status: "failed",
|
|
4231
|
+
error: { message: coverageErrors.join("\n") },
|
|
4232
|
+
};
|
|
4233
|
+
}
|
|
4131
4234
|
const events = stopRecording();
|
|
4132
4235
|
// Drop sessions whose linking event didn't survive — an exec/terminal
|
|
4133
4236
|
// inside a failed `ctx.poll` iteration has its events removed by
|
|
@@ -4147,6 +4250,7 @@ async function runOne(testCase) {
|
|
|
4147
4250
|
browserSessions: sessions.map((s) => s.record),
|
|
4148
4251
|
terminalSessions: terminalSessions.filter((s) => referencedSessions.has(s.sessionId)),
|
|
4149
4252
|
serviceLogDeltas,
|
|
4253
|
+
coverageCaptures,
|
|
4150
4254
|
error: outcome.error,
|
|
4151
4255
|
};
|
|
4152
4256
|
}
|
|
@@ -4898,7 +5002,20 @@ export function harnessMethods(state) {
|
|
|
4898
5002
|
// eslint-disable-next-line no-console
|
|
4899
5003
|
console.warn("[service-logs] baseline capture failed:", err);
|
|
4900
5004
|
}
|
|
4901
|
-
|
|
5005
|
+
// The coverage baseline rides the same call: what the opted-in
|
|
5006
|
+
// services ran during bring-up + project setup, parked under the
|
|
5007
|
+
// synthetic `__setup__` case for `coverageChunk`. A capture error is
|
|
5008
|
+
// reported in the ref, not thrown — there is no case to fail here,
|
|
5009
|
+
// the control plane logs it.
|
|
5010
|
+
let coverage;
|
|
5011
|
+
try {
|
|
5012
|
+
coverage = parkCoverageBundle(SETUP_CASE_ID, await captureServiceCoverage());
|
|
5013
|
+
}
|
|
5014
|
+
catch (err) {
|
|
5015
|
+
// eslint-disable-next-line no-console
|
|
5016
|
+
console.warn("[coverage] baseline capture failed:", err);
|
|
5017
|
+
}
|
|
5018
|
+
return { serviceLogDeltas, coverage };
|
|
4902
5019
|
},
|
|
4903
5020
|
run: async (params) => {
|
|
4904
5021
|
const caseId = requireString(params, "caseId", "caseId");
|
|
@@ -4912,7 +5029,10 @@ export function harnessMethods(state) {
|
|
|
4912
5029
|
// (with a warning) rather than failing the case — and never falls
|
|
4913
5030
|
// back to inlining, which is exactly the oversized reply this path
|
|
4914
5031
|
// exists to avoid.
|
|
4915
|
-
const { browserSessions, ...wire } = result;
|
|
5032
|
+
const { browserSessions, coverageCaptures, ...wire } = result;
|
|
5033
|
+
// Coverage reports take the same side channel: park, reply with a
|
|
5034
|
+
// ref, let the control plane pull through `coverageChunk`.
|
|
5035
|
+
const coverage = parkCoverageBundle(caseId, coverageCaptures);
|
|
4916
5036
|
let replay;
|
|
4917
5037
|
if (browserSessions.length > 0) {
|
|
4918
5038
|
try {
|
|
@@ -4925,7 +5045,7 @@ export function harnessMethods(state) {
|
|
|
4925
5045
|
console.warn("[replay] bundle encode failed; dropping recordings:", err);
|
|
4926
5046
|
}
|
|
4927
5047
|
}
|
|
4928
|
-
return { ...wire, replay };
|
|
5048
|
+
return { ...wire, replay, coverage };
|
|
4929
5049
|
});
|
|
4930
5050
|
},
|
|
4931
5051
|
// One chunk of a parked replay bundle. Case ids are arbitrary user
|
|
@@ -4937,6 +5057,14 @@ export function harnessMethods(state) {
|
|
|
4937
5057
|
throw notFound(`no replay bundle for case: ${caseId}`);
|
|
4938
5058
|
return replayChunk(gz, params.offset, params.limit);
|
|
4939
5059
|
},
|
|
5060
|
+
// One chunk of a parked coverage bundle (same shape as replayChunk).
|
|
5061
|
+
coverageChunk: async (params) => {
|
|
5062
|
+
const caseId = requireString(params, "caseId", "caseId");
|
|
5063
|
+
const gz = COVERAGE_BUNDLES.get(caseId);
|
|
5064
|
+
if (!gz)
|
|
5065
|
+
throw notFound(`no coverage bundle for case: ${caseId}`);
|
|
5066
|
+
return replayChunk(gz, params.offset, params.limit);
|
|
5067
|
+
},
|
|
4940
5068
|
};
|
|
4941
5069
|
}
|
|
4942
5070
|
/**
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** Where the directory is mounted inside an opted-in container. */
|
|
2
|
+
export declare const COVERAGE_CONTAINER_DIR = "/spectest/coverage";
|
|
3
|
+
/** Raw bytes of reports one service may ship per capture. Reports are
|
|
4
|
+
* cumulative and bounded by code size, so anything past this is a
|
|
5
|
+
* runaway (a tool writing a new file per request), not coverage. */
|
|
6
|
+
export declare const COVERAGE_MAX_BYTES_PER_SERVICE: number;
|
|
7
|
+
/** Root of the per-service host directories, under the workspace so the
|
|
8
|
+
* delta-restore teardown wipes them with everything else. */
|
|
9
|
+
export declare function coverageHostDir(workspace: string, service: string): string;
|
|
10
|
+
/** One report file, verbatim. `name` is its path relative to the
|
|
11
|
+
* coverage directory. */
|
|
12
|
+
export interface CoverageReport {
|
|
13
|
+
name: string;
|
|
14
|
+
content: string;
|
|
15
|
+
}
|
|
16
|
+
/** One service's capture: every report in its directory, verbatim. */
|
|
17
|
+
export interface ServiceCoverageCapture {
|
|
18
|
+
service: string;
|
|
19
|
+
reports: CoverageReport[];
|
|
20
|
+
/** Set when the capture failed: the command failed, the directory was
|
|
21
|
+
* empty, no file held an `SF:` record, or the reports were too large.
|
|
22
|
+
* A case with an error here fails — a hole in the coverage data must
|
|
23
|
+
* never be silent. */
|
|
24
|
+
error?: string;
|
|
25
|
+
}
|
|
26
|
+
/** The small ref that rides the `run` reply in place of the bundle. */
|
|
27
|
+
export interface CoverageBundleRef {
|
|
28
|
+
/** Gzipped bundle size; the control plane pulls exactly this many bytes
|
|
29
|
+
* through `coverageChunk`. */
|
|
30
|
+
bytes: number;
|
|
31
|
+
services: {
|
|
32
|
+
service: string;
|
|
33
|
+
reports: number;
|
|
34
|
+
/** Raw (uncompressed) bytes of this service's reports. */
|
|
35
|
+
bytes: number;
|
|
36
|
+
error?: string;
|
|
37
|
+
}[];
|
|
38
|
+
}
|
|
39
|
+
/** The gzipped JSON document `coverageChunk` serves. Mirrors
|
|
40
|
+
* `storage::CaseCoverageBundle` on the server. */
|
|
41
|
+
export interface CoverageBundle {
|
|
42
|
+
caseId: string;
|
|
43
|
+
services: ServiceCoverageCapture[];
|
|
44
|
+
}
|
|
45
|
+
/** True when `text` holds at least one lcov `SF:` record. */
|
|
46
|
+
export declare function hasLcovSourceFile(text: string): boolean;
|
|
47
|
+
/** Every regular file under `dir`, recursively, as paths relative to it.
|
|
48
|
+
* Hidden files are skipped (a tool's own bookkeeping — `.seq`, `.lock`,
|
|
49
|
+
* a tmp file mid-write — is not a report). */
|
|
50
|
+
export declare function listReportFiles(dir: string): Promise<string[]>;
|
|
51
|
+
/**
|
|
52
|
+
* Read every report under `dir`, verbatim. An empty directory, or one with
|
|
53
|
+
* no `SF:` record anywhere, is an error rather than an empty capture: the
|
|
54
|
+
* contract is that an opted-in service always has a report to read.
|
|
55
|
+
*/
|
|
56
|
+
export declare function readCoverageDir(service: string, dir: string): Promise<ServiceCoverageCapture>;
|
|
57
|
+
/** Gzip a case's captures into the bundle `coverageChunk` serves. */
|
|
58
|
+
export declare function encodeCoverageBundle(caseId: string, services: ServiceCoverageCapture[]): Buffer;
|
|
59
|
+
/** The ref for a bundle: its size plus a per-service summary. */
|
|
60
|
+
export declare function coverageBundleRef(gz: Buffer, services: ServiceCoverageCapture[]): CoverageBundleRef;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Coverage collection — the pure part.
|
|
2
|
+
//
|
|
3
|
+
// A service that opts in (`coverage: true | { command }`) gets a writable
|
|
4
|
+
// directory bind-mounted at `/spectest/coverage/` in its container. The
|
|
5
|
+
// service's own tool writes lcov reports there. The daemon reads the
|
|
6
|
+
// directory from the VM side (no `docker exec` to fetch) after bring-up
|
|
7
|
+
// and after each test, and ships the reports **verbatim** to the control
|
|
8
|
+
// plane as a gzipped bundle. Nothing is interpreted here beyond the one
|
|
9
|
+
// contract the harness must fail a case on: at least one report, and at
|
|
10
|
+
// least one lcov `SF:` record among them. The server derives whatever it
|
|
11
|
+
// needs (today the set of source files; later, finer things) from the
|
|
12
|
+
// stored bytes, so a change in that derivation is a re-read of stored
|
|
13
|
+
// runs, never a hole in the history.
|
|
14
|
+
//
|
|
15
|
+
// The container side (mount flag, `command` exec) is in `daemon.ts`.
|
|
16
|
+
import { promises as fs } from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { gzipSync } from "node:zlib";
|
|
19
|
+
/** Where the directory is mounted inside an opted-in container. */
|
|
20
|
+
export const COVERAGE_CONTAINER_DIR = "/spectest/coverage";
|
|
21
|
+
/** Raw bytes of reports one service may ship per capture. Reports are
|
|
22
|
+
* cumulative and bounded by code size, so anything past this is a
|
|
23
|
+
* runaway (a tool writing a new file per request), not coverage. */
|
|
24
|
+
export const COVERAGE_MAX_BYTES_PER_SERVICE = 64 * 1024 * 1024;
|
|
25
|
+
/** Root of the per-service host directories, under the workspace so the
|
|
26
|
+
* delta-restore teardown wipes them with everything else. */
|
|
27
|
+
export function coverageHostDir(workspace, service) {
|
|
28
|
+
return path.join(workspace, ".spectest", "coverage", service);
|
|
29
|
+
}
|
|
30
|
+
/** True when `text` holds at least one lcov `SF:` record. */
|
|
31
|
+
export function hasLcovSourceFile(text) {
|
|
32
|
+
return /^SF:\S/m.test(text);
|
|
33
|
+
}
|
|
34
|
+
/** Every regular file under `dir`, recursively, as paths relative to it.
|
|
35
|
+
* Hidden files are skipped (a tool's own bookkeeping — `.seq`, `.lock`,
|
|
36
|
+
* a tmp file mid-write — is not a report). */
|
|
37
|
+
export async function listReportFiles(dir) {
|
|
38
|
+
const out = [];
|
|
39
|
+
async function walk(d, rel) {
|
|
40
|
+
let entries;
|
|
41
|
+
try {
|
|
42
|
+
entries = await fs.readdir(d, { withFileTypes: true });
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
for (const e of entries) {
|
|
48
|
+
if (e.name.startsWith("."))
|
|
49
|
+
continue;
|
|
50
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
51
|
+
if (e.isDirectory())
|
|
52
|
+
await walk(path.join(d, e.name), r);
|
|
53
|
+
else if (e.isFile())
|
|
54
|
+
out.push(r);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
await walk(dir, "");
|
|
58
|
+
return out.sort();
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Read every report under `dir`, verbatim. An empty directory, or one with
|
|
62
|
+
* no `SF:` record anywhere, is an error rather than an empty capture: the
|
|
63
|
+
* contract is that an opted-in service always has a report to read.
|
|
64
|
+
*/
|
|
65
|
+
export async function readCoverageDir(service, dir) {
|
|
66
|
+
const names = await listReportFiles(dir);
|
|
67
|
+
if (names.length === 0) {
|
|
68
|
+
return {
|
|
69
|
+
service,
|
|
70
|
+
reports: [],
|
|
71
|
+
error: `service "${service}" opted in to coverage but ${COVERAGE_CONTAINER_DIR}/ holds no report — the service (or its coverage command) must write an lcov file there`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
const reports = [];
|
|
75
|
+
let total = 0;
|
|
76
|
+
let anyLcov = false;
|
|
77
|
+
for (const name of names) {
|
|
78
|
+
let content;
|
|
79
|
+
try {
|
|
80
|
+
content = await fs.readFile(path.join(dir, name), "utf8");
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
total += content.length;
|
|
86
|
+
if (total > COVERAGE_MAX_BYTES_PER_SERVICE) {
|
|
87
|
+
return {
|
|
88
|
+
service,
|
|
89
|
+
reports: [],
|
|
90
|
+
error: `service "${service}" has more than ${COVERAGE_MAX_BYTES_PER_SERVICE >> 20} MiB of reports in ${COVERAGE_CONTAINER_DIR}/ — reports must be cumulative, not one file per dump`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (hasLcovSourceFile(content))
|
|
94
|
+
anyLcov = true;
|
|
95
|
+
reports.push({ name, content });
|
|
96
|
+
}
|
|
97
|
+
if (!anyLcov) {
|
|
98
|
+
return {
|
|
99
|
+
service,
|
|
100
|
+
reports,
|
|
101
|
+
error: `service "${service}" wrote ${reports.length} file(s) to ${COVERAGE_CONTAINER_DIR}/ but none holds an lcov \`SF:\` record — only lcov reports are read`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return { service, reports };
|
|
105
|
+
}
|
|
106
|
+
/** Gzip a case's captures into the bundle `coverageChunk` serves. */
|
|
107
|
+
export function encodeCoverageBundle(caseId, services) {
|
|
108
|
+
const doc = { caseId, services };
|
|
109
|
+
return gzipSync(Buffer.from(JSON.stringify(doc)));
|
|
110
|
+
}
|
|
111
|
+
/** The ref for a bundle: its size plus a per-service summary. */
|
|
112
|
+
export function coverageBundleRef(gz, services) {
|
|
113
|
+
return {
|
|
114
|
+
bytes: gz.length,
|
|
115
|
+
services: services.map((s) => ({
|
|
116
|
+
service: s.service,
|
|
117
|
+
reports: s.reports.length,
|
|
118
|
+
bytes: s.reports.reduce((n, r) => n + r.content.length, 0),
|
|
119
|
+
...(s.error ? { error: s.error } : {}),
|
|
120
|
+
})),
|
|
121
|
+
};
|
|
122
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -115,6 +115,27 @@ export interface ServiceConfig {
|
|
|
115
115
|
tls?: readonly ServiceTls[];
|
|
116
116
|
/** Bind-mounted volumes for state that survives snapshot/fork. */
|
|
117
117
|
volumes?: readonly VolumeMount[];
|
|
118
|
+
/**
|
|
119
|
+
* Opt this service in to **code coverage collection** (experimental).
|
|
120
|
+
*
|
|
121
|
+
* The service gets a writable directory bind-mounted at
|
|
122
|
+
* `/spectest/coverage/`. The service's own coverage tool writes **lcov**
|
|
123
|
+
* reports there — cumulative from process start, never reset. After
|
|
124
|
+
* bring-up and after every test, spectest runs `command` (if given) inside
|
|
125
|
+
* the container, then reads every file in the directory and records the
|
|
126
|
+
* set of source files that ran. Only the `SF:` records are read.
|
|
127
|
+
*
|
|
128
|
+
* - `true`: the service writes its reports by itself (a dump on a timer,
|
|
129
|
+
* an exit-time dump for short-lived processes).
|
|
130
|
+
* - `{ command }`: run this (via `sh -c`) in the container to make the
|
|
131
|
+
* service write a report, before the directory is read.
|
|
132
|
+
*
|
|
133
|
+
* The directory must hold at least one lcov report each time it is read;
|
|
134
|
+
* an empty directory or a failed command fails the test with a clear
|
|
135
|
+
* error, so a hole in the coverage data is never silent. See
|
|
136
|
+
* `spectest docs /services/coverage`.
|
|
137
|
+
*/
|
|
138
|
+
coverage?: ServiceCoverage;
|
|
118
139
|
/**
|
|
119
140
|
* Files seeded into the container's filesystem **before it starts**.
|
|
120
141
|
* Each entry's `content` is written to a VM-host staging path and
|
|
@@ -642,6 +663,14 @@ export type ServiceImage = {
|
|
|
642
663
|
/** Extra glob patterns to exclude from the build context. */
|
|
643
664
|
exclude?: readonly string[];
|
|
644
665
|
};
|
|
666
|
+
/**
|
|
667
|
+
* A service's opt-in to coverage collection — see `ServiceConfig.coverage`.
|
|
668
|
+
* `true` when the service writes its own reports; `{ command }` when
|
|
669
|
+
* spectest must run a command in the container to produce one.
|
|
670
|
+
*/
|
|
671
|
+
export type ServiceCoverage = true | {
|
|
672
|
+
command: string;
|
|
673
|
+
};
|
|
645
674
|
export interface VolumeMount {
|
|
646
675
|
/**
|
|
647
676
|
* Named shared volume. Two services mounting the same `name` share one
|
|
@@ -854,6 +883,11 @@ export interface FakeContext {
|
|
|
854
883
|
* for a TLS-provisioning provider hands back to the app under test. */
|
|
855
884
|
certificate(hostnames: readonly string[]): Promise<CertificateMaterial>;
|
|
856
885
|
}
|
|
886
|
+
/**
|
|
887
|
+
* Check one service's `coverage` field. Exported so the daemon applies the
|
|
888
|
+
* same rule to a runtime `startService` spec.
|
|
889
|
+
*/
|
|
890
|
+
export declare function validateCoverage(service: string, cov: unknown): void;
|
|
857
891
|
/**
|
|
858
892
|
* A single test step. Created via `test(...)` or `createTest(services)`.
|
|
859
893
|
* Tests are referenced (not named by string) when one test depends on
|
package/dist/index.js
CHANGED
|
@@ -215,6 +215,21 @@ function expandServiceGroups(input) {
|
|
|
215
215
|
}
|
|
216
216
|
return out;
|
|
217
217
|
}
|
|
218
|
+
/**
|
|
219
|
+
* Check one service's `coverage` field. Exported so the daemon applies the
|
|
220
|
+
* same rule to a runtime `startService` spec.
|
|
221
|
+
*/
|
|
222
|
+
export function validateCoverage(service, cov) {
|
|
223
|
+
if (cov === undefined || cov === true)
|
|
224
|
+
return;
|
|
225
|
+
if (typeof cov === "object" &&
|
|
226
|
+
cov !== null &&
|
|
227
|
+
typeof cov.command === "string" &&
|
|
228
|
+
cov.command.trim().length > 0) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
throw new Error(`service "${service}" has an invalid \`coverage\` value — use \`true\` or \`{ command: "<shell command>" }\``);
|
|
232
|
+
}
|
|
218
233
|
function validateEnvironmentConfig(config) {
|
|
219
234
|
const entries = Object.entries(config.services);
|
|
220
235
|
const serviceNames = new Set(entries.map(([n]) => n));
|
|
@@ -246,6 +261,7 @@ function validateEnvironmentConfig(config) {
|
|
|
246
261
|
throw new Error(`service "${name}" volume name ${JSON.stringify(vol.name)} must be alphanumeric plus [._-]`);
|
|
247
262
|
}
|
|
248
263
|
}
|
|
264
|
+
validateCoverage(name, svc.coverage);
|
|
249
265
|
for (const raw of svc.hostnames ?? []) {
|
|
250
266
|
const h = raw.toLowerCase();
|
|
251
267
|
if (!HOSTNAME_RE.test(hostPatternBody(h))) {
|
package/package.json
CHANGED
package/src/daemon.ts
CHANGED
|
@@ -32,8 +32,18 @@ import {
|
|
|
32
32
|
dnsName as makeDnsDecl,
|
|
33
33
|
isWildcard,
|
|
34
34
|
proxy as makeProxyDecl,
|
|
35
|
+
validateCoverage,
|
|
35
36
|
} from "./index.js";
|
|
36
37
|
import type { DnsTarget, LoweredIngress } from "./index.js";
|
|
38
|
+
import {
|
|
39
|
+
COVERAGE_CONTAINER_DIR,
|
|
40
|
+
coverageBundleRef,
|
|
41
|
+
coverageHostDir,
|
|
42
|
+
encodeCoverageBundle,
|
|
43
|
+
readCoverageDir,
|
|
44
|
+
type CoverageBundleRef,
|
|
45
|
+
type ServiceCoverageCapture,
|
|
46
|
+
} from "./harness/coverage.js";
|
|
37
47
|
import { acquirePersistentBrowser, mobileKey } from "./browser.js";
|
|
38
48
|
import { isMobileApp, openPersistentMobile } from "./mobile.js";
|
|
39
49
|
// Pure ingress hostname matching, ported out of this file (see
|
|
@@ -756,6 +766,23 @@ async function ensureVolumes(svc: NamedService): Promise<string[]> {
|
|
|
756
766
|
return flags;
|
|
757
767
|
}
|
|
758
768
|
|
|
769
|
+
/**
|
|
770
|
+
* The coverage directory for an opted-in service: one host dir per service
|
|
771
|
+
* under the workspace (so the delta-restore teardown wipes it), mounted
|
|
772
|
+
* writable at `/spectest/coverage/`. World-writable because the container
|
|
773
|
+
* may run as any user and spectest creates the dir as root. The reports in
|
|
774
|
+
* it ride snapshots and forks like everything else on the rootfs, which is
|
|
775
|
+
* what makes each read cumulative along the test's ancestor chain.
|
|
776
|
+
*/
|
|
777
|
+
async function ensureCoverage(svc: NamedService): Promise<string[]> {
|
|
778
|
+
if (svc.coverage === undefined) return [];
|
|
779
|
+
validateCoverage(svc.name, svc.coverage);
|
|
780
|
+
const host = coverageHostDir(WORKSPACE, svc.name);
|
|
781
|
+
await fs.mkdir(host, { recursive: true });
|
|
782
|
+
await fs.chmod(host, 0o777);
|
|
783
|
+
return [`--volume=${host}:${COVERAGE_CONTAINER_DIR}`];
|
|
784
|
+
}
|
|
785
|
+
|
|
759
786
|
/** A `user`/`group` pair resolved to the numeric ids the kernel wants.
|
|
760
787
|
* `-1` is chown(2)'s "leave this one alone". */
|
|
761
788
|
interface OwnerIds {
|
|
@@ -2780,6 +2807,7 @@ async function startRuntimeService(spec: RuntimeServiceSpec): Promise<RuntimeSer
|
|
|
2780
2807
|
...(await ensureVolumes(svc)),
|
|
2781
2808
|
...(await ensureFiles(svc, tag)),
|
|
2782
2809
|
...(await ensureCertificates(svc, tag)),
|
|
2810
|
+
...(await ensureCoverage(svc)),
|
|
2783
2811
|
];
|
|
2784
2812
|
await runContainer(svc, tag, flags, aliases);
|
|
2785
2813
|
await waitForReady(svc);
|
|
@@ -3228,6 +3256,7 @@ async function bootstrapInner(): Promise<BootstrapTimings> {
|
|
|
3228
3256
|
...(await ensureVolumes(svc)),
|
|
3229
3257
|
...(await ensureFiles(svc, tag)),
|
|
3230
3258
|
...(await ensureCertificates(svc, tag)),
|
|
3259
|
+
...(await ensureCoverage(svc)),
|
|
3231
3260
|
];
|
|
3232
3261
|
const tRun = Date.now();
|
|
3233
3262
|
progressService(svc.name, { status: "starting", detail: undefined });
|
|
@@ -3369,6 +3398,16 @@ interface RunResult {
|
|
|
3369
3398
|
* failing case's own delta. See {captureServiceLogDeltas}.
|
|
3370
3399
|
*/
|
|
3371
3400
|
serviceLogDeltas: ServiceLogDelta[];
|
|
3401
|
+
/**
|
|
3402
|
+
* Per-service coverage for the services that opted in (`coverage` on
|
|
3403
|
+
* the service): their lcov reports, verbatim, read after this case.
|
|
3404
|
+
* Cumulative along the ancestor chain by construction. Like
|
|
3405
|
+
* `browserSessions` this never rides the `run` reply — the handler
|
|
3406
|
+
* parks a gzipped bundle and replies with a ref the control plane pulls
|
|
3407
|
+
* through `coverageChunk`. An entry with `error` fails the case. See
|
|
3408
|
+
* {captureServiceCoverage}.
|
|
3409
|
+
*/
|
|
3410
|
+
coverageCaptures: ServiceCoverageCapture[];
|
|
3372
3411
|
error?: { message: string; stack?: string };
|
|
3373
3412
|
}
|
|
3374
3413
|
|
|
@@ -3391,19 +3430,42 @@ interface RunResult {
|
|
|
3391
3430
|
// bundle in guest RAM — entries older than the last few are always
|
|
3392
3431
|
// already-fetched leftovers frozen into some snapshot.
|
|
3393
3432
|
const REPLAY_BUNDLES = new Map<string, Buffer>();
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3433
|
+
/** Coverage bundles, same side-channel and same lifetime rules; pulled
|
|
3434
|
+
* through `coverageChunk`. The env-setup baseline parks under the
|
|
3435
|
+
* control plane's synthetic `__setup__` case id. */
|
|
3436
|
+
const COVERAGE_BUNDLES = new Map<string, Buffer>();
|
|
3437
|
+
const PARKED_BUNDLES_MAX = 8;
|
|
3438
|
+
/** The control plane's synthetic case id for env-setup captures
|
|
3439
|
+
* (`storage::SETUP_CASE_ID`). Not a real case id — those are kebab slugs. */
|
|
3440
|
+
const SETUP_CASE_ID = "__setup__";
|
|
3441
|
+
|
|
3442
|
+
function stashBundle(map: Map<string, Buffer>, caseId: string, gz: Buffer): void {
|
|
3397
3443
|
// Re-insert to refresh recency (Map iterates in insertion order).
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
while (
|
|
3401
|
-
const oldest =
|
|
3444
|
+
map.delete(caseId);
|
|
3445
|
+
map.set(caseId, gz);
|
|
3446
|
+
while (map.size > PARKED_BUNDLES_MAX) {
|
|
3447
|
+
const oldest = map.keys().next().value;
|
|
3402
3448
|
if (oldest === undefined) break;
|
|
3403
|
-
|
|
3449
|
+
map.delete(oldest);
|
|
3404
3450
|
}
|
|
3405
3451
|
}
|
|
3406
3452
|
|
|
3453
|
+
function stashReplayBundle(caseId: string, gz: Buffer): void {
|
|
3454
|
+
stashBundle(REPLAY_BUNDLES, caseId, gz);
|
|
3455
|
+
}
|
|
3456
|
+
|
|
3457
|
+
/** Encode + park a case's coverage captures; returns the ref for the reply.
|
|
3458
|
+
* `undefined` when no service opted in, so uncovered projects ship nothing. */
|
|
3459
|
+
function parkCoverageBundle(
|
|
3460
|
+
caseId: string,
|
|
3461
|
+
captures: ServiceCoverageCapture[],
|
|
3462
|
+
): CoverageBundleRef | undefined {
|
|
3463
|
+
if (captures.length === 0) return undefined;
|
|
3464
|
+
const gz = encodeCoverageBundle(caseId, captures);
|
|
3465
|
+
stashBundle(COVERAGE_BUNDLES, caseId, gz);
|
|
3466
|
+
return coverageBundleRef(gz, captures);
|
|
3467
|
+
}
|
|
3468
|
+
|
|
3407
3469
|
// ────────────────────────────────────────────────────────────────────────
|
|
3408
3470
|
// Cumulative service logs (per-case deltas → S3, reconstructed on the web)
|
|
3409
3471
|
// ────────────────────────────────────────────────────────────────────────
|
|
@@ -3526,6 +3588,48 @@ async function captureServiceLogDeltas(): Promise<ServiceLogDelta[]> {
|
|
|
3526
3588
|
);
|
|
3527
3589
|
}
|
|
3528
3590
|
|
|
3591
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
3592
|
+
// Coverage capture (opt-in per service; see harness/coverage.ts)
|
|
3593
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
3594
|
+
//
|
|
3595
|
+
// Read after bring-up and after every test, on every case. The reports are
|
|
3596
|
+
// cumulative from process start and live on the rootfs, so what a child
|
|
3597
|
+
// fork reads already includes everything its ancestors ran — no markers,
|
|
3598
|
+
// no deltas (unlike the service logs above). A `command` runs in the
|
|
3599
|
+
// container first so the service writes a fresh report. The reports ship
|
|
3600
|
+
// verbatim; the server derives what it needs from the stored bytes.
|
|
3601
|
+
|
|
3602
|
+
const COVERAGE_COMMAND_TIMEOUT_MS = 60_000;
|
|
3603
|
+
|
|
3604
|
+
async function captureServiceCoverage(): Promise<ServiceCoverageCapture[]> {
|
|
3605
|
+
const l = loaded;
|
|
3606
|
+
if (!l) return [];
|
|
3607
|
+
const byName = new Map<string, NamedService>();
|
|
3608
|
+
for (const s of namedServices(l.project.environment)) byName.set(s.name, s);
|
|
3609
|
+
for (const [name, s] of RUNTIME_SERVICES) byName.set(name, s);
|
|
3610
|
+
const services = [...byName.values()].filter((s) => s.coverage !== undefined);
|
|
3611
|
+
return Promise.all(
|
|
3612
|
+
services.map(async (svc): Promise<ServiceCoverageCapture> => {
|
|
3613
|
+
const cov = svc.coverage;
|
|
3614
|
+
if (typeof cov === "object" && cov !== null) {
|
|
3615
|
+
const r = await docker(
|
|
3616
|
+
["exec", svc.name, "sh", "-c", cov.command],
|
|
3617
|
+
COVERAGE_COMMAND_TIMEOUT_MS,
|
|
3618
|
+
);
|
|
3619
|
+
if (r.code !== 0) {
|
|
3620
|
+
const tail = capMiddle((r.stderr || r.stdout).trim(), 2000).value;
|
|
3621
|
+
return {
|
|
3622
|
+
service: svc.name,
|
|
3623
|
+
reports: [],
|
|
3624
|
+
error: `coverage command for service "${svc.name}" failed (exit ${r.code}): ${JSON.stringify(cov.command)}${tail ? `\n${tail}` : ""}`,
|
|
3625
|
+
};
|
|
3626
|
+
}
|
|
3627
|
+
}
|
|
3628
|
+
return readCoverageDir(svc.name, coverageHostDir(WORKSPACE, svc.name));
|
|
3629
|
+
}),
|
|
3630
|
+
);
|
|
3631
|
+
}
|
|
3632
|
+
|
|
3529
3633
|
/** Per-Browser rrweb session shipped to the control plane. */
|
|
3530
3634
|
interface BrowserSessionRecord {
|
|
3531
3635
|
sessionId: string;
|
|
@@ -5020,6 +5124,32 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
5020
5124
|
// eslint-disable-next-line no-console
|
|
5021
5125
|
console.warn("[service-logs] delta capture failed:", err);
|
|
5022
5126
|
}
|
|
5127
|
+
// Coverage for the opted-in services, read after the clock stops like
|
|
5128
|
+
// the log deltas. Unlike them it is NOT best-effort: a service that opted
|
|
5129
|
+
// in and produced nothing readable fails the case, so a hole in the
|
|
5130
|
+
// coverage data is visible where it happened rather than silently
|
|
5131
|
+
// widening every later subset run.
|
|
5132
|
+
let coverageCaptures: ServiceCoverageCapture[] = [];
|
|
5133
|
+
try {
|
|
5134
|
+
coverageCaptures = await captureServiceCoverage();
|
|
5135
|
+
} catch (err) {
|
|
5136
|
+
coverageCaptures = [
|
|
5137
|
+
{
|
|
5138
|
+
service: "*",
|
|
5139
|
+
reports: [],
|
|
5140
|
+
error: `coverage capture failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
5141
|
+
},
|
|
5142
|
+
];
|
|
5143
|
+
}
|
|
5144
|
+
const coverageErrors = coverageCaptures
|
|
5145
|
+
.filter((c) => c.error)
|
|
5146
|
+
.map((c) => c.error as string);
|
|
5147
|
+
if (coverageErrors.length > 0 && outcome.status === "passed") {
|
|
5148
|
+
outcome = {
|
|
5149
|
+
status: "failed",
|
|
5150
|
+
error: { message: coverageErrors.join("\n") },
|
|
5151
|
+
};
|
|
5152
|
+
}
|
|
5023
5153
|
const events = stopRecording();
|
|
5024
5154
|
// Drop sessions whose linking event didn't survive — an exec/terminal
|
|
5025
5155
|
// inside a failed `ctx.poll` iteration has its events removed by
|
|
@@ -5038,6 +5168,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
5038
5168
|
browserSessions: sessions.map((s) => s.record),
|
|
5039
5169
|
terminalSessions: terminalSessions.filter((s) => referencedSessions.has(s.sessionId)),
|
|
5040
5170
|
serviceLogDeltas,
|
|
5171
|
+
coverageCaptures,
|
|
5041
5172
|
error: outcome.error,
|
|
5042
5173
|
};
|
|
5043
5174
|
}
|
|
@@ -5940,7 +6071,19 @@ export function harnessMethods(state: RouteState): MethodTable {
|
|
|
5940
6071
|
// eslint-disable-next-line no-console
|
|
5941
6072
|
console.warn("[service-logs] baseline capture failed:", err);
|
|
5942
6073
|
}
|
|
5943
|
-
|
|
6074
|
+
// The coverage baseline rides the same call: what the opted-in
|
|
6075
|
+
// services ran during bring-up + project setup, parked under the
|
|
6076
|
+
// synthetic `__setup__` case for `coverageChunk`. A capture error is
|
|
6077
|
+
// reported in the ref, not thrown — there is no case to fail here,
|
|
6078
|
+
// the control plane logs it.
|
|
6079
|
+
let coverage: CoverageBundleRef | undefined;
|
|
6080
|
+
try {
|
|
6081
|
+
coverage = parkCoverageBundle(SETUP_CASE_ID, await captureServiceCoverage());
|
|
6082
|
+
} catch (err) {
|
|
6083
|
+
// eslint-disable-next-line no-console
|
|
6084
|
+
console.warn("[coverage] baseline capture failed:", err);
|
|
6085
|
+
}
|
|
6086
|
+
return { serviceLogDeltas, coverage };
|
|
5944
6087
|
},
|
|
5945
6088
|
|
|
5946
6089
|
run: async (params) => {
|
|
@@ -5954,7 +6097,10 @@ export function harnessMethods(state: RouteState): MethodTable {
|
|
|
5954
6097
|
// (with a warning) rather than failing the case — and never falls
|
|
5955
6098
|
// back to inlining, which is exactly the oversized reply this path
|
|
5956
6099
|
// exists to avoid.
|
|
5957
|
-
const { browserSessions, ...wire } = result;
|
|
6100
|
+
const { browserSessions, coverageCaptures, ...wire } = result;
|
|
6101
|
+
// Coverage reports take the same side channel: park, reply with a
|
|
6102
|
+
// ref, let the control plane pull through `coverageChunk`.
|
|
6103
|
+
const coverage = parkCoverageBundle(caseId, coverageCaptures);
|
|
5958
6104
|
let replay: { bytes: number } | undefined;
|
|
5959
6105
|
if (browserSessions.length > 0) {
|
|
5960
6106
|
try {
|
|
@@ -5966,7 +6112,7 @@ export function harnessMethods(state: RouteState): MethodTable {
|
|
|
5966
6112
|
console.warn("[replay] bundle encode failed; dropping recordings:", err);
|
|
5967
6113
|
}
|
|
5968
6114
|
}
|
|
5969
|
-
return { ...wire, replay };
|
|
6115
|
+
return { ...wire, replay, coverage };
|
|
5970
6116
|
});
|
|
5971
6117
|
},
|
|
5972
6118
|
|
|
@@ -5978,6 +6124,14 @@ export function harnessMethods(state: RouteState): MethodTable {
|
|
|
5978
6124
|
if (!gz) throw notFound(`no replay bundle for case: ${caseId}`);
|
|
5979
6125
|
return replayChunk(gz, params.offset as number | undefined, params.limit as number | undefined);
|
|
5980
6126
|
},
|
|
6127
|
+
|
|
6128
|
+
// One chunk of a parked coverage bundle (same shape as replayChunk).
|
|
6129
|
+
coverageChunk: async (params) => {
|
|
6130
|
+
const caseId = requireString(params, "caseId", "caseId");
|
|
6131
|
+
const gz = COVERAGE_BUNDLES.get(caseId);
|
|
6132
|
+
if (!gz) throw notFound(`no coverage bundle for case: ${caseId}`);
|
|
6133
|
+
return replayChunk(gz, params.offset as number | undefined, params.limit as number | undefined);
|
|
6134
|
+
},
|
|
5981
6135
|
};
|
|
5982
6136
|
}
|
|
5983
6137
|
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { gunzipSync } from "node:zlib";
|
|
6
|
+
import {
|
|
7
|
+
coverageBundleRef,
|
|
8
|
+
coverageHostDir,
|
|
9
|
+
encodeCoverageBundle,
|
|
10
|
+
hasLcovSourceFile,
|
|
11
|
+
listReportFiles,
|
|
12
|
+
readCoverageDir,
|
|
13
|
+
} from "./coverage";
|
|
14
|
+
|
|
15
|
+
async function tmp(): Promise<string> {
|
|
16
|
+
return fs.mkdtemp(path.join(os.tmpdir(), "spectest-cov-"));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("hasLcovSourceFile", () => {
|
|
20
|
+
test("finds an SF record anywhere, tolerating CRLF", () => {
|
|
21
|
+
expect(hasLcovSourceFile("TN:\r\nSF:/app/server.js\r\nDA:1,1\n")).toBe(true);
|
|
22
|
+
expect(hasLcovSourceFile("TN:\nSF:\nend_of_record\n")).toBe(false);
|
|
23
|
+
expect(hasLcovSourceFile("{}")).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("readCoverageDir", () => {
|
|
28
|
+
test("ships every report verbatim, nested, with relative names", async () => {
|
|
29
|
+
const dir = await tmp();
|
|
30
|
+
await fs.mkdir(path.join(dir, "sub"));
|
|
31
|
+
await fs.writeFile(path.join(dir, "lcov.info"), "SF:/app/b.js\nDA:1,1\nend_of_record\n");
|
|
32
|
+
await fs.writeFile(path.join(dir, "sub", "more.info"), "SF:/app/c.js\n");
|
|
33
|
+
await fs.writeFile(path.join(dir, ".seq"), "3");
|
|
34
|
+
const r = await readCoverageDir("web", dir);
|
|
35
|
+
expect(r.error).toBeUndefined();
|
|
36
|
+
expect(r.reports).toEqual([
|
|
37
|
+
{ name: "lcov.info", content: "SF:/app/b.js\nDA:1,1\nend_of_record\n" },
|
|
38
|
+
{ name: "sub/more.info", content: "SF:/app/c.js\n" },
|
|
39
|
+
]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("an empty directory is an error, not an empty capture", async () => {
|
|
43
|
+
const dir = await tmp();
|
|
44
|
+
const r = await readCoverageDir("web", dir);
|
|
45
|
+
expect(r.reports).toEqual([]);
|
|
46
|
+
expect(r.error).toContain("holds no report");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("a missing directory is reported like an empty one", async () => {
|
|
50
|
+
const r = await readCoverageDir("web", "/nonexistent/spectest-cov");
|
|
51
|
+
expect(r.error).toContain("holds no report");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("files with no SF record are an error, but still shipped", async () => {
|
|
55
|
+
const dir = await tmp();
|
|
56
|
+
await fs.writeFile(path.join(dir, "coverage.json"), "{}");
|
|
57
|
+
const r = await readCoverageDir("web", dir);
|
|
58
|
+
expect(r.reports.length).toBe(1);
|
|
59
|
+
expect(r.error).toContain("SF:");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("listReportFiles skips dotfiles", async () => {
|
|
63
|
+
const dir = await tmp();
|
|
64
|
+
await fs.writeFile(path.join(dir, ".lock"), "");
|
|
65
|
+
await fs.writeFile(path.join(dir, "a.info"), "");
|
|
66
|
+
expect(await listReportFiles(dir)).toEqual(["a.info"]);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("bundle round-trips and the ref summarises it", () => {
|
|
71
|
+
const services = [
|
|
72
|
+
{ service: "web", reports: [{ name: "lcov.info", content: "SF:/app/a.js\n" }] },
|
|
73
|
+
{ service: "api", reports: [], error: "no report" },
|
|
74
|
+
];
|
|
75
|
+
const gz = encodeCoverageBundle("case-1", services);
|
|
76
|
+
expect(JSON.parse(gunzipSync(gz).toString())).toEqual({ caseId: "case-1", services });
|
|
77
|
+
expect(coverageBundleRef(gz, services)).toEqual({
|
|
78
|
+
bytes: gz.length,
|
|
79
|
+
services: [
|
|
80
|
+
{ service: "web", reports: 1, bytes: 13 },
|
|
81
|
+
{ service: "api", reports: 0, bytes: 0, error: "no report" },
|
|
82
|
+
],
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("coverageHostDir sits under the workspace's .spectest", () => {
|
|
87
|
+
expect(coverageHostDir("/workspace", "api")).toBe("/workspace/.spectest/coverage/api");
|
|
88
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Coverage collection — the pure part.
|
|
2
|
+
//
|
|
3
|
+
// A service that opts in (`coverage: true | { command }`) gets a writable
|
|
4
|
+
// directory bind-mounted at `/spectest/coverage/` in its container. The
|
|
5
|
+
// service's own tool writes lcov reports there. The daemon reads the
|
|
6
|
+
// directory from the VM side (no `docker exec` to fetch) after bring-up
|
|
7
|
+
// and after each test, and ships the reports **verbatim** to the control
|
|
8
|
+
// plane as a gzipped bundle. Nothing is interpreted here beyond the one
|
|
9
|
+
// contract the harness must fail a case on: at least one report, and at
|
|
10
|
+
// least one lcov `SF:` record among them. The server derives whatever it
|
|
11
|
+
// needs (today the set of source files; later, finer things) from the
|
|
12
|
+
// stored bytes, so a change in that derivation is a re-read of stored
|
|
13
|
+
// runs, never a hole in the history.
|
|
14
|
+
//
|
|
15
|
+
// The container side (mount flag, `command` exec) is in `daemon.ts`.
|
|
16
|
+
|
|
17
|
+
import { promises as fs } from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { gzipSync } from "node:zlib";
|
|
20
|
+
|
|
21
|
+
/** Where the directory is mounted inside an opted-in container. */
|
|
22
|
+
export const COVERAGE_CONTAINER_DIR = "/spectest/coverage";
|
|
23
|
+
|
|
24
|
+
/** Raw bytes of reports one service may ship per capture. Reports are
|
|
25
|
+
* cumulative and bounded by code size, so anything past this is a
|
|
26
|
+
* runaway (a tool writing a new file per request), not coverage. */
|
|
27
|
+
export const COVERAGE_MAX_BYTES_PER_SERVICE = 64 * 1024 * 1024;
|
|
28
|
+
|
|
29
|
+
/** Root of the per-service host directories, under the workspace so the
|
|
30
|
+
* delta-restore teardown wipes them with everything else. */
|
|
31
|
+
export function coverageHostDir(workspace: string, service: string): string {
|
|
32
|
+
return path.join(workspace, ".spectest", "coverage", service);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** One report file, verbatim. `name` is its path relative to the
|
|
36
|
+
* coverage directory. */
|
|
37
|
+
export interface CoverageReport {
|
|
38
|
+
name: string;
|
|
39
|
+
content: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** One service's capture: every report in its directory, verbatim. */
|
|
43
|
+
export interface ServiceCoverageCapture {
|
|
44
|
+
service: string;
|
|
45
|
+
reports: CoverageReport[];
|
|
46
|
+
/** Set when the capture failed: the command failed, the directory was
|
|
47
|
+
* empty, no file held an `SF:` record, or the reports were too large.
|
|
48
|
+
* A case with an error here fails — a hole in the coverage data must
|
|
49
|
+
* never be silent. */
|
|
50
|
+
error?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The small ref that rides the `run` reply in place of the bundle. */
|
|
54
|
+
export interface CoverageBundleRef {
|
|
55
|
+
/** Gzipped bundle size; the control plane pulls exactly this many bytes
|
|
56
|
+
* through `coverageChunk`. */
|
|
57
|
+
bytes: number;
|
|
58
|
+
services: {
|
|
59
|
+
service: string;
|
|
60
|
+
reports: number;
|
|
61
|
+
/** Raw (uncompressed) bytes of this service's reports. */
|
|
62
|
+
bytes: number;
|
|
63
|
+
error?: string;
|
|
64
|
+
}[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The gzipped JSON document `coverageChunk` serves. Mirrors
|
|
68
|
+
* `storage::CaseCoverageBundle` on the server. */
|
|
69
|
+
export interface CoverageBundle {
|
|
70
|
+
caseId: string;
|
|
71
|
+
services: ServiceCoverageCapture[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** True when `text` holds at least one lcov `SF:` record. */
|
|
75
|
+
export function hasLcovSourceFile(text: string): boolean {
|
|
76
|
+
return /^SF:\S/m.test(text);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Every regular file under `dir`, recursively, as paths relative to it.
|
|
80
|
+
* Hidden files are skipped (a tool's own bookkeeping — `.seq`, `.lock`,
|
|
81
|
+
* a tmp file mid-write — is not a report). */
|
|
82
|
+
export async function listReportFiles(dir: string): Promise<string[]> {
|
|
83
|
+
const out: string[] = [];
|
|
84
|
+
async function walk(d: string, rel: string): Promise<void> {
|
|
85
|
+
let entries;
|
|
86
|
+
try {
|
|
87
|
+
entries = await fs.readdir(d, { withFileTypes: true });
|
|
88
|
+
} catch {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
for (const e of entries) {
|
|
92
|
+
if (e.name.startsWith(".")) continue;
|
|
93
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
94
|
+
if (e.isDirectory()) await walk(path.join(d, e.name), r);
|
|
95
|
+
else if (e.isFile()) out.push(r);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
await walk(dir, "");
|
|
99
|
+
return out.sort();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Read every report under `dir`, verbatim. An empty directory, or one with
|
|
104
|
+
* no `SF:` record anywhere, is an error rather than an empty capture: the
|
|
105
|
+
* contract is that an opted-in service always has a report to read.
|
|
106
|
+
*/
|
|
107
|
+
export async function readCoverageDir(
|
|
108
|
+
service: string,
|
|
109
|
+
dir: string,
|
|
110
|
+
): Promise<ServiceCoverageCapture> {
|
|
111
|
+
const names = await listReportFiles(dir);
|
|
112
|
+
if (names.length === 0) {
|
|
113
|
+
return {
|
|
114
|
+
service,
|
|
115
|
+
reports: [],
|
|
116
|
+
error: `service "${service}" opted in to coverage but ${COVERAGE_CONTAINER_DIR}/ holds no report — the service (or its coverage command) must write an lcov file there`,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
const reports: CoverageReport[] = [];
|
|
120
|
+
let total = 0;
|
|
121
|
+
let anyLcov = false;
|
|
122
|
+
for (const name of names) {
|
|
123
|
+
let content: string;
|
|
124
|
+
try {
|
|
125
|
+
content = await fs.readFile(path.join(dir, name), "utf8");
|
|
126
|
+
} catch {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
total += content.length;
|
|
130
|
+
if (total > COVERAGE_MAX_BYTES_PER_SERVICE) {
|
|
131
|
+
return {
|
|
132
|
+
service,
|
|
133
|
+
reports: [],
|
|
134
|
+
error: `service "${service}" has more than ${COVERAGE_MAX_BYTES_PER_SERVICE >> 20} MiB of reports in ${COVERAGE_CONTAINER_DIR}/ — reports must be cumulative, not one file per dump`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
if (hasLcovSourceFile(content)) anyLcov = true;
|
|
138
|
+
reports.push({ name, content });
|
|
139
|
+
}
|
|
140
|
+
if (!anyLcov) {
|
|
141
|
+
return {
|
|
142
|
+
service,
|
|
143
|
+
reports,
|
|
144
|
+
error: `service "${service}" wrote ${reports.length} file(s) to ${COVERAGE_CONTAINER_DIR}/ but none holds an lcov \`SF:\` record — only lcov reports are read`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
return { service, reports };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Gzip a case's captures into the bundle `coverageChunk` serves. */
|
|
151
|
+
export function encodeCoverageBundle(
|
|
152
|
+
caseId: string,
|
|
153
|
+
services: ServiceCoverageCapture[],
|
|
154
|
+
): Buffer {
|
|
155
|
+
const doc: CoverageBundle = { caseId, services };
|
|
156
|
+
return gzipSync(Buffer.from(JSON.stringify(doc)));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The ref for a bundle: its size plus a per-service summary. */
|
|
160
|
+
export function coverageBundleRef(
|
|
161
|
+
gz: Buffer,
|
|
162
|
+
services: ServiceCoverageCapture[],
|
|
163
|
+
): CoverageBundleRef {
|
|
164
|
+
return {
|
|
165
|
+
bytes: gz.length,
|
|
166
|
+
services: services.map((s) => ({
|
|
167
|
+
service: s.service,
|
|
168
|
+
reports: s.reports.length,
|
|
169
|
+
bytes: s.reports.reduce((n, r) => n + r.content.length, 0),
|
|
170
|
+
...(s.error ? { error: s.error } : {}),
|
|
171
|
+
})),
|
|
172
|
+
};
|
|
173
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -252,6 +252,27 @@ export interface ServiceConfig {
|
|
|
252
252
|
tls?: readonly ServiceTls[];
|
|
253
253
|
/** Bind-mounted volumes for state that survives snapshot/fork. */
|
|
254
254
|
volumes?: readonly VolumeMount[];
|
|
255
|
+
/**
|
|
256
|
+
* Opt this service in to **code coverage collection** (experimental).
|
|
257
|
+
*
|
|
258
|
+
* The service gets a writable directory bind-mounted at
|
|
259
|
+
* `/spectest/coverage/`. The service's own coverage tool writes **lcov**
|
|
260
|
+
* reports there — cumulative from process start, never reset. After
|
|
261
|
+
* bring-up and after every test, spectest runs `command` (if given) inside
|
|
262
|
+
* the container, then reads every file in the directory and records the
|
|
263
|
+
* set of source files that ran. Only the `SF:` records are read.
|
|
264
|
+
*
|
|
265
|
+
* - `true`: the service writes its reports by itself (a dump on a timer,
|
|
266
|
+
* an exit-time dump for short-lived processes).
|
|
267
|
+
* - `{ command }`: run this (via `sh -c`) in the container to make the
|
|
268
|
+
* service write a report, before the directory is read.
|
|
269
|
+
*
|
|
270
|
+
* The directory must hold at least one lcov report each time it is read;
|
|
271
|
+
* an empty directory or a failed command fails the test with a clear
|
|
272
|
+
* error, so a hole in the coverage data is never silent. See
|
|
273
|
+
* `spectest docs /services/coverage`.
|
|
274
|
+
*/
|
|
275
|
+
coverage?: ServiceCoverage;
|
|
255
276
|
/**
|
|
256
277
|
* Files seeded into the container's filesystem **before it starts**.
|
|
257
278
|
* Each entry's `content` is written to a VM-host staging path and
|
|
@@ -1017,6 +1038,13 @@ export type ServiceImage =
|
|
|
1017
1038
|
exclude?: readonly string[];
|
|
1018
1039
|
};
|
|
1019
1040
|
|
|
1041
|
+
/**
|
|
1042
|
+
* A service's opt-in to coverage collection — see `ServiceConfig.coverage`.
|
|
1043
|
+
* `true` when the service writes its own reports; `{ command }` when
|
|
1044
|
+
* spectest must run a command in the container to produce one.
|
|
1045
|
+
*/
|
|
1046
|
+
export type ServiceCoverage = true | { command: string };
|
|
1047
|
+
|
|
1020
1048
|
export interface VolumeMount {
|
|
1021
1049
|
/**
|
|
1022
1050
|
* Named shared volume. Two services mounting the same `name` share one
|
|
@@ -1236,6 +1264,25 @@ export interface FakeContext {
|
|
|
1236
1264
|
certificate(hostnames: readonly string[]): Promise<CertificateMaterial>;
|
|
1237
1265
|
}
|
|
1238
1266
|
|
|
1267
|
+
/**
|
|
1268
|
+
* Check one service's `coverage` field. Exported so the daemon applies the
|
|
1269
|
+
* same rule to a runtime `startService` spec.
|
|
1270
|
+
*/
|
|
1271
|
+
export function validateCoverage(service: string, cov: unknown): void {
|
|
1272
|
+
if (cov === undefined || cov === true) return;
|
|
1273
|
+
if (
|
|
1274
|
+
typeof cov === "object" &&
|
|
1275
|
+
cov !== null &&
|
|
1276
|
+
typeof (cov as { command?: unknown }).command === "string" &&
|
|
1277
|
+
(cov as { command: string }).command.trim().length > 0
|
|
1278
|
+
) {
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
throw new Error(
|
|
1282
|
+
`service "${service}" has an invalid \`coverage\` value — use \`true\` or \`{ command: "<shell command>" }\``,
|
|
1283
|
+
);
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1239
1286
|
function validateEnvironmentConfig<S extends ServicesMap>(
|
|
1240
1287
|
config: EnvironmentConfig<S>,
|
|
1241
1288
|
): void {
|
|
@@ -1281,6 +1328,7 @@ function validateEnvironmentConfig<S extends ServicesMap>(
|
|
|
1281
1328
|
);
|
|
1282
1329
|
}
|
|
1283
1330
|
}
|
|
1331
|
+
validateCoverage(name, svc.coverage);
|
|
1284
1332
|
for (const raw of svc.hostnames ?? []) {
|
|
1285
1333
|
const h = raw.toLowerCase();
|
|
1286
1334
|
if (!HOSTNAME_RE.test(hostPatternBody(h))) {
|