supercov 2.0.0 → 3.0.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.
Files changed (37) hide show
  1. package/README.md +52 -22
  2. package/docs/agent-loop.md +8 -13
  3. package/docs/assertions.md +161 -75
  4. package/docs/cli.md +111 -59
  5. package/docs/coverage-model.md +5 -6
  6. package/docs/evidence.md +0 -11
  7. package/docs/getting-started.md +1 -1
  8. package/docs/performance.md +6 -14
  9. package/docs/quality.md +1 -1
  10. package/docs/security.md +43 -12
  11. package/docs/supported-suites.md +44 -48
  12. package/docs/troubleshooting.md +12 -5
  13. package/docs/workspace-isolation.md +23 -2
  14. package/package.json +36 -21
  15. package/runtime/javascript/atomic.mjs +46 -2
  16. package/runtime/javascript/ava.mjs +82 -0
  17. package/runtime/javascript/bootstrap.cjs +43 -0
  18. package/runtime/javascript/jest.cjs +52 -15
  19. package/runtime/javascript/jest.config.mjs +9 -1
  20. package/runtime/javascript/jestReporter.mjs +5 -6
  21. package/runtime/javascript/launchSupervisor.mjs +127 -15
  22. package/runtime/javascript/mocha.mjs +180 -0
  23. package/runtime/javascript/nodeTest.mjs +57 -3
  24. package/runtime/javascript/playwright.mjs +241 -32
  25. package/runtime/javascript/provenance.mjs +1 -1
  26. package/runtime/javascript/register.mjs +290 -2
  27. package/runtime/javascript/resolve-loader.mjs +40 -1
  28. package/runtime/javascript/runnerEvidence.mjs +3 -5
  29. package/runtime/javascript/runtime.mjs +551 -61
  30. package/runtime/javascript/tap.mjs +130 -0
  31. package/runtime/javascript/transport.mjs +7 -2
  32. package/runtime/javascript/vitest.mjs +5 -8
  33. package/runtime/javascript/vitestReporter.mjs +3 -10
  34. package/docs/assertion-agent.md +0 -150
  35. package/docs/assertion-evidence.md +0 -103
  36. package/docs/assertion-maps.md +0 -316
  37. package/schemas/assertions.schema.json +0 -276
@@ -54,6 +54,10 @@ function executionLogPath(path, token = executionLog.token) {
54
54
  ? `${path.slice(0, -".jsonl".length)}${suffix}`
55
55
  : `${path}${suffix}`;
56
56
  }
