From f63ddd719a11be1b1b49ce01d3382e1261b465fd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 23 Aug 2026 23:29:37 +0000 Subject: [PATCH 1/4] test(cli): add fail-closed specs against today's exit helpers Harness for #3083: $cliTestResultFailed currently mirrors runTests' totalFail+totalError check, and $browserTestResultFailed mirrors browserTest's always-success return. Specs expect directoryRejected, bundlesDiscovered=0, unloadable, and browser Fail/Error to fail-closed. Signed-off-by: Cursor Agent Co-authored-by: Peter Amiri --- cli/lucli/Module.cfc | 27 ++++ .../specs/commands/TestExitFailClosedSpec.cfc | 141 ++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 cli/lucli/tests/specs/commands/TestExitFailClosedSpec.cfc diff --git a/cli/lucli/Module.cfc b/cli/lucli/Module.cfc index 137bab4e1..2e00ea1e4 100644 --- a/cli/lucli/Module.cfc +++ b/cli/lucli/Module.cfc @@ -5740,6 +5740,33 @@ component extends="modules.BaseModule" { // ── Test Execution ─────────────────────────────── + /** + * True when a `wheels test` JSON result should map to a non-zero CLI + * exit via Wheels.TestsFailed. Public ONLY so the CLI specs can reach + * it (cli/CLAUDE.md "public for specs" carve-out); hidden from MCP via + * the structural $-prefix sweep. + * + * Body currently mirrors runTests' historic check (totalFail + + * totalError only). directoryRejected, bundlesDiscovered=0, and + * unloadable specs still return false — the #3083 honesty gap vs + * tools/test-local.sh / tools/ci/run-tests.sh. + */ + public boolean function $cliTestResultFailed(required struct result, numeric specsFailedToLoad = 0) { + return ((arguments.result.totalFail ?: 0) + (arguments.result.totalError ?: 0)) > 0; + } + + /** + * True when a `wheels browser test` JSON result should map to a + * non-zero CLI exit. Public for specs; hidden from MCP via the + * structural sweep. + * + * Body currently mirrors browserTest's historic `return ""` (always + * success) so Fail/Error specs go red until the helper is wired. + */ + public boolean function $browserTestResultFailed(required struct data) { + return false; + } + private string function runTests( string filter = "", string reporter = "simple", diff --git a/cli/lucli/tests/specs/commands/TestExitFailClosedSpec.cfc b/cli/lucli/tests/specs/commands/TestExitFailClosedSpec.cfc new file mode 100644 index 000000000..0be6d6306 --- /dev/null +++ b/cli/lucli/tests/specs/commands/TestExitFailClosedSpec.cfc @@ -0,0 +1,141 @@ +/** + * `wheels test` / `wheels browser test` must fail-closed (issue #3083). + * + * tools/test-local.sh and tools/ci/run-tests.sh already treat + * directoryRejected and bundlesDiscovered=0 as exit 1. The CLI's + * runTests() historically only OR'd totalFail + totalError, so a + * rejected scope, a vacuous 0-bundle run, or unloadable *Spec.cfc + * files (displayTestResults warns but does not fail) exited 0. + * browserTest() printed Fail/Error then always returned "" (LuCLI + * success). + * + * These specs lock the Module helpers that own the exit decision. + * No live server — MigrationExitCodeSpec / TestCommandSpec pattern. + */ +component extends="wheels.wheelstest.system.BaseSpec" { + + function beforeAll() { + variables.testHelper = new cli.lucli.tests.TestHelper(); + variables.tempRoot = testHelper.scaffoldTempProject(expandPath("/")); + directoryCreate(tempRoot & "/vendor/wheels", true, true); + fileWrite(tempRoot & "/lucee.json", "{}"); + variables.mod = new cli.lucli.Module(cwd = variables.tempRoot); + } + + function afterAll() { + testHelper.cleanupTempProject(variables.tempRoot); + } + + function run() { + + describe("$cliTestResultFailed — wheels test fail-closed (##3083)", () => { + + it("flags directoryRejected: true even when totalFail/Error are 0", () => { + expect( + mod.$cliTestResultFailed({ + directoryRejected: true, + totalFail: 0, + totalError: 0, + bundlesDiscovered: 314 + }) + ).toBeTrue(); + }); + + it("flags bundlesDiscovered: 0 even when totalFail/Error are 0", () => { + expect( + mod.$cliTestResultFailed({ + directoryRejected: false, + totalFail: 0, + totalError: 0, + bundlesDiscovered: 0 + }) + ).toBeTrue(); + }); + + it("flags unloadable specs (specsFailedToLoad > 0) even when totalFail/Error are 0", () => { + expect( + mod.$cliTestResultFailed( + result = { + directoryRejected: false, + totalFail: 0, + totalError: 0, + bundlesDiscovered: 1 + }, + specsFailedToLoad = 2 + ) + ).toBeTrue(); + }); + + it("flags totalFail > 0 (regression lock on the existing Fail path)", () => { + expect( + mod.$cliTestResultFailed({ + totalFail: 1, + totalError: 0, + bundlesDiscovered: 1 + }) + ).toBeTrue(); + }); + + it("flags totalError > 0 (regression lock on the existing Error path)", () => { + expect( + mod.$cliTestResultFailed({ + totalFail: 0, + totalError: 3, + bundlesDiscovered: 1 + }) + ).toBeTrue(); + }); + + it("does not flag a clean pass (no reject, bundles present, nothing unloadable, no Fail/Error)", () => { + expect( + mod.$cliTestResultFailed( + result = { + directoryRejected: false, + totalFail: 0, + totalError: 0, + bundlesDiscovered: 4 + }, + specsFailedToLoad = 0 + ) + ).toBeFalse(); + }); + + }); + + describe("$browserTestResultFailed — wheels browser test fail-closed", () => { + + it("flags totalFail > 0", () => { + expect( + mod.$browserTestResultFailed({ + totalPass: 0, + totalFail: 1, + totalError: 0 + }) + ).toBeTrue(); + }); + + it("flags totalError > 0", () => { + expect( + mod.$browserTestResultFailed({ + totalPass: 2, + totalFail: 0, + totalError: 1 + }) + ).toBeTrue(); + }); + + it("does not flag a clean pass", () => { + expect( + mod.$browserTestResultFailed({ + totalPass: 3, + totalFail: 0, + totalError: 0 + }) + ).toBeFalse(); + }); + + }); + + } + +} From cecba650ebf549b70cb093e1c45c3214be04c517 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 23 Aug 2026 23:31:34 +0000 Subject: [PATCH 2/4] fix(cli): fail-closed on directoryRejected, empty, unloadable, browser Fail Expand $cliTestResultFailed to match test-local.sh / run-tests.sh honesty flags, pass the unloadable-spec probe into the exit path, and throw Wheels.TestsFailed from browserTest when Fail/Error > 0. Signed-off-by: Cursor Agent Co-authored-by: Peter Amiri --- .../3083-cli-test-exit-fail-closed.fixed.md | 1 + cli/lucli/Module.cfc | 87 +++++++++++++++---- 2 files changed, 71 insertions(+), 17 deletions(-) create mode 100644 changelog.d/3083-cli-test-exit-fail-closed.fixed.md diff --git a/changelog.d/3083-cli-test-exit-fail-closed.fixed.md b/changelog.d/3083-cli-test-exit-fail-closed.fixed.md new file mode 100644 index 000000000..fccc46a88 --- /dev/null +++ b/changelog.d/3083-cli-test-exit-fail-closed.fixed.md @@ -0,0 +1 @@ +- `wheels test` now exits non-zero when the runner reports `directoryRejected`, `bundlesDiscovered=0`, or unloadable specs (same honesty as `tools/test-local.sh` / `tools/ci/run-tests.sh`); `wheels browser test` exits non-zero on Fail/Error instead of always returning success (#3083) diff --git a/cli/lucli/Module.cfc b/cli/lucli/Module.cfc index 2e00ea1e4..50b815d67 100644 --- a/cli/lucli/Module.cfc +++ b/cli/lucli/Module.cfc @@ -5742,29 +5742,60 @@ component extends="modules.BaseModule" { /** * True when a `wheels test` JSON result should map to a non-zero CLI - * exit via Wheels.TestsFailed. Public ONLY so the CLI specs can reach - * it (cli/CLAUDE.md "public for specs" carve-out); hidden from MCP via - * the structural $-prefix sweep. + * exit via Wheels.TestsFailed. Aligns with tools/test-local.sh and + * tools/ci/run-tests.sh: Fail/Error, a rejected directory= scope + * (#3083), a vacuous 0-bundle discovery (#3083), or unloadable + * *Spec.cfc files that displayTestResults already WARNs about. * - * Body currently mirrors runTests' historic check (totalFail + - * totalError only). directoryRejected, bundlesDiscovered=0, and - * unloadable specs still return false — the #3083 honesty gap vs - * tools/test-local.sh / tools/ci/run-tests.sh. + * Public ONLY so the CLI specs can reach it (cli/CLAUDE.md "public + * for specs" carve-out); hidden from MCP via the structural $-prefix + * sweep. bundlesDiscovered is read with structKeyExists — Lucee's + * Elvis treats 0 as empty, which would hide the exact 0-bundle case. */ public boolean function $cliTestResultFailed(required struct result, numeric specsFailedToLoad = 0) { + if (structKeyExists(arguments.result, "directoryRejected") && arguments.result.directoryRejected) { + return true; + } + if (structKeyExists(arguments.result, "bundlesDiscovered") && arguments.result.bundlesDiscovered == 0) { + return true; + } + if (arguments.specsFailedToLoad > 0) { + return true; + } return ((arguments.result.totalFail ?: 0) + (arguments.result.totalError ?: 0)) > 0; } /** * True when a `wheels browser test` JSON result should map to a - * non-zero CLI exit. Public for specs; hidden from MCP via the - * structural sweep. - * - * Body currently mirrors browserTest's historic `return ""` (always - * success) so Fail/Error specs go red until the helper is wired. + * non-zero CLI exit via Wheels.TestsFailed. Public for specs; + * hidden from MCP via the structural sweep. */ public boolean function $browserTestResultFailed(required struct data) { - return false; + return ((arguments.data.totalFail ?: 0) + (arguments.data.totalError ?: 0)) > 0; + } + + /** + * Disk-vs-loaded delta used by displayTestResults' unloadable WARN + * and by runTests' exit decision, so a skipped *Spec.cfc cannot + * warn-and-exit-0. Best-effort: probe failures return 0. + */ + private numeric function $countSpecsFailedToLoad(required any result, string testDirectory = "") { + if (!len(arguments.testDirectory) || !isStruct(arguments.result)) { + return 0; + } + try { + var runner = new services.TestRunner(projectRoot = variables.projectRoot); + var diskCount = runner.countSpecsOnDisk(arguments.testDirectory); + var loadedCount = (structKeyExists(arguments.result, "bundleStats") && isArray(arguments.result.bundleStats)) + ? arrayLen(arguments.result.bundleStats) + : 0; + if (diskCount > loadedCount) { + return diskCount - loadedCount; + } + } catch (any probeErr) { + verbose("Failed-to-load probe failed: #probeErr.message#"); + } + return 0; } private string function runTests( @@ -5875,7 +5906,15 @@ component extends="modules.BaseModule" { // Record failure so the command can exit non-zero AFTER the output // is flushed. Throwing here would be swallowed by the catch below. // testing.mdx documents a non-zero exit on failure. CLI audit H6. - testsFailed = ((result.totalFail ?: 0) + (result.totalError ?: 0)) > 0; + // Fail-closed on #3083 honesty flags and unloadable specs, not + // just totalFail/totalError — same gate as test-local.sh. + testsFailed = $cliTestResultFailed( + result = result, + specsFailedToLoad = $countSpecsFailedToLoad( + result = result, + testDirectory = resolvedDir + ) + ); } else { // Could be an HTML error page. Either way no result document was // produced — the run crashed, which must exit non-zero (#2963). @@ -6065,9 +6104,9 @@ component extends="modules.BaseModule" { // passed an empty run." We probe the disk and warn if the loaded // bundle count is lower than the on-disk *Spec.cfc count. See // finding #2 in the 2026-04-29 fresh-VM triage. - var specsFailedToLoad = 0; + var specsFailedToLoad = $countSpecsFailedToLoad(result, arguments.testDirectory); var unloadedSpecPaths = []; - if (len(arguments.testDirectory)) { + if (specsFailedToLoad > 0 && len(arguments.testDirectory)) { try { var runner = new services.TestRunner(projectRoot = variables.projectRoot); var diskCount = runner.countSpecsOnDisk(arguments.testDirectory); @@ -6075,7 +6114,6 @@ component extends="modules.BaseModule" { ? arrayLen(result.bundleStats) : 0; if (diskCount > loadedCount) { - specsFailedToLoad = diskCount - loadedCount; var diskSpecs = runner.listSpecsOnDisk(arguments.testDirectory); var loadedNames = {}; if (loadedCount > 0) { @@ -8119,9 +8157,16 @@ component extends="modules.BaseModule" { if (format == "json") { out(httpResult); + if (isJSON(httpResult)) { + var jsonData = deserializeJSON(httpResult); + if ($browserTestResultFailed(jsonData)) { + throw(type = "Wheels.TestsFailed", message = "Tests failed — see the report above."); + } + } return ""; } + var testsFailed = false; try { var data = deserializeJSON(httpResult); var totalPass = data.totalPass ?: 0; @@ -8208,6 +8253,10 @@ component extends="modules.BaseModule" { out("the BrowserTest spec may need explicit try/catch around .click() /", "yellow"); out(".fill() to surface Playwright exceptions into failMessage.", "yellow"); } + // Record after the report flushes. Throwing inside this try + // would be swallowed by the parse-error catch as + // "Failed to parse test results". + testsFailed = $browserTestResultFailed(data); } catch (any e) { out("Failed to parse test results: #e.message#", "red"); if (verboseOutput) { @@ -8215,6 +8264,10 @@ component extends="modules.BaseModule" { } } + if (testsFailed) { + throw(type = "Wheels.TestsFailed", message = "Tests failed — see the report above."); + } + return ""; } From ae6d7abe0c6c273d5d1e0b9e091b3da2a0018e22 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 23 Aug 2026 23:39:30 +0000 Subject: [PATCH 3/4] test(cli): add throw-seam specs against no-op exit helpers $throwIfCliTestsFailed and $throwIfBrowserTestsFailed are no-ops so the Wheels.TestsFailed locks for directoryRejected, empty, unloadable, and browser Fail/Error go red. Helper boolean specs stay green. Signed-off-by: Cursor Agent Co-authored-by: Peter Amiri --- cli/lucli/Module.cfc | 16 +++ .../specs/commands/TestExitFailClosedSpec.cfc | 108 ++++++++++++++++++ 2 files changed, 124 insertions(+) diff --git a/cli/lucli/Module.cfc b/cli/lucli/Module.cfc index 50b815d67..63b508276 100644 --- a/cli/lucli/Module.cfc +++ b/cli/lucli/Module.cfc @@ -5774,6 +5774,22 @@ component extends="modules.BaseModule" { return ((arguments.data.totalFail ?: 0) + (arguments.data.totalError ?: 0)) > 0; } + /** + * Process-exit seam for `wheels test`. Public for specs; hidden from MCP + * via the structural $-prefix sweep. Body is a no-op until the throw + * is wired — $cliTestResultFailed already owns the boolean contract. + */ + public void function $throwIfCliTestsFailed(required struct result, numeric specsFailedToLoad = 0) { + } + + /** + * Process-exit seam for `wheels browser test`. Public for specs; + * hidden from MCP via the structural sweep. Body is a no-op until + * the throw is wired. + */ + public void function $throwIfBrowserTestsFailed(required struct data) { + } + /** * Disk-vs-loaded delta used by displayTestResults' unloadable WARN * and by runTests' exit decision, so a skipped *Spec.cfc cannot diff --git a/cli/lucli/tests/specs/commands/TestExitFailClosedSpec.cfc b/cli/lucli/tests/specs/commands/TestExitFailClosedSpec.cfc index 0be6d6306..860fa38d1 100644 --- a/cli/lucli/tests/specs/commands/TestExitFailClosedSpec.cfc +++ b/cli/lucli/tests/specs/commands/TestExitFailClosedSpec.cfc @@ -136,6 +136,114 @@ component extends="wheels.wheelstest.system.BaseSpec" { }); + describe("$throwIfCliTestsFailed — wheels test process-exit seam (##3083)", () => { + + it("throws Wheels.TestsFailed when directoryRejected: true and totalFail/Error are 0", () => { + expect(() => + mod.$throwIfCliTestsFailed({ + directoryRejected: true, + totalFail: 0, + totalError: 0, + bundlesDiscovered: 314 + }) + ).toThrow(type = "Wheels.TestsFailed"); + }); + + it("throws Wheels.TestsFailed when bundlesDiscovered: 0 and totalFail/Error are 0", () => { + expect(() => + mod.$throwIfCliTestsFailed({ + directoryRejected: false, + totalFail: 0, + totalError: 0, + bundlesDiscovered: 0 + }) + ).toThrow(type = "Wheels.TestsFailed"); + }); + + it("throws Wheels.TestsFailed when specsFailedToLoad > 0 and totalFail/Error are 0", () => { + expect(() => + mod.$throwIfCliTestsFailed( + result = { + directoryRejected: false, + totalFail: 0, + totalError: 0, + bundlesDiscovered: 1 + }, + specsFailedToLoad = 2 + ) + ).toThrow(type = "Wheels.TestsFailed"); + }); + + it("throws Wheels.TestsFailed when totalFail > 0", () => { + expect(() => + mod.$throwIfCliTestsFailed({ + totalFail: 1, + totalError: 0, + bundlesDiscovered: 1 + }) + ).toThrow(type = "Wheels.TestsFailed"); + }); + + it("throws Wheels.TestsFailed when totalError > 0", () => { + expect(() => + mod.$throwIfCliTestsFailed({ + totalFail: 0, + totalError: 3, + bundlesDiscovered: 1 + }) + ).toThrow(type = "Wheels.TestsFailed"); + }); + + it("does not throw on a clean pass", () => { + expect(() => + mod.$throwIfCliTestsFailed( + result = { + directoryRejected: false, + totalFail: 0, + totalError: 0, + bundlesDiscovered: 4 + }, + specsFailedToLoad = 0 + ) + ).notToThrow(); + }); + + }); + + describe("$throwIfBrowserTestsFailed — wheels browser test process-exit seam", () => { + + it("throws Wheels.TestsFailed when totalFail > 0", () => { + expect(() => + mod.$throwIfBrowserTestsFailed({ + totalPass: 0, + totalFail: 1, + totalError: 0 + }) + ).toThrow(type = "Wheels.TestsFailed"); + }); + + it("throws Wheels.TestsFailed when totalError > 0", () => { + expect(() => + mod.$throwIfBrowserTestsFailed({ + totalPass: 2, + totalFail: 0, + totalError: 1 + }) + ).toThrow(type = "Wheels.TestsFailed"); + }); + + it("does not throw on a clean pass", () => { + expect(() => + mod.$throwIfBrowserTestsFailed({ + totalPass: 3, + totalFail: 0, + totalError: 0 + }) + ).notToThrow(); + }); + + }); + } } From 8320573b9a0cdb503dc07f5522b6c79c3c73a578 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 23 Aug 2026 23:40:22 +0000 Subject: [PATCH 4/4] fix(cli): throw Wheels.TestsFailed only from $throwIf seams Compose $throwIfCliTestsFailed / $throwIfBrowserTestsFailed from the existing boolean helpers and wire runTests / browserTest to call them after the report flushes. No parallel bare throw left beside the seams. Signed-off-by: Cursor Agent Co-authored-by: Peter Amiri --- cli/lucli/Module.cfc | 75 ++++++++++++++++++++++++++------------------ 1 file changed, 44 insertions(+), 31 deletions(-) diff --git a/cli/lucli/Module.cfc b/cli/lucli/Module.cfc index 63b508276..07d837672 100644 --- a/cli/lucli/Module.cfc +++ b/cli/lucli/Module.cfc @@ -5775,19 +5775,32 @@ component extends="modules.BaseModule" { } /** - * Process-exit seam for `wheels test`. Public for specs; hidden from MCP - * via the structural $-prefix sweep. Body is a no-op until the throw - * is wired — $cliTestResultFailed already owns the boolean contract. + * Process-exit seam for `wheels test`. The only Wheels.TestsFailed + * throw site on that path — runTests calls this after the report + * flushes. Composes $cliTestResultFailed. Public for specs; hidden + * from MCP via the structural $-prefix sweep. */ public void function $throwIfCliTestsFailed(required struct result, numeric specsFailedToLoad = 0) { + if ( + $cliTestResultFailed( + result = arguments.result, + specsFailedToLoad = arguments.specsFailedToLoad + ) + ) { + throw(type = "Wheels.TestsFailed", message = "Tests failed — see the report above."); + } } /** - * Process-exit seam for `wheels browser test`. Public for specs; - * hidden from MCP via the structural sweep. Body is a no-op until - * the throw is wired. + * Process-exit seam for `wheels browser test`. The only + * Wheels.TestsFailed throw site on that path. Composes + * $browserTestResultFailed. Public for specs; hidden from MCP + * via the structural sweep. */ public void function $throwIfBrowserTestsFailed(required struct data) { + if ($browserTestResultFailed(arguments.data)) { + throw(type = "Wheels.TestsFailed", message = "Tests failed — see the report above."); + } } /** @@ -5875,11 +5888,12 @@ component extends="modules.BaseModule" { out("Scope: #filter#", "cyan"); } - var testsFailed = false; // Struct (not a bare local) so the catch-block write persists on // BoxLang — local assignments inside catch are discarded there - // (CLAUDE.md cross-engine invariant 11). - var runState = {crashed = false}; + // (CLAUDE.md cross-engine invariant 11). result/specsFailedToLoad + // live here too so $throwIfCliTestsFailed can run AFTER the try + // (a throw inside would be swallowed as a crashed run). + var runState = {crashed = false, hasResult = false, result = {}, specsFailedToLoad = 0}; try { var testUrl = "http://localhost:#serverPort##testPath#?format=#format#&db=#db#"; @@ -5919,17 +5933,13 @@ component extends="modules.BaseModule" { displayTestResults(result, verboseOutput, resolvedDir, ciMode); } - // Record failure so the command can exit non-zero AFTER the output - // is flushed. Throwing here would be swallowed by the catch below. - // testing.mdx documents a non-zero exit on failure. CLI audit H6. - // Fail-closed on #3083 honesty flags and unloadable specs, not - // just totalFail/totalError — same gate as test-local.sh. - testsFailed = $cliTestResultFailed( + // Stash for the post-try throw seam. Throwing here would be + // swallowed by the catch below as a crashed run. + runState.hasResult = true; + runState.result = result; + runState.specsFailedToLoad = $countSpecsFailedToLoad( result = result, - specsFailedToLoad = $countSpecsFailedToLoad( - result = result, - testDirectory = resolvedDir - ) + testDirectory = resolvedDir ); } else { // Could be an HTML error page. Either way no result document was @@ -5962,11 +5972,15 @@ component extends="modules.BaseModule" { // Exit non-zero when specs failed/errored so CI and shells can detect it. // Previously runTests always returned "" → `wheels test` exited 0 even when // tests failed, silently green-lighting broken builds. CLI audit H6. - if (testsFailed) { - throw(type = "Wheels.TestsFailed", message = "Tests failed — see the report above."); + // Sole Wheels.TestsFailed site for this path — do not throw beside it. + if (runState.hasResult) { + $throwIfCliTestsFailed( + result = runState.result, + specsFailedToLoad = runState.specsFailedToLoad + ); } // A crash during the HTTP/parse phase printed red but exited 0 — the - // throw above only covers FAILING tests, not CRASHED runs (#2963). + // seam above only covers FAILING tests, not CRASHED runs (#2963). if (runState.crashed) { throw(type = "Wheels.TestRunFailed", message = "Test run crashed before producing results — see the output above."); } @@ -8174,15 +8188,12 @@ component extends="modules.BaseModule" { if (format == "json") { out(httpResult); if (isJSON(httpResult)) { - var jsonData = deserializeJSON(httpResult); - if ($browserTestResultFailed(jsonData)) { - throw(type = "Wheels.TestsFailed", message = "Tests failed — see the report above."); - } + $throwIfBrowserTestsFailed(deserializeJSON(httpResult)); } return ""; } - var testsFailed = false; + var parsed = {hasData = false, data = {}}; try { var data = deserializeJSON(httpResult); var totalPass = data.totalPass ?: 0; @@ -8269,10 +8280,11 @@ component extends="modules.BaseModule" { out("the BrowserTest spec may need explicit try/catch around .click() /", "yellow"); out(".fill() to surface Playwright exceptions into failMessage.", "yellow"); } - // Record after the report flushes. Throwing inside this try + // Stash after the report flushes. Throwing inside this try // would be swallowed by the parse-error catch as // "Failed to parse test results". - testsFailed = $browserTestResultFailed(data); + parsed.hasData = true; + parsed.data = data; } catch (any e) { out("Failed to parse test results: #e.message#", "red"); if (verboseOutput) { @@ -8280,8 +8292,9 @@ component extends="modules.BaseModule" { } } - if (testsFailed) { - throw(type = "Wheels.TestsFailed", message = "Tests failed — see the report above."); + // Sole Wheels.TestsFailed site for the text path. + if (parsed.hasData) { + $throwIfBrowserTestsFailed(parsed.data); } return "";