module-spec 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -1
- package/lib/languages/typescript/mutate.js +70 -29
- package/lib/mutation.js +8 -2
- package/package.json +1 -1
- package/spec/tests.md +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 0.2.1
|
|
4
4
|
|
|
5
|
+
- Under vitest, a live mutant of a module-level constant or regex runs again in a fresh vitest
|
|
6
|
+
process. Stryker's vitest runner, given the module's test files, ran its tests without the
|
|
7
|
+
mutant switched on, so it read as live whatever the tests checked.
|
|
8
|
+
|
|
9
|
+
## 0.2.0
|
|
10
|
+
|
|
11
|
+
- `mutate` counts only the module's core, shell and door tests. An adapter's test and a
|
|
12
|
+
contract test prove the adapter against the outside and are slow, so a mutant that only
|
|
13
|
+
they catch now lives until a test through the door catches it. On factory's chat module
|
|
14
|
+
those tests were 39 of 40 seconds of test time, rerun for every core mutant.
|
|
15
|
+
- Under vitest, `mutate` keeps each module's verdicts in
|
|
16
|
+
`node_modules/.cache/module-spec/mutate/`, and a rerun tests again only the mutants whose
|
|
17
|
+
code or covering tests changed.
|
|
5
18
|
- `mutate` changes only `core/`, `shell/` and the door, as the spec says. Before,
|
|
6
19
|
it also changed files in no part, such as an app's `main.ts` or a
|
|
7
20
|
`vitest.config.ts`.
|
|
@@ -11,10 +11,18 @@ const OUTPUT_LIMIT = 512 * 1024 * 1024;
|
|
|
11
11
|
const CAUGHT = new Set(["Killed", "Timeout"]);
|
|
12
12
|
const LIVE = new Set(["Survived", "NoCoverage"]);
|
|
13
13
|
|
|
14
|
+
/** The project's own vitest, as Stryker's vitest runner loads it, or else the kit's. */
|
|
15
|
+
function vitestBin(root) {
|
|
16
|
+
const local = path.join(root, "node_modules", "vitest");
|
|
17
|
+
const dir = fs.existsSync(path.join(local, "package.json")) ? local : kitPackageDir("vitest");
|
|
18
|
+
return path.join(dir, JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8")).bin.vitest);
|
|
19
|
+
}
|
|
20
|
+
|
|
14
21
|
/** The shell command that runs only `tests`, with the project's own runner. Stryker runs it once per mutant. */
|
|
15
22
|
function testCommand(root, tests) {
|
|
16
23
|
const files = tests.map((file) => JSON.stringify(file)).join(" ");
|
|
17
|
-
|
|
24
|
+
if (testRunnerOf(root) !== "vitest") return `${JSON.stringify(process.execPath)} --test ${files}`;
|
|
25
|
+
return `${JSON.stringify(process.execPath)} ${JSON.stringify(vitestBin(root))} run ${files}`;
|
|
18
26
|
}
|
|
19
27
|
|
|
20
28
|
const oneLine = (text) => {
|
|
@@ -40,37 +48,70 @@ function runnerConfig(root, tests) {
|
|
|
40
48
|
};
|
|
41
49
|
}
|
|
42
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Under vitest, Stryker keeps each mutant's verdict and the tests that covered it, so a rerun tests again only the
|
|
53
|
+
* mutants whose code or covering tests changed. The command runner reports no tests, so a weakened test would keep
|
|
54
|
+
* its old verdict there, and it reruns everything. The file sits under node_modules/.cache, which no project commits.
|
|
55
|
+
*/
|
|
56
|
+
function incrementalConfig(root, module) {
|
|
57
|
+
if (testRunnerOf(root) !== "vitest") return {};
|
|
58
|
+
const file = `${module.dir.replaceAll("/", "_")}.json`;
|
|
59
|
+
return { incremental: true, incrementalFile: path.join(root, "node_modules", ".cache", "module-spec", "mutate", file) };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** One Stryker run with `config`, in its own folder under `work`, and the mutants it reported. */
|
|
63
|
+
function strykerRun(root, work, name, config) {
|
|
64
|
+
const report = path.join(work, `${name}.json`);
|
|
65
|
+
const configFile = path.join(work, `${name}.config.json`);
|
|
66
|
+
fs.writeFileSync(configFile, JSON.stringify({
|
|
67
|
+
...config,
|
|
68
|
+
reporters: ["json"],
|
|
69
|
+
jsonReporter: { fileName: report },
|
|
70
|
+
tempDirName: path.join(work, `${name}-sandbox`),
|
|
71
|
+
cleanTempDir: "always",
|
|
72
|
+
checkers: [],
|
|
73
|
+
// Stryker rewrites tsconfig.json with the project's own TypeScript, which from version 7 has no JS API. The kit's
|
|
74
|
+
// runners read TypeScript without it, so the rewrite is skipped by naming a file that does not exist.
|
|
75
|
+
tsconfigFile: "module-spec-skips-the-tsconfig-rewrite.json",
|
|
76
|
+
logLevel: "error",
|
|
77
|
+
}));
|
|
78
|
+
const dir = kitPackageDir("@stryker-mutator/core");
|
|
79
|
+
const bin = path.join(dir, JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8")).bin.stryker);
|
|
80
|
+
const run = spawnSync(process.execPath, [bin, "run", configFile], { cwd: root, env: environmentForTests(), encoding: "utf8", maxBuffer: OUTPUT_LIMIT });
|
|
81
|
+
if (run.status !== 0 || !fs.existsSync(report)) throw new Error(`Stryker failed:\n${run.stderr || run.stdout}`);
|
|
82
|
+
return Object.entries(JSON.parse(fs.readFileSync(report, "utf8")).files).flatMap(([file, { mutants }]) => mutants.map((mutant) => ({ ...mutant, file })));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const mutantKey = (mutant) => `${mutant.file}:${mutant.location.start.line}:${mutant.location.start.column} ${mutant.mutatorName} ${mutant.replacement ?? ""}`;
|
|
86
|
+
/** Stryker reads a range's columns from another origin than its report writes them, so a range names whole lines. */
|
|
87
|
+
const linesOf = ({ file, location: { start, end } }) => `${file}:${start.line}-${end.line}`;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Stryker's vitest runner, given the module's test files, runs a static mutant's tests without the mutant switched
|
|
91
|
+
* on, so a module-level constant or regex reads as live whatever its tests check. A command run starts vitest anew
|
|
92
|
+
* for each mutant, so the module loads with the mutant on. Only the live static mutants run again that way.
|
|
93
|
+
*/
|
|
94
|
+
function withStaticMutantsRetested(root, work, tests, mutants) {
|
|
95
|
+
const doubtful = mutants.filter((mutant) => mutant.static && LIVE.has(mutant.status));
|
|
96
|
+
if (doubtful.length === 0) return mutants;
|
|
97
|
+
const retested = new Map(
|
|
98
|
+
strykerRun(root, work, "static", {
|
|
99
|
+
testRunner: "command",
|
|
100
|
+
commandRunner: { command: testCommand(root, tests) },
|
|
101
|
+
coverageAnalysis: "off",
|
|
102
|
+
plugins: [],
|
|
103
|
+
mutate: [...new Set(doubtful.map(linesOf))],
|
|
104
|
+
}).map((mutant) => [mutantKey(mutant), mutant.status]),
|
|
105
|
+
);
|
|
106
|
+
return mutants.map((mutant) => (retested.has(mutantKey(mutant)) ? { ...mutant, status: retested.get(mutantKey(mutant)) } : mutant));
|
|
107
|
+
}
|
|
108
|
+
|
|
43
109
|
/** Mutation testing by the kit's own StrykerJS, in a sandbox under the system temp folder. */
|
|
44
|
-
export async function mutate({ root, files, tests }) {
|
|
110
|
+
export async function mutate({ root, module, files, tests }) {
|
|
45
111
|
const work = fs.mkdtempSync(path.join(os.tmpdir(), "module-spec-stryker-"));
|
|
46
112
|
try {
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
fs.writeFileSync(configFile, JSON.stringify({
|
|
50
|
-
...runnerConfig(root, tests),
|
|
51
|
-
mutate: files,
|
|
52
|
-
reporters: ["json"],
|
|
53
|
-
jsonReporter: { fileName: report },
|
|
54
|
-
tempDirName: path.join(work, "sandbox"),
|
|
55
|
-
cleanTempDir: "always",
|
|
56
|
-
checkers: [],
|
|
57
|
-
// Stryker rewrites tsconfig.json with the project's own TypeScript, which from version 7 has no JS API. The kit's
|
|
58
|
-
// runners read TypeScript without it, so the rewrite is skipped by naming a file that does not exist.
|
|
59
|
-
tsconfigFile: "module-spec-skips-the-tsconfig-rewrite.json",
|
|
60
|
-
logLevel: "error",
|
|
61
|
-
}));
|
|
62
|
-
const dir = kitPackageDir("@stryker-mutator/core");
|
|
63
|
-
const bin = path.join(dir, JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8")).bin.stryker);
|
|
64
|
-
const run = spawnSync(process.execPath, [bin, "run", configFile], {
|
|
65
|
-
cwd: root,
|
|
66
|
-
env: environmentForTests(),
|
|
67
|
-
encoding: "utf8",
|
|
68
|
-
maxBuffer: OUTPUT_LIMIT,
|
|
69
|
-
});
|
|
70
|
-
if (run.status !== 0 || !fs.existsSync(report)) throw new Error(`Stryker failed:\n${run.stderr || run.stdout}`);
|
|
71
|
-
const mutants = Object.entries(JSON.parse(fs.readFileSync(report, "utf8")).files).flatMap(([file, { mutants }]) =>
|
|
72
|
-
mutants.map((mutant) => ({ ...mutant, file })),
|
|
73
|
-
);
|
|
113
|
+
const first = strykerRun(root, work, "run", { ...runnerConfig(root, tests), mutate: files, ...incrementalConfig(root, module) });
|
|
114
|
+
const mutants = testRunnerOf(root) === "vitest" ? withStaticMutantsRetested(root, work, tests, first) : first;
|
|
74
115
|
return {
|
|
75
116
|
killed: mutants.filter((mutant) => CAUGHT.has(mutant.status)).length,
|
|
76
117
|
survivors: mutants
|
package/lib/mutation.js
CHANGED
|
@@ -15,13 +15,19 @@ export const MUTATION_REPORT = "mutation-report.txt";
|
|
|
15
15
|
*/
|
|
16
16
|
const MUTATED = new Set(["core", "shell"]);
|
|
17
17
|
|
|
18
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* The files mutation testing changes in a module, and the module's own tests, which must notice. An adapter's test
|
|
20
|
+
* proves the adapter against the outside and is slow, while the code it reaches is proven through the door, so it
|
|
21
|
+
* does not count.
|
|
22
|
+
*/
|
|
19
23
|
export function mutationTargets(module) {
|
|
20
24
|
const files = [];
|
|
21
25
|
const tests = [];
|
|
22
26
|
for (const file of module.files) {
|
|
23
27
|
const place = placeOf(module, file);
|
|
24
|
-
if (place.test)
|
|
28
|
+
if (place.test) {
|
|
29
|
+
if (place.part !== "adapters" && !place.contractTest) tests.push(file);
|
|
30
|
+
}
|
|
25
31
|
else if (place.door || MUTATED.has(place.part)) files.push(file);
|
|
26
32
|
}
|
|
27
33
|
return { files, tests };
|
package/package.json
CHANGED
package/spec/tests.md
CHANGED
|
@@ -130,8 +130,10 @@ code does. Mutation testing catches it, because every mutant lives.
|
|
|
130
130
|
hold only types, not `adapters/`, whose contract tests need the real
|
|
131
131
|
service, and not `view/`, which holds no rules
|
|
132
132
|
([F3](frontend.md#f3-a-view-holds-no-rules)).
|
|
133
|
-
- **Which tests count:** only the module's own
|
|
134
|
-
|
|
133
|
+
- **Which tests count:** only the module's own, and not its adapters' tests
|
|
134
|
+
or contract tests. Those prove an adapter against the outside and are slow.
|
|
135
|
+
The core they reach is proven through the door. A mutant that only another
|
|
136
|
+
module's test, or an adapter's test, catches still lives.
|
|
135
137
|
- **Zero live mutants:** `module-spec mutate` fails on any live mutant, and
|
|
136
138
|
names it, even on a module's first run.
|
|
137
139
|
- **When no test can tell:** some mutants behave exactly like the code, such
|