From 59b48c30455895d4427863a0f25123cb9f6583b7 Mon Sep 17 00:00:00 2001 From: Christian Schurr Date: Mon, 9 Nov 2026 14:22:27 +0100 Subject: [PATCH 1/2] test: kill the surviving mutants that matter Add a test for `&foo=true` -> true: no test checked the `'true'` case in defaultTransform, so two mutants of it survived. Skip the 200 000-character timing tests during mutation runs (STRYKER_MUTATOR_WORKER is set). Stryker's instrumented code runs ~4x slower on all cores at once, so they failed without a mutant and killed random mutants, which made the score move between runs. The score is now 95.38% in every run. The 14 surviving mutants are equivalent. Stryker disable comments can't exclude them: a comment disables every mutant of that mutator on the line, which would also turn off 11 mutants the tests kill today. Set thresholds.break to 95.3 instead, so one new surviving mutant fails the run. --- src/__tests__/index.test.ts | 16 +++++++++++++++- stryker.config.mjs | 11 +++++++---- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index b42111d..1b49d48 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -10,6 +10,9 @@ import { } from '../' import {outcomeWithName} from './utils' +// The tests run on Node, but the project has no `@types/node`. +declare const process: {env: Record} + describe('basic functionality', () => { describe('transform value', () => { it('can parse Numbers `+foo -> {foo: Number($value)}`', () => { @@ -28,6 +31,14 @@ describe('basic functionality', () => { foo: false, }) }) + it('can parse true Booleans `&foo: true -> {foo: true}`', () => { + const formData = new FormData() + formData.append('&foo', 'true') + const result = parseFormData(formData) + expect(result).toEqual({ + foo: true, + }) + }) it("can parse checkbox Booleans `&foo: on -> {foo: $value === 'on'}`", () => { const formData = new FormData() formData.append('&foo', 'on') @@ -801,7 +812,10 @@ describe('bracket keys', () => { // Each shape needs the matching `]` of every `[`. Searching for it again // for every `[` takes seconds at this length. - it.each([ + // Skipped during mutation testing: Stryker's instrumented code runs ~4x + // slower on all CPU cores at once, so these tests fail without a mutant and + // "kill" whichever mutant is active. + it.skipIf(process.env['STRYKER_MUTATOR_WORKER'] !== undefined).each([ ['a[b]', 'x'], ['a[', ''], ['a[0]b', ''], diff --git a/stryker.config.mjs b/stryker.config.mjs index d25d811..15a2ed9 100644 --- a/stryker.config.mjs +++ b/stryker.config.mjs @@ -5,10 +5,13 @@ const config = { testRunner: 'vitest', mutate: ['src/index.ts', 'src/path-parts.ts', 'src/transform.ts'], reporters: ['html', 'clear-text', 'progress'], - // The first runs scored 95.0-97.0%. The score moves because the tests that - // parse 200 000-character paths in under 500 ms sometimes fail under - // Stryker's load and so kill a mutant. `break` leaves room for that. - thresholds: {high: 97, low: 95, break: 93}, + // The score is 95.38% (289 of 303 mutants) in every run. The 14 surviving + // mutants are equivalent: they can't change any output. `// Stryker + // disable` comments can't exclude them, because a comment disables every + // mutant of that mutator on the line, including ones the tests kill. + // `break` is just under the score, so one new surviving mutant + // (288 of 303 = 95.05%) fails the run. Raise it when the score goes up. + thresholds: {high: 100, low: 95, break: 95.3}, // Stryker rewrites `extends`/`references` paths in this file with the // TypeScript JS API, which TypeScript 7 doesn't ship. Our tsconfig.json has // neither, so we point Stryker at a file that doesn't exist and it skips From f4d6c6c1d90c0151892330440d2c896f4d0da6d6 Mon Sep 17 00:00:00 2001 From: Christian Schurr Date: Mon, 9 Nov 2026 14:38:42 +0100 Subject: [PATCH 2/2] test: kill three more mutants in the removal of prototype names - An empty object or array that a kept entry set (via transformEntry) must stay when a removed entry goes through it. Kills both mutants of `isRemoved && nextPathValue === undefined`. - With an empty-string property on Object.prototype, `a[]` entries stay: only object path parts are compared with Object.prototype. Kills `pathPart.type === 'object'` -> `true`. The score is 96.37% (292 of 303) in every run; the 11 survivors are equivalent. Raise thresholds.break to 96.3. --- src/__tests__/index.test.ts | 53 +++++++++++++++++++++++++++++++++++++ stryker.config.mjs | 6 ++--- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index 1b49d48..9a3a836 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -1027,6 +1027,36 @@ describe('Object.prototype names', () => { expect(result).toStrictEqual({}) }) + // A removed entry can go through an empty object or array that a kept entry + // set (only possible with such a transform). Undoing the removed entry must + // not delete it. + it.each<[string, unknown, string, unknown]>([ + ['a', {}, 'a.toString', {a: {}}], + ['a', [], 'a[].toString', {a: []}], + ])( + 'keeps %s with the value %j when %s is removed', + (keptPath, keptValue, removedPath, expected) => { + const values = new Map([ + [keptPath, keptValue], + [removedPath, 'x'], + ]) + const result = parseFormData( + [ + [keptPath, 'x'], + [removedPath, 'x'], + ], + { + transformEntry: ([path]) => ({ + path, + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- see above + value: values.get(path) as string, + }), + }, + ) + expect(result).toStrictEqual(expected) + }, + ) + // The same errors as for an unused name, so an error doesn't tell the // client which names exist on Object.prototype. it.each([ @@ -1104,6 +1134,29 @@ describe('Object.prototype names', () => { expect(parseFormData([['a.patched', '1']])).toStrictEqual({}) }) }) + + // Only object path parts are compared with Object.prototype. `a[]` has no + // object key, so its entries stay. + describe('an empty-string property added to Object.prototype', () => { + const proto: object = Object.prototype + afterEach(() => { + Reflect.deleteProperty(proto, '') + }) + + it('keeps the entries of `a[]`', () => { + Object.defineProperty(proto, '', { + value: 'x', + configurable: true, + writable: true, + }) + expect( + parseFormData([ + ['a[]', 'x'], + ['a[]', 'y'], + ]), + ).toStrictEqual({a: ['x', 'y']}) + }) + }) }) class RemixFileSubclass extends File {} diff --git a/stryker.config.mjs b/stryker.config.mjs index 15a2ed9..e6f8da0 100644 --- a/stryker.config.mjs +++ b/stryker.config.mjs @@ -5,13 +5,13 @@ const config = { testRunner: 'vitest', mutate: ['src/index.ts', 'src/path-parts.ts', 'src/transform.ts'], reporters: ['html', 'clear-text', 'progress'], - // The score is 95.38% (289 of 303 mutants) in every run. The 14 surviving + // The score is 96.37% (292 of 303 mutants) in every run. The 11 surviving // mutants are equivalent: they can't change any output. `// Stryker // disable` comments can't exclude them, because a comment disables every // mutant of that mutator on the line, including ones the tests kill. // `break` is just under the score, so one new surviving mutant - // (288 of 303 = 95.05%) fails the run. Raise it when the score goes up. - thresholds: {high: 100, low: 95, break: 95.3}, + // (291 of 303 = 96.04%) fails the run. Raise it when the score goes up. + thresholds: {high: 100, low: 95, break: 96.3}, // Stryker rewrites `extends`/`references` paths in this file with the // TypeScript JS API, which TypeScript 7 doesn't ship. Our tsconfig.json has // neither, so we point Stryker at a file that doesn't exist and it skips