57
+ /** Append one event to the run's execution log, as the supervisor does. */
58
+ export function recordExecution(value) {
59
+ record(value);
60
+ }
57
61
  function record(value) {
58
62
  const configuredPath = process.env["SUPERCOV_EXECUTION_LOG"];
59
63
  if (!configuredPath)
@@ -254,15 +258,32 @@ function launchArgv(value) {
254
258
  return value.cmd;
255
259
  return [String(value.command)];
256
260
  }
261
+ // A plain map of environment variables, as `run(argv, { ...process.env })`
262
+ // passes one: string values under variable names, at least one of them in
263
+ // capitals, and nothing an options object would carry. A name is anything
264
+ // without `=`: Windows' own environment has `ProgramFiles(x86)`, and a map
265
+ // copied from it was taken for options and its guest left unconfigured.
266
+ function environmentMap(value) {
267
+ const entries = Object.entries(value);
268
+ return (entries.length > 0 &&
269
+ entries.every(([name, item]) => typeof item === "string" && /^[^=\0]+$/.test(name)) &&
270
+ entries.some(([name]) => /^[A-Z_][A-Z0-9_]*$/.test(name)) &&
271
+ !launchOptions(value));
272
+ }
257
273
  function injectRemoteLaunch(options, mapping) {
258
- const environmentKey = "environment" in options && !("env" in options) ? "environment" : "env";
274
+ const coverage = {
275
+ ...process.env,
276
+ SUPERCOV_EXECUTION_LOG_SHARD: `${process.pid}-${++remoteLaunchSequence}`,
277
+ };
278
+ if (environmentMap(options))
279
+ return guestCoverageEnvironment(mapping, coverage, options);
280
+ // The SDK's own name for a command's environment: `env`, `environment`
281
+ // or, as E2B spells it, `envs`. A new one is called `env`.
282
+ const environmentKey = ["env", "environment", "envs"].find((key) => key in options) ?? "env";
259
283
  const existing = options[environmentKey];
260
284
  return {
261
285
  ...options,
262
- [environmentKey]: guestCoverageEnvironment(mapping, {
263
- ...process.env,
264
- SUPERCOV_EXECUTION_LOG_SHARD: `${process.pid}-${++remoteLaunchSequence}`,
265
- }, existing && typeof existing === "object"
286
+ [environmentKey]: guestCoverageEnvironment(mapping, coverage, existing && typeof existing === "object"
266
287
  ? existing
267
288
  : {}),
268
289
  };
@@ -418,6 +439,21 @@ export function wrapCapabilityObject(value, mapping) {
418
439
  guestRoot: mapping.guestRoot,
419
440
  });
420
441
  }
442
+ else if (typeof property === "string" &&
443
+ /env/i.test(property) &&
444
+ args.length === 1 &&
445
+ args[0] &&
446
+ typeof args[0] === "object" &&
447
+ environmentMap(args[0])) {
448
+ // A builder that takes the command's environment by itself
449
+ // (`withEnvironment(env)`) before it starts the command.
450
+ callArguments = [injectRemoteLaunch(args[0], mapping)];
451
+ record({
452
+ event: "remote-launch",
453
+ command: commandSummary([property]),
454
+ guestRoot: mapping.guestRoot,
455
+ });
456
+ }
421
457
  return wrapResult(Reflect.apply(member, target, callArguments), mapping);
422
458
  };
423
459
  },
@@ -435,6 +471,7 @@ export function wrapImportedCapability(value) {
435
471
  return value;
436
472
  if (isClassConstructor(value)) {
437
473
  patchBuilder(value);
474
+ patchMountMethods(value);
438
475
  return value;
439
476
  }
440
477
  const object = value;
@@ -489,6 +526,49 @@ export function wrapImportedCapability(value) {
489
526
  importedCapabilityProxies.set(object, proxy);
490
527
  return proxy;
491
528
  }
529
+ const patchedMountMethods = new WeakSet();
530
+ /**
531
+ * A builder made with `new` (`new GenericContainer(image)`) takes its mounts
532
+ * on an instance method, `withBindMounts([{ source, target }])`. The class
533
+ * itself must stay the exact value it is -- registries compare constructors
534
+ * -- so its mount methods are patched in place instead: called with a mount
535
+ * of the workspace, the builder they return is followed, and its later calls
536
+ * (`withEnvironment`, `start`) see the mapping. Called without one, they do
537
+ * what they always did.
538
+ */
539
+ function patchMountMethods(builder) {
540
+ for (let prototype = builder.prototype; prototype && prototype !== Object.prototype; prototype = Object.getPrototypeOf(prototype)) {
541
+ for (const name of Object.getOwnPropertyNames(prototype)) {
542
+ if (!/mount/i.test(name))
543
+ continue;
544
+ const descriptor = Object.getOwnPropertyDescriptor(prototype, name);
545
+ if (!descriptor ||
546
+ typeof descriptor.value !== "function" ||
547
+ !descriptor.writable ||
548
+ patchedMountMethods.has(descriptor.value))
549
+ continue;
550
+ const original = descriptor.value;
551
+ const patched = function supercovMountMethod(...args) {
552
+ const hostRoot = process.env["SUPERCOV_PROJECT_ROOT"];
553
+ const mapping = hostRoot
554
+ ? args.map((argument) => discoverWorkspaceMapping(argument, hostRoot)).find(Boolean)
555
+ : undefined;
556
+ const result = Reflect.apply(original, this, args);
557
+ if (!mapping)
558
+ return result;
559
+ record({
560
+ event: "workspace-capability",
561
+ hostRoot: mapping.hostRoot,
562
+ guestRoot: mapping.guestRoot,
563
+ cacheIdentities: [],
564
+ });
565
+ return wrapResult(result, mapping);
566
+ };
567
+ patchedMountMethods.add(patched);
568
+ Object.defineProperty(prototype, name, { ...descriptor, value: patched });
569
+ }
570
+ }
571
+ }
492
572
  function patchBuilder(builder) {
493
573
  if (patchedBuilders.has(builder))
494
574
  return;
@@ -545,20 +625,52 @@ function inspectExports(value, depth = 0) {
545
625
  }
546
626
  }
547
627
  }
