1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
//! E2E tests for the TypeScript mutation rule: drive the built CLI binary
//! end-to-end (no mocks) against the fixture projects and assert the exit code.
//!
//! The binary spawns the bundled Node mutation adapter; in production the npm
//! launcher appends its path as `--ts-mutation-adapter`, so these tests pass the freshly-built
//! adapter ([`common::ts_adapter`]) the same way on each invocation. The fixtures are
//! **runner-only** (vitest): the tool bundles and drives Stryker; the project provides only its
//! own test runner. Requires the built node adapter and the fixtures' vitest (`npm ci` in
//! `tests/fixtures/unit_mutation/typescript`).
//!
//! The gate is **on by default and binary** — parity with the Rust arm: an un-exempted
//! surviving mutant fails the run, and the only way to pass with a survivor present is a
//! reason-required `mutation` exemption. The default fixtures are the prescribed consumer
//! package layout — `{package.json, tsconfig.json, src/**, tests/**}`, scanned at `src/`,
//! whose source imports `../package.json`: `killed` (every mutant caught) and `survivors` (a
//! coverage-passing but assertion-light suite whose mutants all survive). The flat, no-manifest
//! shape is the `loose_*` special case. Each test runs against its own staged copy so the
//! parallel Stryker runs never collide in a shared project dir.
mod common;
use std::path::{Path, PathBuf};
use std::process::Command;
use common::{tested_count, ts_adapter, GitRepo, Staged, ENGINE_NOT_RUN};
fn fixtures() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/unit_mutation")
}
/// Exit code of `testing-conventions unit mutation --language typescript [--config <cfg>] <project>`,
/// passing the bundled adapter path as `--ts-mutation-adapter`, exactly as the npm launcher does.
fn unit_mutation_exit(project: &Path, config: Option<&str>) -> i32 {
let mut command = Command::new(env!("CARGO_BIN_EXE_testing-conventions"));
command
.args(["unit", "mutation", "--language", "typescript"])
.arg("--ts-mutation-adapter")
.arg(ts_adapter());
if let Some(config) = config {
command.arg("--config").arg(fixtures().join(config));
}
command
.arg(project)
.status()
.expect("the built binary should run")
.code()
.expect("the process should exit with a code")
}
#[test]
fn run_without_the_adapter_arg_fails_clean() {
// The npm launcher appends `--ts-mutation-adapter`; run directly without it, the TS arm
// must fail (exit 1) with a clear error naming the argument — never guess at a Node entry
// on disk.
let project = Staged::new("survivors");
let out = Command::new(env!("CARGO_BIN_EXE_testing-conventions"))
.args(["unit", "mutation", "--language", "typescript"])
.arg(project.path().join("src"))
.output()
.expect("the built binary should run");
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(
out.status.code(),
Some(1),
"a missing adapter argument should fail the run; stderr: {stderr}"
);
assert!(
stderr.contains("--ts-mutation-adapter"),
"the error should name the adapter argument; got: {stderr}"
);
}
#[test]
fn a_relative_scan_path_runs_from_the_package_root() {
// `tiers::package_root` walks `scan_root.ancestors()`, which ends at `""` for a
// **relative** scan path such as `src` — `Path::new("").join("package.json")` resolves
// against the cwd, so the walk stops there and returns an empty path. The adapter then
// ran with `Command::current_dir("")`, which fails with ENOENT and was reported as
// "is `node` installed?". The reusable workflow passes `SCAN_PATH: src`, so every
// TypeScript consumer of the mutation gate hit this. Every other test here passes an
// absolute staged path, which is why it shipped.
let project = Staged::new("killed");
let out = Command::new(env!("CARGO_BIN_EXE_testing-conventions"))
.current_dir(project.path())
.args(["unit", "mutation", "--language", "typescript"])
.arg("--ts-mutation-adapter")
.arg(ts_adapter())
.arg("src")
.output()
.expect("the built binary should run");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!stderr.contains("is `node` on PATH?"),
"a missing working directory must not masquerade as a missing interpreter; got: {stderr}"
);
assert_eq!(
out.status.code(),
Some(0),
"a relative scan path should clear the gate exactly as the absolute path does; stderr: {stderr}"
);
}
#[test]
fn a_broken_adapter_path_fails_clean() {
// The argument points at a Node entry that doesn't exist (node can't find the module):
// the run must fail (exit 1) with the adapter's captured output surfaced, not hang or
// pass. Covers the non-zero-exit path of the adapter spawn.
let project = Staged::new("survivors");
let out = Command::new(env!("CARGO_BIN_EXE_testing-conventions"))
.args(["unit", "mutation", "--language", "typescript"])
.arg("--ts-mutation-adapter")
.arg("/nonexistent/testing-conventions-adapter.js")
.arg(project.path().join("src"))
.output()
.expect("the built binary should run");
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(
out.status.code(),
Some(1),
"a broken adapter path should fail the run; stderr: {stderr}"
);
assert!(
stderr.contains("adapter failed"),
"the error should report the adapter failure; got: {stderr}"
);
}
#[test]
fn killed_project_passes_and_states_the_tested_count() {
// Every mutant is caught, so the project clears the gate — and the success line
// states how many mutants the engine judged, the evidence telling this pass apart
// from an engine-skipped one. The default package layout is scanned at `src/`.
let package = Staged::new("killed");
let out = Command::new(env!("CARGO_BIN_EXE_testing-conventions"))
.args(["unit", "mutation", "--language", "typescript"])
.arg("--ts-mutation-adapter")
.arg(ts_adapter())
.arg(package.path().join("src"))
.output()
.expect("the built binary should run");
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(
out.status.code(),
Some(0),
"every mutant is caught; stderr: {stderr}"
);
assert!(
tested_count(&stdout) > 0,
"the engine ran, so the count is non-zero; got: {stdout}"
);
}
#[test]
fn a_diff_with_no_mutatable_changed_lines_reports_the_engine_not_run() {
// Only a test file changes on the diff, so the run is skipped — and the output says
// the engine never ran, distinct from the all-killed success, keeping the vacuous
// pass visible in the job log. The exit code stays 0: an empty diff owes no run.
let repo = GitRepo::new("ts-vacuous");
repo.write(
"index.ts",
"export function add(a: number, b: number): number {\n return a + b;\n}\n",
);
repo.write(
"index.test.ts",
"import { it, expect } from 'vitest';\nimport { add } from './index';\nit('pins add', () => {\n expect(add(2, 3)).toBe(5);\n});\n",
);
repo.commit("baseline");
let base = repo.head();
repo.write(
"index.test.ts",
"import { it, expect } from 'vitest';\nimport { add } from './index';\nit('pins add', () => {\n expect(add(2, 3)).toBe(5);\n expect(add(-1, 1)).toBe(0);\n});\n",
);
repo.commit("tweak only the test file");
let out = Command::new(env!("CARGO_BIN_EXE_testing-conventions"))
.args(["unit", "mutation", "--language", "typescript"])
.arg("--ts-mutation-adapter")
.arg(ts_adapter())
.args(["--base", &base])
.arg(repo.path())
.output()
.expect("the built binary should run");
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(
out.status.code(),
Some(0),
"an empty mutate set passes; stderr: {stderr}"
);
assert!(
stdout.contains(ENGINE_NOT_RUN),
"the skip is stated; got: {stdout}"
);
assert!(
!stdout.contains("every mutation was caught"),
"an engine-skipped pass never claims mutants were caught; got: {stdout}"
);
}
#[test]
fn survivors_fail_the_gate_by_default() {
// The gate is on by default and binary: an un-exempted surviving mutant fails the run, no
// config required. The default `{package.json, src/**}` layout is scanned at `src/`, the
// run rooted at the package root so the upward `../package.json` import resolves, and the
// survivors are listed scan-path-relative.
let package = Staged::new("survivors");
let out = Command::new(env!("CARGO_BIN_EXE_testing-conventions"))
.args(["unit", "mutation", "--language", "typescript"])
.arg("--ts-mutation-adapter")
.arg(ts_adapter())
.arg(package.path().join("src"))
.output()
.expect("the built binary should run");
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(
out.status.code(),
Some(1),
"the assertion-light suite leaves survivors; stderr: {stderr}"
);
assert!(
stderr.contains("unexplained surviving mutant") && stderr.contains("index.ts"),
"the survivors are listed scan-path-relative; got: {stderr}"
);
}
#[test]
fn a_loose_tree_fails_the_gate_on_survivors() {
// The loose special case: flat scripts, no manifest, scanned at the root. The gate still
// runs Stryker in place there and fails on the un-exempted survivor.
let project = Staged::loose("loose_survivors");
assert_eq!(unit_mutation_exit(project.path(), None), 1);
}
#[test]
fn an_exempted_survivor_passes_the_gate() {
// The survivor's file carries a `mutation` exemption, so the gate clears it (an
// equivalent / deliberately-defensive mutation, lifted with a reason) — the only
// way to pass with a survivor present.
let package = Staged::new("survivors");
assert_eq!(
unit_mutation_exit(&package.path().join("src"), Some("mutation_exempt_ts.toml")),
0
);
}