mandrel-platform 1.2.0 → 1.3.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.
@@ -0,0 +1,256 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * stryker-base-config.test.mjs — regression guard for the shared Stryker base
4
+ * config's bail and timeout shape.
5
+ *
6
+ * The bug this pins: with Stryker's default bail (`disableBail: false`), the
7
+ * vitest runner can mark a mutant Survived having completed zero tests — the
8
+ * mutant carries a non-empty covering-test list while its completed-test count
9
+ * is zero. A consumer measured 92 of 345 mutants flipping verdict between two
10
+ * identical runs, and a recorded score of 53.5% against a real 72.29%. A
11
+ * committed baseline taken under bail is therefore a floor under a number
12
+ * nothing measured: the gate is not merely noisy, it is wrong in the direction
13
+ * that hides surviving mutants.
14
+ *
15
+ * Disabling bail is not free — every mutant now runs its full covering set, so
16
+ * the run lengthens. The timeouts in this same base config must move with it,
17
+ * or the suite trades a wrong number for a silent overrun, and an overrun that
18
+ * preserves the prior result and exits clean is the same failure wearing a
19
+ * different mask. That coupling is why bail and the timeouts are asserted
20
+ * together here rather than in two independent tests: re-tightening either half
21
+ * alone reintroduces the defect.
22
+ *
23
+ * This repository ships the config but runs no mutation suite of its own, so
24
+ * the contract is asserted by shape. The run-to-run stability it buys is
25
+ * observable only in a consumer.
26
+ *
27
+ * The delivery half is asserted end-to-end rather than by shape: the suite
28
+ * imports the base through its published package specifier — the same
29
+ * resolution a consumer's `stryker.config.mjs` performs — and checks the
30
+ * settings that arrive. Stryker has no `extends` option (adjudicated against
31
+ * @stryker-mutator/core and @stryker-mutator/api 9.6.1), so the spread import
32
+ * is the only mechanism that delivers anything at all, and it is the only one
33
+ * documented.
34
+ *
35
+ * Run: node --test scripts/stryker-base-config.test.mjs
36
+ */
37
+
38
+ import assert from "node:assert/strict";
39
+ import { test } from "node:test";
40
+ import { readFileSync } from "node:fs";
41
+
42
+ const CONFIG_PATH = "config/stryker.base.json";
43
+ const PACKAGE_PATH = "package.json";
44
+
45
+ /**
46
+ * The specifier a consumer's `stryker.config.mjs` imports. Node resolves it
47
+ * through this package's own `exports` map (self-reference), so importing it
48
+ * here exercises the same resolution a consumer gets rather than a stand-in
49
+ * for it.
50
+ */
51
+ const PACKAGE_SPECIFIER = "mandrel-platform/stryker.base.json";
52
+
53
+ /** Stryker's own defaults, per https://stryker-mutator.io/docs/stryker-js/configuration. */
54
+ const STRYKER_DEFAULTS = Object.freeze({
55
+ timeoutMS: 5000,
56
+ timeoutFactor: 1.5,
57
+ dryRunTimeoutMinutes: 5,
58
+ });
59
+
60
+ /**
61
+ * The timeout floor this config carried *before* bail was disabled. Bail
62
+ * cut every mutant's run short, so 60s absolute was survivable; without it the
63
+ * covering set runs to completion and the budget has to grow. Asserting
64
+ * strictly above the old value is what makes "raised alongside the bail
65
+ * change" mechanically checkable rather than a claim in a commit message.
66
+ */
67
+ const PRE_CHANGE_TIMEOUT_MS = 60000;
68
+
69
+ function readConfig() {
70
+ return JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
71
+ }
72
+
73
+ test("base config disables bail so every mutant runs its full covering set", () => {
74
+ const config = readConfig();
75
+
76
+ assert.equal(
77
+ config.disableBail,
78
+ true,
79
+ `${CONFIG_PATH} must set "disableBail": true. Stryker defaults it to false, ` +
80
+ "and under bail the runner can score a mutant Survived having completed " +
81
+ "zero of its covering tests.",
82
+ );
83
+ });
84
+
85
+ test("timeouts are raised above both Stryker's defaults and the pre-change floor", async (t) => {
86
+ const config = readConfig();
87
+
88
+ await t.test("timeoutMS clears the pre-bail-change floor", () => {
89
+ assert.equal(
90
+ typeof config.timeoutMS,
91
+ "number",
92
+ `${CONFIG_PATH} must pin "timeoutMS" explicitly, not inherit it.`,
93
+ );
94
+ assert.ok(
95
+ config.timeoutMS > PRE_CHANGE_TIMEOUT_MS,
96
+ `"timeoutMS" is ${config.timeoutMS}; it must exceed the pre-change ` +
97
+ `${PRE_CHANGE_TIMEOUT_MS} because disabling bail lengthens every mutant's run.`,
98
+ );
99
+ });
100
+
101
+ await t.test("timeoutFactor is pinned above Stryker's default", () => {
102
+ assert.equal(
103
+ typeof config.timeoutFactor,
104
+ "number",
105
+ `${CONFIG_PATH} must pin "timeoutFactor" explicitly, not inherit it.`,
106
+ );
107
+ assert.ok(
108
+ config.timeoutFactor > STRYKER_DEFAULTS.timeoutFactor,
109
+ `"timeoutFactor" is ${config.timeoutFactor}; it must exceed Stryker's ` +
110
+ `${STRYKER_DEFAULTS.timeoutFactor} default so a full covering set is not ` +
111
+ "clipped as a false Timeout.",
112
+ );
113
+ });
114
+
115
+ await t.test("dryRunTimeoutMinutes is pinned above Stryker's default", () => {
116
+ assert.equal(
117
+ typeof config.dryRunTimeoutMinutes,
118
+ "number",
119
+ `${CONFIG_PATH} must pin "dryRunTimeoutMinutes" explicitly, not inherit it.`,
120
+ );
121
+ assert.ok(
122
+ config.dryRunTimeoutMinutes > STRYKER_DEFAULTS.dryRunTimeoutMinutes,
123
+ `"dryRunTimeoutMinutes" is ${config.dryRunTimeoutMinutes}; it must exceed ` +
124
+ `Stryker's ${STRYKER_DEFAULTS.dryRunTimeoutMinutes}-minute default.`,
125
+ );
126
+ });
127
+ });
128
+
129
+ test("a Timeout is not silently absorbed into the score", () => {
130
+ const config = readConfig();
131
+
132
+ // `ignoreStatic` legitimately drops static mutants from the denominator.
133
+ // Nothing else may: an option that reclassifies or suppresses a timed-out or
134
+ // errored mutant would let a run that overran still report the prior number
135
+ // and exit clean — the exact failure disabling bail is meant to end.
136
+ assert.equal(
137
+ Object.hasOwn(config, "maxTestRunnerReuse"),
138
+ false,
139
+ 'Do not pin "maxTestRunnerReuse" in the shared base; it masks runner-level ' +
140
+ "instability that the timeout budget is supposed to surface.",
141
+ );
142
+ assert.notEqual(
143
+ config.allowEmpty,
144
+ true,
145
+ '"allowEmpty" must stay false/absent: a dry run that executed no tests must ' +
146
+ "fail loudly rather than score an empty suite.",
147
+ );
148
+ });
149
+
150
+ test("the documented spread-import mechanism delivers the bail-free settings", async () => {
151
+ // This is the consumer's own resolution path, not a proxy for it: the
152
+ // specifier below is resolved through the published `exports` map by Node,
153
+ // exactly as `stryker.config.mjs` in a consuming repo resolves it. Asserting
154
+ // the exports-map *string* instead would pass while the file it points at
155
+ // carried the wrong values, or while the entry was absent from `files` — the
156
+ // two ways the recipe can be documented correctly and still deliver nothing.
157
+ const { default: base } = await import(PACKAGE_SPECIFIER, {
158
+ with: { type: "json" },
159
+ });
160
+
161
+ assert.equal(
162
+ base.disableBail,
163
+ true,
164
+ `Importing "${PACKAGE_SPECIFIER}" must yield "disableBail": true. A consumer ` +
165
+ "spreading this object into stryker.config.mjs gets whatever this " +
166
+ "resolves to, so a broken export or a stale published file silently " +
167
+ "restores bail.",
168
+ );
169
+ assert.ok(
170
+ base.timeoutMS > PRE_CHANGE_TIMEOUT_MS,
171
+ `Importing "${PACKAGE_SPECIFIER}" must also carry the raised timeout budget ` +
172
+ `that bail-free runs need; got timeoutMS ${base.timeoutMS}.`,
173
+ );
174
+
175
+ // The exports map must reach *this* file, or the assertions in the rest of
176
+ // this suite are guarding a config no consumer receives.
177
+ assert.deepEqual(
178
+ base,
179
+ readConfig(),
180
+ `"${PACKAGE_SPECIFIER}" must resolve to ${CONFIG_PATH} — the file every ` +
181
+ "other test here asserts.",
182
+ );
183
+ });
184
+
185
+ test("the config declares no `extends`, which Stryker does not support", () => {
186
+ const config = readConfig();
187
+
188
+ // Adjudicated against @stryker-mutator/core 9.6.1 and @stryker-mutator/api
189
+ // 9.6.1: the config reader loads exactly one config file and deep-merges CLI
190
+ // arguments over it — there is no extends resolution step anywhere in it —
191
+ // and `extends` is absent from the 45 top-level properties in the published
192
+ // stryker-core.json schema. A base that advertises an `extends` recipe sends
193
+ // consumers down a path where the settings below arrive not at all.
194
+ assert.equal(
195
+ Object.hasOwn(config, "extends"),
196
+ false,
197
+ `${CONFIG_PATH} must not declare "extends". Stryker has no such option; ` +
198
+ "the supported mechanism is importing this file by its package " +
199
+ "specifier and spreading it (see the README).",
200
+ );
201
+ });
202
+
203
+ test("annotations use the `_comment` suffix Stryker's validator exempts", () => {
204
+ const config = readConfig();
205
+
206
+ // Stryker warns "Unknown stryker config option \"<key>\"" for any top-level
207
+ // key that is neither in its schema nor suffixed `_comment`. A prefix-named
208
+ // key like `_comment_disableBail` fails that suffix check, so documenting
209
+ // the base costs every consumer a warning on every run.
210
+ const STRYKER_OPTIONS = new Set([
211
+ "$schema",
212
+ "packageManager",
213
+ "reporters",
214
+ "coverageAnalysis",
215
+ "ignoreStatic",
216
+ "cleanTempDir",
217
+ "disableBail",
218
+ "timeoutMS",
219
+ "timeoutFactor",
220
+ "dryRunTimeoutMinutes",
221
+ "thresholds",
222
+ ]);
223
+
224
+ const wouldWarn = Object.keys(config).filter(
225
+ (key) => !STRYKER_OPTIONS.has(key) && !key.endsWith("_comment"),
226
+ );
227
+
228
+ assert.deepEqual(
229
+ wouldWarn,
230
+ [],
231
+ `${CONFIG_PATH} keys ${JSON.stringify(wouldWarn)} are neither pinned Stryker ` +
232
+ 'options nor suffixed "_comment", so Stryker reports each as an unknown ' +
233
+ "config option in every consumer run. Rename annotations to " +
234
+ "`<topic>_comment`.",
235
+ );
236
+ });
237
+
238
+ test("every pinned Stryker option is still exported to consumers", () => {
239
+ const pkg = JSON.parse(readFileSync(PACKAGE_PATH, "utf8"));
240
+
241
+ // The import test above proves resolution works *here*, where Node's
242
+ // self-reference falls back to the local file. Publication is what carries it
243
+ // to a consumer, and that needs both the exports entry and the files
244
+ // allowlist.
245
+ assert.equal(
246
+ pkg.exports["./stryker.base.json"],
247
+ `./${CONFIG_PATH}`,
248
+ "The ./stryker.base.json export must point at the file this test asserts, " +
249
+ "or consumers import a config nothing guards.",
250
+ );
251
+ assert.ok(
252
+ pkg.files.includes("config/"),
253
+ 'The "files" allowlist must publish config/, or the export resolves to a ' +
254
+ "file absent from the tarball.",
255
+ );
256
+ });