628
+ // spawn, fork and execFile take (file, args?, options?, callback?): options
629
+ // follow an argument list, or an explicit null or undefined in its place. exec
630
+ // takes (command, options?, callback?), and calls execFile as (file, options,
631
+ // callback) itself -- that object is the options, not an argument list.
548
632
  function childOptionsIndex(method, args) {
549
- if (method === "spawn" || method === "spawnSync" || method === "fork")
550
- return Array.isArray(args[1]) || (args.length > 2 && args[2] !== undefined)
551
- ? 2
552
- : 1;
553
- if (method === "exec" ||
554
- method === "execSync" ||
633
+ if (method === "spawn" ||
634
+ method === "spawnSync" ||
635
+ method === "fork" ||
555
636
  method === "execFile" ||
556
637
  method === "execFileSync")
557
- return Array.isArray(args[1]) || (args.length > 2 && args[2] !== undefined)
558
- ? 2
559
- : 1;
638
+ return Array.isArray(args[1]) || (args[1] == null && args.length > 2) ? 2 : 1;
639
+ if (method === "exec" || method === "execSync")
640
+ return 1;
560
641
  return undefined;
561
642
  }
643
+ // util.promisify(exec) and util.promisify(execFile) resolve { stdout, stderr }
644
+ // through a function Node keeps on the original, and that function calls the
645
+ // original. A wrapper carries its own, which calls the wrapper, or a promisified
646
+ // call resolves the bare stdout string and skips the wrapper.
647
+ const promisifyCustom = Symbol.for("nodejs.util.promisify.custom");
648
+ function keepPromisifiedShape(original, wrapper) {
649
+ if (typeof original[promisifyCustom] !== "function")
650
+ return wrapper;
651
+ Object.defineProperty(wrapper, promisifyCustom, {
652
+ configurable: true,
653
+ value: (...args) => {
654
+ let settle;
655
+ let reject;
656
+ const promise = new Promise((ok, fail) => {
657
+ settle = ok;
658
+ reject = fail;
659
+ });
660
+ promise.child = wrapper(...args, (error, stdout, stderr) => {
661
+ if (error !== null) {
662
+ error.stdout = stdout;
663
+ error.stderr = stderr;
664
+ reject(error);
665
+ }
666
+ else
667
+ settle({ stdout, stderr });
668
+ });
669
+ return promise;
670
+ },
671
+ });
672
+ return wrapper;
673
+ }
562
674
  function injectChildEnvironment(method, args) {
563
675
  const index = childOptionsIndex(method, args);
564
676
  if (index === undefined)
@@ -617,7 +729,7 @@ function patchChildProcesses() {
617
729
  configurable: true,
618
730
  enumerable: true,
619
731
  writable: true,
620
- value: (...args) => Reflect.apply(original, childProcess, injectChildEnvironment(method, args)),
732
+ value: keepPromisifiedShape(original, (...args) => Reflect.apply(original, childProcess, injectChildEnvironment(method, args))),
621
733
  });
622
734
  }
623
735
  syncBuiltinESMExports();
