Skip to content

test_runner: preserve AssertionError actual constructor name across isolation#61716

Open
inoway46 wants to merge 1 commit intonodejs:mainfrom
inoway46:fix-50397-regression-first
Open

test_runner: preserve AssertionError actual constructor name across isolation#61716
inoway46 wants to merge 1 commit intonodejs:mainfrom
inoway46:fix-50397-regression-first

Conversation

@inoway46
Copy link

@inoway46 inoway46 commented Feb 7, 2026

Summary

This PR fixes the regression described in #50397 where test runner output can lose the original constructor name for AssertionError values (for example, ExtendedArray being shown as plain Array in actual output).

What changed

  1. Added a regression test first

    • test/parallel/test-runner-issue-50397.js
    • test/fixtures/test-runner/issue-50397/prototype-mismatch.js
    • Covers both --test-isolation=none and --test-isolation=process.
  2. Implemented a test-runner-scoped fix

    • Added lib/internal/test_runner/assertion_error_prototype.js to collect/apply metadata for AssertionError actual/expected constructor names.
    • Wired metadata collection in lib/internal/test_runner/reporter/v8-serializer.js.
    • Wired metadata application and cleanup in lib/internal/test_runner/runner.js.
  3. Added focused sequential coverage for the helper logic

    • test/sequential/test-runner-assertion-error-prototype.js
    • Verifies metadata collection, restoration after serialize/deserialize, and idempotent application.

Why this approach

