@@ -5,6 +5,7 @@ const tmpdir = require('../common/tmpdir'); |
5 | 5 | const fixtures = require('../common/fixtures'); |
6 | 6 | const assert = require('node:assert'); |
7 | 7 | const { rm, readFile } = require('node:fs/promises'); |
| 8 | +const { relative } = require('node:path'); |
8 | 9 | const { setTimeout } = require('node:timers/promises'); |
9 | 10 | const { test, beforeEach, afterEach, run } = require('node:test'); |
10 | 11 |
|
@@ -155,6 +156,82 @@ test('test should pass on third rerun with `--test`', async () => { |
155 | 156 | assert.deepStrictEqual(await getStateFile(), expectedStateFile); |
156 | 157 | }); |
157 | 158 |
|
| 159 | +test('failing test is not swallowed when siblings share its source location', async () => { |
| 160 | + // Regression coverage for https://github.com/nodejs/node/issues/63424. |
| 161 | + // |
| 162 | + // The fixture runs the 4-sibling group (D, E pass; F fails; G passes) |
| 163 | + // before the 3-sibling group from the issue (A pass, B fails, C pass) so |
| 164 | + // that the passing sibling at global position 2 (E) ends up recorded at |
| 165 | + // base:(1). With the runner-side off-by-one reintroduced, the buggy |
| 166 | + // counter on retry collides every sibling after the first onto base:(1) |
| 167 | + // and matches the failing F (and B) against E's "passed" entry, replacing |
| 168 | + // their assert.fail with a synthetic noop and reporting exit 0. |
| 169 | + // |
| 170 | + // The state-file shape additionally pins down the writer-side bugs: the |
| 171 | + // writer off-by-one or its prior pass-only counting both shift the |
| 172 | + // recorded slots away from the expected base:(N) layout. |
| 173 | + const fixturePath = fixtures.path('test-runner', 'rerun-shared-helper-swallows-failure.mjs'); |
| 174 | + const args = ['--test-rerun-failures', stateFile, fixturePath]; |
| 175 | + |
| 176 | + // getStateFile() normalises backslashes to '/'; match that on Windows. |
| 177 | + const fixtureKey = relative(process.cwd(), fixturePath).replaceAll('\\', '/'); |
| 178 | + const innerLoc = `${fixtureKey}:25:13`; |
| 179 | + const passingInner = { passed_on_attempt: 0, name: 'inner' }; |
| 180 | + const expectedInnerSlots = { |
| 181 | + [innerLoc]: passingInner, // D |
| 182 | + [`${innerLoc}:(1)`]: passingInner, // E - fills the slot a buggy runner aliases F/B onto |
| 183 | + [`${innerLoc}:(3)`]: passingInner, // G - :(2) reserved for F's failure |
| 184 | + [`${innerLoc}:(4)`]: passingInner, // A |
| 185 | + [`${innerLoc}:(6)`]: passingInner, // C - :(5) reserved for B's failure |
| 186 | + }; |
| 187 | + const passingParents = { |
| 188 | + [`${fixtureKey}:37:3`]: 'D passes', |
| 189 | + [`${fixtureKey}:38:3`]: 'E passes', |
| 190 | + [`${fixtureKey}:40:3`]: 'G passes', |
| 191 | + [`${fixtureKey}:48:3`]: 'A passes', |
| 192 | + [`${fixtureKey}:50:3`]: 'C passes', |
| 193 | + }; |
| 194 | + const failingParentSlots = [ |
| 195 | + `${fixtureKey}:39:3`, // F fails |
| 196 | + `${fixtureKey}:49:3`, // B fails |
| 197 | + ]; |
| 198 | + const failingInnerSlots = [`${innerLoc}:(2)`, `${innerLoc}:(5)`]; |
| 199 | + |
| 200 | + function assertAttempt(state, attemptIndex) { |
| 201 | + for (const [key, expected] of Object.entries(expectedInnerSlots)) { |
| 202 | + assert.deepStrictEqual(state[attemptIndex][key], expected, `attempt ${attemptIndex} missing or wrong entry for ${key}`); |
| 203 | + } |
| 204 | + for (const [key, name] of Object.entries(passingParents)) { |
| 205 | + assert.strictEqual(state[attemptIndex][key]?.name, name, `attempt ${attemptIndex} missing parent ${name} at ${key}`); |
| 206 | + assert.strictEqual(state[attemptIndex][key]?.passed_on_attempt, 0); |
| 207 | + } |
| 208 | + for (const key of failingInnerSlots) { |
| 209 | + assert.strictEqual(state[attemptIndex][key], undefined, `attempt ${attemptIndex} should not record failing inner at ${key}`); |
| 210 | + } |
| 211 | + for (const key of failingParentSlots) { |
| 212 | + assert.strictEqual(state[attemptIndex][key], undefined, `attempt ${attemptIndex} should not record failing parent at ${key}`); |
| 213 | + } |
| 214 | + } |
| 215 | + |
| 216 | + // Attempt 0: F and B fail. |
| 217 | + let { code, signal } = await common.spawnPromisified(process.execPath, args); |
| 218 | + assert.strictEqual(code, 1); |
| 219 | + assert.strictEqual(signal, null); |
| 220 | + let state = await getStateFile(); |
| 221 | + assert.strictEqual(state.length, 1); |
| 222 | + assertAttempt(state, 0); |
| 223 | + |
| 224 | + // Attempt 1: F and B must STILL fail. Before the fix this run reported |
| 225 | + // exit 0 because the failing tests were matched to passing siblings' |
| 226 | + // previous-attempt slots and replaced with synthetic noops. |
| 227 | + ({ code, signal } = await common.spawnPromisified(process.execPath, args)); |
| 228 | + assert.strictEqual(code, 1); |
| 229 | + assert.strictEqual(signal, null); |
| 230 | + state = await getStateFile(); |
| 231 | + assert.strictEqual(state.length, 2); |
| 232 | + assertAttempt(state, 1); |
| 233 | +}); |
| 234 | + |
158 | 235 | test('rerun preserves the original duration on the replayed pass', async () => { |
159 | 236 | const durationFixture = fixtures.path('test-runner', 'rerun-duration.js'); |
160 | 237 | const args = ['--test-rerun-failures', stateFile, durationFixture]; |
|