@@ -0,0 +1,180 @@
1
+ // Supercov Mocha adapter. The preload imports it in every Mocha process --
2
+ // the CLI, the child the CLI forks for Node options, and each --parallel
3
+ // worker -- and it patches Mocha's own Runner, so a suite runs exactly as
4
+ // written: no reporter, --require or configuration is added. Each test
5
+ // attempt gets its exact scope when Mocha announces it, before its
6
+ // beforeEach hooks, and its coverage, assertion phases and outcome are
7
+ // written after its afterEach hooks. Code that runs outside any test --
8
+ // loading the test files, before/after all hooks -- is written as setup. It
9
+ // computes no coverage.
10
+ import { realpathSync } from "node:fs";
11
+ import { join, relative, resolve, sep } from "node:path";
12
+ import { pathToFileURL } from "node:url";
13
+ import { appendEvidenceRecord } from "./atomic.mjs";
14
+ import { inferTestProvenance } from "./provenance.mjs";
15
+ import { runnerExecutionScope } from "./runnerEvidence.mjs";
16
+
17
+ const SETUP_HOOK = /^"(?:before|after) all" hook/;
18
+
19
+ function localFile(path) {
20
+ return path ? relative(process.cwd(), path).split(sep).join("/") : undefined;
21
+ }
22
+
23
+ // The installed mocha package the entrypoint belongs to.
24
+ function mochaRoot(entrypoint) {
25
+ let real;
26
+ try {
27
+ real = realpathSync(entrypoint).replaceAll("\\", "/");
28
+ }
29
+ catch {
30
+ return undefined;
31
+ }
32
+ const at = real.lastIndexOf("/node_modules/mocha/");
33
+ return at < 0 ? undefined : real.slice(0, at + "/node_modules/mocha".length);
34
+ }
35
+
36
+ export async function adaptMocha(entrypoint) {
37
+ const runtime = globalThis.__SUPERCOV_DIRECT_RUNTIME__ ?? process.__SUPERCOV_DIRECT_RUNTIME__;
38
+ const evidenceDirectory = process.env.SUPERCOV_EVIDENCE_DIR;
39
+ const root = mochaRoot(entrypoint);
40
+ if (!runtime || !evidenceDirectory || !root)
41
+ return;
42
+ // Mocha 12 is ESM and exports { Runner }; 10 and 11 are CommonJS and
43
+ // export the class itself. Either way this is the instance Mocha uses.
44
+ let loaded;
45
+ try {
46
+ loaded = await import(pathToFileURL(join(root, "lib", "runner.js")).href);
47
+ }
48
+ catch {
49
+ return;
50
+ }
51
+ const Runner = loaded.Runner ?? loaded.default?.Runner ?? loaded.default;
52
+ const run = Runner?.prototype?.run;
53
+ if (typeof run !== "function" || run.supercovMocha)
54
+ return;
55
+ // One snapshot per test, as under Jest; the server transport would only
56
+ // repeat it, unattributed.
57
+ runtime.enableRuntimeSnapshotEvidence();
58
+ const directory = resolve(process.cwd(), evidenceDirectory);
59
+ const write = (payload) => appendEvidenceRecord(directory, "mocha", payload);
60
+ // A --parallel parent overrides run() and never reaches this: its workers
61
+ // run the tests, each with a Runner of its own.
62
+ Runner.prototype.run = function supercovMochaRun(...args) {
63
+ observe(this, runtime, write);
64
+ return Reflect.apply(run, this, args);
65
+ };
66
+ Runner.prototype.run.supercovMocha = true;
67
+ }
68
+
69
+ function observe(runner, runtime, write) {
70
+ // Tests that share a file and a full title are told apart by the order in
71
+ // which their first attempts ran; a retry keeps its test's identity.
72
+ const firstAttempts = new Map();
73
+ const identities = new WeakMap();
74
+ let active;
75
+ let setups = 0;
76
+ function identity(test) {
77
+ const known = identities.get(test);
78
+ if (known)
79
+ return known;
80
+ const file = test.file;
81
+ const name = test.titlePath().join(" > ");
82
+ const key = `${file ?? ""}\0${name}`;
83
+ let occurrence = firstAttempts.get(key) ?? 0;
84
+ if (test.currentRetry() > 0)
85
+ occurrence = Math.max(occurrence - 1, 0);
86
+ else
87
+ firstAttempts.set(key, occurrence + 1);
88
+ const value = {
89
+ runner: "mocha",
90
+ file,
91
+ name,
92
+ ...(occurrence ? { registrationOrdinal: occurrence } : {}),
93
+ retry: test.currentRetry(),
94
+ };
95
+ identities.set(test, value);
96
+ return value;
97
+ }
98
+ function record(id, status, scope, snapshot) {
99
+ const testFile = localFile(id.file);
100
+ write({
101
+ testId: scope ? scope.testId : runnerExecutionScope(id).testId,
102
+ ...(scope ? { scope } : {}),
103
+ test: id.name,
104
+ ...(testFile ? { testFile } : {}),
105
+ title: id.name.split(" > ").at(-1) ?? id.name,
106
+ retry: id.retry ?? 0,
107
+ status,
108
+ ...(id.role ? { role: id.role } : {}),
109
+ provenance: inferTestProvenance({
110
+ runner: "mocha",
111
+ file: testFile,
112
+ explicitKind: process.env.SUPERCOV_TEST_KIND,
113
+ }),
114
+ ...(scope ? { phases: runtime.takeNodeAssertionPhases(scope) } : {}),
115
+ runtime: snapshot ? [snapshot] : [],
116
+ browser: [],
117
+ server: [],
118
+ });
119
+ }
120
+ // What ran since the last test or setup ended, outside any test.
121
+ function flushSetup(name) {
122
+ const snapshot = runtime.coverageSnapshot();
123
+ runtime.resetCoverage();
124
+ if (!snapshot.hits.length && !snapshot.decisions.length)
125
+ return;
126
+ setups += 1;
127
+ const id = { runner: "mocha", name, role: "setup", registrationOrdinal: setups };
128
+ record(id, "passed", runnerExecutionScope(id), snapshot);
129
+ }
130
+ function flushActive() {
131
+ const current = active;
132
+ active = undefined;
133
+ if (!current)
134
+ return;
135
+ record(current.id, current.status, current.scope, runtime.coverageSnapshot());
136
+ runtime.resetCoverage();
137
+ runtime.activateCoverageScope();
138
+ }
139
+ runner.on("start", () => flushSetup("module setup"));
140
+ runner.on("test", (test) => {
141
+ flushActive();
142
+ flushSetup(`${test.parent?.fullTitle() || "root suite"} > hooks`);
143
+ const id = identity(test);
144
+ const scope = runnerExecutionScope(id);
145
+ active = { test, id, scope, status: "unknown" };
146
+ runtime.activateCoverageScope(scope);
147
+ runtime.resetCoverage(scope.testId);
148
+ });
149
+ runner.on("pass", (test) => {
150
+ if (active?.test === test)
151
+ active.status = "passed";
152
+ });
153
+ // A failed attempt Mocha will run again, and a failing beforeEach or
154
+ // afterEach hook, fail the attempt that is running.
155
+ runner.on("retry", (test) => {
156
+ if (active?.test === test)
157
+ active.status = "failed";
158
+ });
159
+ runner.on("fail", (runnable) => {
160
+ if (active && (active.test === runnable || runnable.type === "hook"))
161
+ active.status = "failed";
162
+ else if (runnable.type === "test")
163
+ record(identity(runnable), "failed");
164
+ });
165
+ runner.on("pending", (test) => {
166
+ if (active?.test === test)
167
+ active.status = "skipped";
168
+ else
169
+ record(identity(test), "skipped");
170
+ });
171
+ runner.on("hook", (hook) => {
172
+ if (SETUP_HOOK.test(hook.title ?? ""))
173
+ flushActive();
174
+ });
175
+ runner.on("suite end", flushActive);
176
+ runner.on("end", () => {
177
+ flushActive();
178
+ flushSetup("hooks");
179
+ });
180
+ }
@@ -1,3 +1,4 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
1
2
  import * as native from "node:test";