The fix is intentionally scoped to internal/test_runner/* so that shared internal/error_serdes behavior is unchanged for other subsystems.

Tests

  • python3 tools/test.py test/parallel/test-runner-issue-50397.js
  • python3 tools/test.py test/sequential/test-runner-assertion-error-prototype.js
  • python3 tools/test.py test/sequential/test-error-serdes.js

Fixes #50397

@nodejs-github-bot
Copy link
Collaborator

Review requested:

  • @nodejs/test_runner

@nodejs-github-bot nodejs-github-bot added needs-ci PRs that need a full CI run. test_runner Issues and PRs related to the test runner subsystem. labels Feb 7, 2026
@inoway46 inoway46 force-pushed the fix-50397-regression-first branch 2 times, most recently from d7ee644 to 02d6049 Compare February 7, 2026 11:46
@inoway46 inoway46 marked this pull request as ready for review February 7, 2026 11:53
@inoway46 inoway46 force-pushed the fix-50397-regression-first branch 2 times, most recently from e092462 to 0fee869 Compare February 7, 2026 15:46
@MoLow
Copy link
Member

MoLow commented Feb 8, 2026

@BridgeAR PTAL

@codecov
Copy link

codecov bot commented Feb 8, 2026

Codecov Report

❌ Patch coverage is 81.48148% with 40 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.74%. Comparing base (f6464c5) to head (0fee869).
⚠️ Report is 59 commits behind head on main.

Files with missing lines Patch % Lines
.../internal/test_runner/assertion_error_prototype.js 79.78% 33 Missing and 5 partials ⚠️
lib/internal/test_runner/runner.js 84.61% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #61716      +/-   ##
==========================================
- Coverage   89.76%   89.74%   -0.03%     
==========================================
  Files         673      676       +3     
  Lines      203944   204718     +774     
  Branches    39191    39352     +161     
==========================================
+ Hits       183080   183720     +640     
- Misses      13194    13272      +78     
- Partials     7670     7726      +56     
Files with missing lines Coverage Δ
lib/internal/test_runner/reporter/v8-serializer.js 100.00% <100.00%> (ø)
lib/internal/test_runner/runner.js 92.91% <84.61%> (-0.21%) ⬇️
.../internal/test_runner/assertion_error_prototype.js 79.78% <79.78%> (ø)

... and 59 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@inoway46

This comment was marked as outdated.

@inoway46 inoway46 force-pushed the fix-50397-regression-first branch from 0fee869 to 0505a19 Compare February 9, 2026 02:16
@inoway46
Copy link
Author

inoway46 commented Feb 9, 2026

Sharing this investigation in case it helps review context.

I did a deeper investigation to clarify why output differs across the three cases described in the original issue comment:
#50397 (comment)

I used a one-off custom reporter (not committed) to inspect the parent-side test:fail payload, and I ran this from a fresh main build.

Commands used

# Case 1 (node --test + node:test) with payload inspection
./out/Release/node --test --test-isolation=process --test-reporter=./tmp/debug-reporter.mjs ./tmp/repro-50397.mjs
./out/Release/node --test --test-isolation=none --test-reporter=./tmp/debug-reporter.mjs ./tmp/repro-50397.mjs

# Case 2 (node --test without node:test) with payload inspection
./out/Release/node --test --test-isolation=process --test-reporter=./tmp/debug-reporter.mjs ./tmp/repro-50397-no-node-test.mjs

User-facing output commands (by case)

# Case 2-A: node --test (without node:test), process isolation
./out/Release/node --test --test-isolation=process ./tmp/repro-50397-no-node-test.mjs

# Case 2-B: plain node (without node:test), non-test-runner reference
./out/Release/node ./tmp/repro-50397-no-node-test.mjs

# Case 3: plain node + node:test (single-process node:test path)
./out/Release/node ./tmp/repro-50397-with-node-test.mjs

Key observed payload values

  • Case 1 (node --test + node:test, --test-isolation=process):

    • errorCode: "ERR_TEST_FAILURE"
    • causeCode: "ERR_ASSERTION"
    • causeActualCtor: "Array" (ExtendedArray identity lost across boundary)
    • causeExpectedCtor: "Array"
    • causeDiffValue: "simple"
  • Case 1 (node --test + node:test, --test-isolation=none):

    • causeActualCtor: "ExtendedArray"
    • causeExpectedCtor: "Array"
  • Case 2 (node --test without node:test, --test-isolation=process):

    • errorCode: "ERR_TEST_FAILURE"
    • causeType: "[object String]"
    • causeCode: null
    • i.e. parent does not receive an AssertionError object as cause in this path

Observed behavior by case

Case 1 (node --test + node:test)

  • --test-isolation=process:

    • structured test:fail event path
    • details.error is ERR_TEST_FAILURE
    • details.error.cause is AssertionError, but cause.actual.constructor.name is Array (identity lost across boundary)
  • --test-isolation=none:

    • same structured event path, but cause.actual.constructor.name remains ExtendedArray
  • this is the path affected by child->parent serialization/deserialization (and where this PR applies constructor/prototype identity restoration metadata)

Case 2 (node --test without node:test, observed with --test-isolation=process)

  • different path: parent mainly sees test process failure + stderr forwarding
  • assertion rendering comes from child-side stderr text, not parent-side reconstructed assertion object

Case 3 (node + node:test)

  • no --test-isolation boundary here; this is the single-process node:test bootstrap path
  • output shape difference is explained by AssertionError inspect behavior (actual: [ExtendedArray]), not the same boundary issue as Case 1

So the regression is specifically that, under --test-isolation=process, the parent-side AssertionError.cause.actual is deserialized as a plain Array, losing the ExtendedArray identity; this PR transports minimal metadata to restore that identity for reporting.

Conclusion

This PR addresses Case 1 (structured node:test + --test boundary, specifically --test-isolation=process).

Case 2 and Case 3 are different mechanisms and should be considered separate follow-up scopes if needed.

debug reporter and repro snippets used for investigation
// ./tmp/debug-reporter.mjs
export default async function* debugReporter(source) {
  for await (const event of source) {
    if (event.type !== 'test:fail') continue;

    const details = event.data?.details ?? {};
    const error = details.error;
    const cause = error?.cause;

    const summary = {
      eventType: event.type,
      testName: event.data?.name ?? null,
      detailsKeys: Object.keys(details).sort(),

      errorCode: error?.code ?? null,
      errorKeys: error ? Object.keys(error).sort() : [],

      causeType: cause ? Object.prototype.toString.call(cause) : null,
      causeCode: cause?.code ?? null,
      causeKeys: cause && typeof cause === 'object' ? Object.keys(cause).sort() : [],
      causeHasActual: !!cause && Object.prototype.hasOwnProperty.call(cause, 'actual'),
      causeHasExpected: !!cause && Object.prototype.hasOwnProperty.call(cause, 'expected'),
      causeActualCtor: cause?.actual?.constructor?.name ?? null,
      causeExpectedCtor: cause?.expected?.constructor?.name ?? null,
      causeDiffType: typeof cause?.diff,
      causeDiffValue: typeof cause?.diff === 'string' ? cause.diff : null,
    };

    console.log('@@DEBUG_TEST_FAIL_EVENT@@' + JSON.stringify(summary));
  }
}
// ./tmp/repro-50397.mjs (case 1)
import assert from 'node:assert/strict';
import test from 'node:test';

class ExtendedArray extends Array {}

test('deepStrictEqual should print prototype diff', () => {
  assert.deepStrictEqual(new ExtendedArray('hello'), ['hello']);
});
// ./tmp/repro-50397-no-node-test.mjs (case 2)
import assert from 'node:assert/strict';

class ExtendedArray extends Array {}

assert.deepStrictEqual(new ExtendedArray('hello'), ['hello']);
// ./tmp/repro-50397-with-node-test.mjs (case 3)
import test from 'node:test';
import assert from 'node:assert/strict';

class ExtendedArray extends Array {}

test('deepStrictEqual should print prototype diff', () => {
  assert.deepStrictEqual(new ExtendedArray('hello'), ['hello']);
});

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-ci PRs that need a full CI run. test_runner Issues and PRs related to the test runner subsystem.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

deepStrictEqual diff is unhelpful when prototype mismatches

3 participants