Skip to content

Handle thrown values that are not Errors - #185

Open
DmitrySharabin wants to merge 1 commit into
mainfrom
fix/183-non-error-throws
Open

DmitrySharabin wants to merge 1 commit into
mainfrom
fix/183-non-error-throws

Conversation

@DmitrySharabin

@DmitrySharabin DmitrySharabin commented Sep 11, 2026

Copy link
Copy Markdown
Member

Closes #183.

Throwing a non-Error is legal JS, and real code does it. hTest crashed on it — and took the whole run down, not just the offending test. What the issue reported as one crash is five symptoms of one assumption: that the caught value is an object.

# Site Symptom
1 if ("actual" in e) in throws on a primitive receiver
2 e.source = "beforeAll" (5 hook catches) strict mode: cannot set a property on a primitive
3 pass: !!this.error throws: false passed for a test that threw null
4 !("actual" in this.error) crash when no throws criterion is set
5 ${this.error.stack} crash on throw null; phantom undefined line otherwise
6 ${e.message} in the map()/check() catches reports map() failed. undefined

#3 is the one to worry about: a silent false negative, not a crash.

The change

Normalize where the value enters, rather than teaching five downstream sites to cope with primitives:

export function asError (value) {
	return Object(value) === value ? value : new Error(String(value), { cause: value });
}

plus one e = asError(e); in each catch block. Every existing line in those blocks is untouched: with the value normalized up front, e.source = …, ${e.message}, "actual" in e and ${this.error.stack} all work as written.
Nothing else moves — "actual" in e, !!this.error and ${this.error.stack} stay exactly as they were, because with an object they were never wrong.

The original value survives as cause, so a predicate can still match it:

{ run () { throw "boom"; }, throws: error => error.cause === "boom" }

Verification

126/126. Each new test was confirmed red against the unfixed code, and the set was reduced by mutation testing: two candidates that never failed alone across six mutations (including a "guard the in sites instead of wrapping" alternative implementation) were dropped as subsumed.

Case Before After
throw "boom" | 42 | null | undefined, throws: true crash PASS
throw null, throws: false passed FAIL
throw "boom", no throws crash Got error Error: boom
beforeAll/beforeEach/afterEach throws a string crash SKIP, beforeEach: boom
throws: TypeError with a real Error PASS PASS, stack intact
check() that throws an Error Got error Error: check() failed unchanged
map()/check() that throws a string map() failed. undefined map() failed. mapboom

Diff stats

Substantive lines only (blank- and comment-only changes excluded).

File Added Removed
src/util.js 3 0
src/classes/TestResult.js 19 13
tests/errors.js 58 0
SKILL.md 1 1

Notes

  • Output now reads Got error Error: boom rather than Got error boom, and throws: e => e === "boom" becomes e.cause === "boom". Both are new ground — the old behavior was a crash.
  • asError lives in src/util.js by the file's own precedent: it holds single-consumer helpers (interceptConsole is used only by TestResult.js, regexEscape only by map.js), and the criterion is whether a helper knows anything about its caller. This one doesn't.
  • SKILL.md's throws row now records that a non-Error is wrapped and the original is on .cause, per the project's SKILL.md-review rule for TestResult.js.
  • Out of scope, pre-existing: a frozen Error thrown from a hook still crashes on e.source = …. Unchanged from main, not part of Throwing a non-Error crashes the runner and takes down the whole run #183.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RaXmFiBg3UGUMuGFXjn5Ki

@netlify

netlify Bot commented Sep 11, 2026

Copy link
Copy Markdown

Deploy Preview for h-test ready!

Name Link
🔨 Latest commit e6b02b5
🔍 Latest deploy log https://app.netlify.com/projects/h-test/deploys/6aa404bf4714df0008e3900d
😎 Deploy Preview https://deploy-preview-185--h-test.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@DmitrySharabin
DmitrySharabin force-pushed the fix/183-non-error-throws branch 3 times, most recently from 14d7cfb to cdf50ce Compare September 11, 2026 13:22
Throwing a primitive crashed the runner and took down the whole run: five
sites assumed the caught value was an object. Normalize it once at each
catch instead, keeping the original as cause.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RaXmFiBg3UGUMuGFXjn5Ki
@DmitrySharabin
DmitrySharabin force-pushed the fix/183-non-error-throws branch from cdf50ce to e6b02b5 Compare September 11, 2026 13:40

@LeaVerou LeaVerou left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add a property to the Error object that we are creating (or just make it our own subclass) so that we know to unwrap it instead of falsely reporting that an Error was thrown when it wasn't?

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Throwing a non-Error crashes the runner and takes down the whole run

2 participants