2
3
  import { fileURLToPath } from "node:url";
3
4
  import vm from "node:vm";
@@ -86,6 +87,43 @@ function restoreUserError(error, depth = 0) {
86
87
  }
87
88
  return error;
88
89
  }
90
+ // The describe/suite blocks a test is registered in, outermost first. A test
91
+ // is named by them as Vitest, Jest and Playwright name theirs, so two tests
92
+ // with one name in different suites stay apart, and `tests affected --names`
93
+ // gives what --test-name-pattern matches. A store rather than a stack: an
94
+ // async suite registers tests after it awaits.
95
+ const suitePath = new AsyncLocalStorage();
96
+ function suiteName(args, callback) {
97
+ return typeof args[0] === "string" ? args[0] : callback.name || "<anonymous>";
98
+ }
99
+ function wrappedSuite(original) {
100
+ const wrapped = function supercovNodeSuite(...args) {
101
+ const index = callbackIndex(args);
102
+ if (index < 0)
103
+ return Reflect.apply(original, this, args);
104
+ const callback = args[index];
105
+ const path = [...(suitePath.getStore() ?? []), suiteName(args, callback)];
106
+ const next = [...args];
107
+ next[index] = function supercovNodeSuiteCallback(...callbackArgs) {
108
+ return suitePath.run(path, () => Reflect.apply(callback, this, callbackArgs));
109
+ };
110
+ // node:test reports a suite at its direct caller, as it does a test.
111
+ const registration = registrationAt(callerLocation(supercovNodeSuite));
112
+ if (registration)
113
+ return registration(this === undefined ? original : original.bind(this), next);
114
+ return Reflect.apply(original, this, next);
115
+ };
116
+ for (const property of ["skip", "todo", "only"]) {
117
+ const member = original[property];
118
+ if (typeof member === "function")
119
+ Object.defineProperty(wrapped, property, {
120
+ configurable: true,
121
+ enumerable: true,
122
+ value: wrappedSuite(member),
123
+ });
124
+ }
125
+ return wrapped;
126
+ }
89
127
  const registrationCounts = new Map();
90
128
  function wrappedRegistration(original, parentTestId, forcedStatus) {
91
129
  const wrapped = function supercovNodeTest(...args) {
@@ -96,7 +134,9 @@ function wrappedRegistration(original, parentTestId, forcedStatus) {
96
134
  const location = callerLocation(supercovNodeTest);
97
135
  const identity = {
98
136
  runner: "node:test",
99
- name: testName(args, callback),
137
+ name: parentTestId
138
+ ? testName(args, callback)
139
+ : [...(suitePath.getStore() ?? []), testName(args, callback)].join(" > "),
100
140
  ...(parentTestId ? { parentTestId } : {}),
101
141
  ...location,
102
142
  // Source-map producers disagree about whether a call expression maps to
@@ -260,8 +300,8 @@ function restoringHook(original, scope, hookName = "hook") {
260
300
  }
261
301
  export const test = wrappedRegistration(native.test);
262
302
  export const it = wrappedRegistration(native.it);
263
- export const suite = native.suite;
264
- export const describe = native.describe;
303
+ export const suite = wrappedSuite(native.suite);
304
+ export const describe = wrappedSuite(native.describe);
265
305
  export const before = restoringHook(native.before, undefined, "before");
266
306
  export const after = restoringHook(native.after, undefined, "after");
267
307
  export const beforeEach = restoringHook(native.beforeEach, undefined, "beforeEach");
@@ -270,4 +310,18 @@ export const mock = native.mock;
270
310
  export const snapshot = native.snapshot;
271
311
  export const run = native.run;
272
312
  export const assert = native.assert;
313
+ // node:test hangs its whole API off `test` (test.mock, test.describe,
314
+ // test.expectFailure, test.getTestContext...). What the adapter does not wrap
315
+ // stays the runtime's own.
316
+ const adapted = { it, describe, suite, before, after, beforeEach, afterEach, test };
317
+ for (const property of Object.keys(native.test)) {
318
+ if (property in test && !(property in adapted))
319
+ continue;
320
+ Object.defineProperty(test, property, {
321
+ configurable: true,
322
+ enumerable: true,
323
+ writable: true,
324
+ value: adapted[property] ?? native.test[property],
325
+ });
326
+ }
273
327
  export default test;