From 1a232fe89abf3aae3fc7099be2876ffccf1ff543 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Wed, 24 Jun 2026 10:16:01 -0700 Subject: [PATCH] fix(test): render the app test-runner HTML report (#3251) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The built-in fallback app test runner (vendor/wheels/tests/app-runner.cfm, used when an app has no tests/runner.cfm of its own) emitted raw JSON for the no-format default and ?format=html, so opening /wheels/app/tests in a browser returned an unreadable JSON blob instead of the TestBox-style HTML report the core runner renders. This is item 1 of #3251 (items 2 and 3 shipped in #3255). - Extract the format-to-output decision into TestFormatResolver so the rule is unit-testable without HTTP (mirrors TestDirectoryResolver / TestDbResolver). The html / no-format branch now falls through to html.cfm with type="App" — a branch html.cfm already supported. json / txt / junit are unchanged, and an unrecognized ?format= value still produces no body, matching prior behavior. - Fix a latent Adobe ColdFusion 500 ("Routines cannot be declared more than once") in the shared report template html.cfm: its recursive processNestedSuites helper was a named function declaration, which leaks into the cached Public.cfc scope on Adobe and collides on the second include. It is now a variables-scoped function expression, the same pattern the core runner uses. This also fixes /wheels/core/tests?format=html on Adobe, which 500'd. - Add AppRunnerTestFormatSpec (resolver unit tests) and HtmlReportFunctionDeclarationGuardSpec (source guard against reintroducing a named function in html.cfm, since CI exercises the runners via format=json and never the html render path). Verified on Lucee 7 and Adobe 2023 (Docker): resolver + full dispatch area green (135 specs); /wheels/app/tests renders the HTML report and json/txt/junit are unchanged; format=html works on repeated requests on both engines. Fixes #3251 Co-Authored-By: Claude Opus 4.8 Signed-off-by: Peter Amiri --- .../3251-app-test-html-report.fixed.md | 2 + .../_assets/dispatch/TestFormatResolver.cfc | 87 ++++++++++++++ vendor/wheels/tests/app-runner.cfm | 83 +++++++------ vendor/wheels/tests/html.cfm | 16 ++- .../dispatch/AppRunnerTestFormatSpec.cfc | 113 ++++++++++++++++++ ...HtmlReportFunctionDeclarationGuardSpec.cfc | 80 +++++++++++++ 6 files changed, 338 insertions(+), 43 deletions(-) create mode 100644 changelog.d/3251-app-test-html-report.fixed.md create mode 100644 vendor/wheels/tests/_assets/dispatch/TestFormatResolver.cfc create mode 100644 vendor/wheels/tests/specs/dispatch/AppRunnerTestFormatSpec.cfc create mode 100644 vendor/wheels/tests/specs/dispatch/HtmlReportFunctionDeclarationGuardSpec.cfc diff --git a/changelog.d/3251-app-test-html-report.fixed.md b/changelog.d/3251-app-test-html-report.fixed.md new file mode 100644 index 0000000000..8670ad0221 --- /dev/null +++ b/changelog.d/3251-app-test-html-report.fixed.md @@ -0,0 +1,2 @@ +- `/wheels/app/tests` now renders the TestBox-style HTML report in a browser for apps that use the built-in fallback test runner, matching `/wheels/core/tests`. The endpoint previously returned raw JSON for the no-format default and `?format=html`; `?format=json`, `?format=txt`, and `?format=junit` are unchanged, and an unrecognized `?format=` value still returns no body as before (#3251). +- Fixed an Adobe ColdFusion `Routines cannot be declared more than once` HTTP 500 in the shared test-report template (`vendor/wheels/tests/html.cfm`) that broke the `format=html` report for both the app and core test runners on repeated requests. The recursive helper is now declared as a variables-scoped function expression, matching the core runner's existing convention (#3251). diff --git a/vendor/wheels/tests/_assets/dispatch/TestFormatResolver.cfc b/vendor/wheels/tests/_assets/dispatch/TestFormatResolver.cfc new file mode 100644 index 0000000000..4bc3f7dfca --- /dev/null +++ b/vendor/wheels/tests/_assets/dispatch/TestFormatResolver.cfc @@ -0,0 +1,87 @@ +/** + * Helper extracted from app-runner.cfm so the output-format rule is + * unit-testable without spinning up an HTTP request. app-runner.cfm reads + * url.format, hands it to this resolver, and uses the returned reporter / + * contentType / rendersHtml / recognized fields to drive the response. + * + * Issue #3251 (item 1): the html / no-format branch historically emitted + * application/json for the app runner — a user opening + * `/wheels/app/tests?format=html` (or hitting the no-format default) in a + * browser got raw JSON instead of the TestBox-style HTML report the core + * runner (`/wheels/core/tests`) renders. resolveFormat() marks that branch + * rendersHtml=true so the app runner falls through to html.cfm with + * type="App" — a branch html.cfm already supports — exactly like the core + * runner falls through with type="Core". + * + * Recognized formats (case-insensitive, trimmed): html | json | txt | junit, + * plus the no-format default (no `format` key) which is treated as html. Any + * other value — an empty string or an arbitrary token like "xml" — resolves + * to recognized=false. The app runner emits nothing for an unrecognized + * format, preserving the historical behavior: html.cfm must NOT be rendered + * for an arbitrary url.format. Its dev-tools navigation + * (vendor/wheels/tests/_navigation.cfm) builds format-toggle links from + * url.format and the framework's response content-negotiation throws a 500 + * on Adobe when the format is unknown. + */ +component { + + variables.REPORTER_PACKAGE = "wheels.wheelstest.system.reports"; + + public struct function resolveFormat(required struct url) { + var htmlChoice = { + format: "html", + reporter: variables.REPORTER_PACKAGE & ".JSONReporter", + contentType: "text/html", + rendersHtml: true, + recognized: true + }; + + // No format key at all is the no-format default → HTML report. + if (!StructKeyExists(arguments.url, "format")) { + return htmlChoice; + } + + var format = LCase(Trim(arguments.url.format)); + + switch (format) { + case "html": + return htmlChoice; + case "json": + return { + format: "json", + reporter: variables.REPORTER_PACKAGE & ".JSONReporter", + contentType: "application/json", + rendersHtml: false, + recognized: true + }; + case "txt": + return { + format: "txt", + reporter: variables.REPORTER_PACKAGE & ".TextReporter", + contentType: "text/plain", + rendersHtml: false, + recognized: true + }; + case "junit": + return { + format: "junit", + reporter: variables.REPORTER_PACKAGE & ".ANTJUnitReporter", + contentType: "text/xml", + rendersHtml: false, + recognized: true + }; + default: + // Empty value or unrecognized token: not a known format. The app + // runner emits nothing, matching the historical behavior and + // avoiding an html.cfm render for an arbitrary url.format. + return { + format: format, + reporter: "", + contentType: "", + rendersHtml: false, + recognized: false + }; + } + } + +} diff --git a/vendor/wheels/tests/app-runner.cfm b/vendor/wheels/tests/app-runner.cfm index 5cba3d6d51..5f01665631 100644 --- a/vendor/wheels/tests/app-runner.cfm +++ b/vendor/wheels/tests/app-runner.cfm @@ -114,48 +114,55 @@ bundlesDiscovered = local.bundlesDiscovered ); - if (!StructKeyExists(url, "format") || url.format == "html") { - result = testBox.run(reporter = "wheels.wheelstest.system.reports.JSONReporter"); - decoded = DeserializeJSON(result); - cfheader(statuscode = (decoded.totalFail > 0 || decoded.totalError > 0) ? 417 : 200); - // For the html case the framework runner falls through to html.cfm; - // for the app-runner we just emit the JSON in this branch too since - // app tests are typically requested over JSON (CLI/CI). Users hitting - // the URL in a browser still get a structured response they can read. - cfcontent(type="application/json"); - writeOutput(local.dirResolver.injectScopeMetadata( - resultJson = result, - scope = local.testScope, - bundlesDiscovered = local.bundlesDiscovered, - warnings = local.scopeWarnings - )); - } else if (url.format == "json") { - result = testBox.run(reporter = "wheels.wheelstest.system.reports.JSONReporter"); - decoded = DeserializeJSON(result); - if (decoded.totalFail > 0 || decoded.totalError > 0) { - if (!StructKeyExists(url, "cli") || !url.cli) { - cfheader(statuscode = 417); + // Resolve the output format (reporter + content type + whether to + // render an HTML report) through TestFormatResolver so the rule is + // unit-testable without an HTTP request (see AppRunnerTestFormatSpec, + // issue #3251). An unrecognized format resolves to recognized=false: + // the runner emits nothing, preserving the historical behavior. + local.fmtResolver = new wheels.tests._assets.dispatch.TestFormatResolver(); + local.output = local.fmtResolver.resolveFormat(url); + + if (local.output.recognized) { + result = testBox.run(reporter = local.output.reporter); + + if (local.output.rendersHtml) { + // Render the TestBox-style HTML report for the html / no-format + // default, mirroring the core runner (vendor/wheels/tests/runner.cfm). + // html.cfm has a type="App" branch (package=tests.specs, + // route=testbox) built for exactly this. Previously this branch + // emitted raw JSON, so a user opening /wheels/app/tests?format=html + // in a browser got JSON instead of the report (issue #3251 item 1). + decoded = DeserializeJSON(result); + cfheader(statuscode = (decoded.totalFail > 0 || decoded.totalError > 0) ? 417 : 200); + type = "App"; + include "html.cfm"; + } else if (local.output.format == "json") { + decoded = DeserializeJSON(result); + if (decoded.totalFail > 0 || decoded.totalError > 0) { + if (!StructKeyExists(url, "cli") || !url.cli) { + cfheader(statuscode = 417); + } + } else { + cfheader(statuscode = 200); } + cfcontent(type = local.output.contentType); + cfheader(name="Access-Control-Allow-Origin", value="*"); + writeOutput(local.dirResolver.injectScopeMetadata( + resultJson = result, + scope = local.testScope, + bundlesDiscovered = local.bundlesDiscovered, + warnings = local.scopeWarnings + )); } else { - cfheader(statuscode = 200); + // txt / junit: emit the reporter output verbatim under the + // resolved content type. + cfcontent(type = local.output.contentType); + writeOutput(result); } - cfcontent(type="application/json"); - cfheader(name="Access-Control-Allow-Origin", value="*"); - writeOutput(local.dirResolver.injectScopeMetadata( - resultJson = result, - scope = local.testScope, - bundlesDiscovered = local.bundlesDiscovered, - warnings = local.scopeWarnings - )); - } else if (url.format == "txt") { - result = testBox.run(reporter = "wheels.wheelstest.system.reports.TextReporter"); - cfcontent(type = "text/plain"); - writeOutput(result); - } else if (url.format == "junit") { - result = testBox.run(reporter = "wheels.wheelstest.system.reports.ANTJUnitReporter"); - cfcontent(type = "text/xml"); - writeOutput(result); } + // Unrecognized format (empty value / unknown token): no output, and + // testBox is not run — html.cfm must not be rendered for an arbitrary + // url.format (it 500s on Adobe). Mirrors the pre-fix fall-through. } finally { // Restore the original datasource (via applyDataSource() so test-run cached model classes are invalidated). if (local.swappedDataSource) { diff --git a/vendor/wheels/tests/html.cfm b/vendor/wheels/tests/html.cfm index 9a89842e50..c38c2963f4 100644 --- a/vendor/wheels/tests/html.cfm +++ b/vendor/wheels/tests/html.cfm @@ -34,12 +34,18 @@ testResults.ok = (testResults.numFailures + testResults.numErrors) == 0; - // Recursive function to process nested suites - function processNestedSuites(suites, bundleName) { + // Recursive function to process nested suites. Declared as a variables-scoped + // function expression (not a named `function` declaration) so repeated + // includes of this template into the cached Public.cfc do not throw Adobe's + // "Routines cannot be declared more than once" — the same reason the core + // runner declares its helpers as closures (vendor/wheels/tests/runner.cfm). + // A named declaration in an included .cfm leaks into the component scope on + // Adobe and collides on the second request. See issue #3251. + variables.processNestedSuites = function(suites, bundleName) { for (suite in suites) { // Process nested suites first (deeper level) if (structKeyExists(suite, "suiteStats") && arrayLen(suite.suiteStats) > 0) { - processNestedSuites(suite.suiteStats, bundleName); + variables.processNestedSuites(suite.suiteStats, bundleName); } // Process individual specs in this suite @@ -108,10 +114,10 @@ arrayAppend(testResults.results, thisResult); } } - } + }; for (bundle in DeJsonResult.bundleStats) { - processNestedSuites(bundle.suiteStats, bundle.name); + variables.processNestedSuites(bundle.suiteStats, bundle.name); } failures = []; diff --git a/vendor/wheels/tests/specs/dispatch/AppRunnerTestFormatSpec.cfc b/vendor/wheels/tests/specs/dispatch/AppRunnerTestFormatSpec.cfc new file mode 100644 index 0000000000..78faa5e563 --- /dev/null +++ b/vendor/wheels/tests/specs/dispatch/AppRunnerTestFormatSpec.cfc @@ -0,0 +1,113 @@ +component extends="wheels.WheelsTest" { + + function run() { + + describe("app-runner output format resolution (issue 3251)", () => { + + // Issue #3251 (item 1): `/wheels/app/tests?format=html` — which is + // also the no-format default — historically emitted application/json. + // A user opening that URL in a browser reasonably expects the + // TestBox-style HTML report the core runner (`/wheels/core/tests`) + // renders. resolveFormat() centralizes the format-to-output decision + // so the html / no-format branch can be marked rendersHtml=true; the + // app runner then falls through to html.cfm (type="App", a branch + // html.cfm already supports) exactly like the core runner does. + // + // Recognized formats are html | json | txt | junit (plus the + // no-format default). An UNrecognized value (an empty string or an + // arbitrary token like "xml") resolves to recognized=false: the app + // runner emits nothing for it, preserving the historical behavior. + // html.cfm must NOT be rendered for an arbitrary url.format — its + // dev-tools navigation builds format-toggle links from url.format and + // the framework's response content-negotiation 500s on Adobe when the + // format is unknown. + + it("renders HTML when url has no format key (the no-format default)", () => { + var resolver = new wheels.tests._assets.dispatch.TestFormatResolver(); + var resolved = resolver.resolveFormat(url = {}); + expect(resolved.recognized).toBeTrue(); + expect(resolved.rendersHtml).toBeTrue(); + expect(resolved.format).toBe("html"); + expect(resolved.contentType).toBe("text/html"); + }); + + it("renders HTML for format=html", () => { + var resolver = new wheels.tests._assets.dispatch.TestFormatResolver(); + var resolved = resolver.resolveFormat(url = { format: "html" }); + expect(resolved.recognized).toBeTrue(); + expect(resolved.rendersHtml).toBeTrue(); + expect(resolved.contentType).toBe("text/html"); + }); + + it("uses the JSON reporter for the HTML branch (html.cfm needs the JSON payload to render)", () => { + var resolver = new wheels.tests._assets.dispatch.TestFormatResolver(); + expect(resolver.resolveFormat(url = { format: "html" }).reporter) + .toBe("wheels.wheelstest.system.reports.JSONReporter"); + }); + + it("emits JSON (not HTML) for format=json", () => { + var resolver = new wheels.tests._assets.dispatch.TestFormatResolver(); + var resolved = resolver.resolveFormat(url = { format: "json" }); + expect(resolved.recognized).toBeTrue(); + expect(resolved.rendersHtml).toBeFalse(); + expect(resolved.format).toBe("json"); + expect(resolved.contentType).toBe("application/json"); + expect(resolved.reporter).toBe("wheels.wheelstest.system.reports.JSONReporter"); + }); + + it("emits text/plain for format=txt via the Text reporter", () => { + var resolver = new wheels.tests._assets.dispatch.TestFormatResolver(); + var resolved = resolver.resolveFormat(url = { format: "txt" }); + expect(resolved.recognized).toBeTrue(); + expect(resolved.rendersHtml).toBeFalse(); + expect(resolved.format).toBe("txt"); + expect(resolved.contentType).toBe("text/plain"); + expect(resolved.reporter).toBe("wheels.wheelstest.system.reports.TextReporter"); + }); + + it("emits text/xml for format=junit via the ANTJUnit reporter", () => { + var resolver = new wheels.tests._assets.dispatch.TestFormatResolver(); + var resolved = resolver.resolveFormat(url = { format: "junit" }); + expect(resolved.recognized).toBeTrue(); + expect(resolved.rendersHtml).toBeFalse(); + expect(resolved.format).toBe("junit"); + expect(resolved.contentType).toBe("text/xml"); + expect(resolved.reporter).toBe("wheels.wheelstest.system.reports.ANTJUnitReporter"); + }); + + it("matches the format token case-insensitively", () => { + var resolver = new wheels.tests._assets.dispatch.TestFormatResolver(); + expect(resolver.resolveFormat(url = { format: "JSON" }).format).toBe("json"); + expect(resolver.resolveFormat(url = { format: "Txt" }).format).toBe("txt"); + expect(resolver.resolveFormat(url = { format: "HTML" }).rendersHtml).toBeTrue(); + }); + + it("trims surrounding whitespace before matching", () => { + var resolver = new wheels.tests._assets.dispatch.TestFormatResolver(); + expect(resolver.resolveFormat(url = { format: " junit " }).format).toBe("junit"); + }); + + it("does NOT render HTML for an empty format value (preserves the historical no-output behavior)", () => { + var resolver = new wheels.tests._assets.dispatch.TestFormatResolver(); + var resolved = resolver.resolveFormat(url = { format: "" }); + expect(resolved.recognized).toBeFalse(); + expect(resolved.rendersHtml).toBeFalse(); + }); + + it("does NOT render HTML for an unrecognized format token (avoids the Adobe html.cfm 500)", () => { + // Rendering html.cfm for an arbitrary url.format 500s on Adobe + // (the dev-tools nav reads url.format and response negotiation + // chokes on the unknown format). An unrecognized token must + // resolve to recognized=false so the app runner emits nothing, + // exactly as it did before this fix. + var resolver = new wheels.tests._assets.dispatch.TestFormatResolver(); + var resolved = resolver.resolveFormat(url = { format: "xml" }); + expect(resolved.recognized).toBeFalse(); + expect(resolved.rendersHtml).toBeFalse(); + }); + + }); + + } + +} diff --git a/vendor/wheels/tests/specs/dispatch/HtmlReportFunctionDeclarationGuardSpec.cfc b/vendor/wheels/tests/specs/dispatch/HtmlReportFunctionDeclarationGuardSpec.cfc new file mode 100644 index 0000000000..98bd3281ee --- /dev/null +++ b/vendor/wheels/tests/specs/dispatch/HtmlReportFunctionDeclarationGuardSpec.cfc @@ -0,0 +1,80 @@ +/** + * Structural cross-engine guard for issue #3251 (item 1). + * + * vendor/wheels/tests/html.cfm renders the TestBox HTML report and is included + * into the cached Public.cfc singleton by both the core runner + * (vendor/wheels/tests/runner.cfm) and the app runner + * (vendor/wheels/tests/app-runner.cfm). On Adobe ColdFusion a *named* function + * declaration in an included .cfm leaks into the component scope, so the second + * request that includes the template throws "Routines cannot be declared more + * than once" — a hard HTTP 500. html.cfm originally declared + * `function processNestedSuites()`, which 500'd `/wheels/core/tests?format=html` + * and `/wheels/app/tests?format=html` on every Adobe engine. + * + * The fix declares the helper as a variables-scoped function EXPRESSION + * (`variables.processNestedSuites = function(){...}`), the same pattern the core + * runner already uses for its helpers to dodge Adobe's + * DuplicateFunctionDefinitionException. CI exercises the runners with + * `format=json`, never the `format=html` render path, so a regression here is + * invisible to the normal suite — hence this source-level guard. + * + * Scan rules (Anti-Pattern 14 spirit, line-anchored on purpose): + * - Only html.cfm is scanned (a .cfm view included into a component). + * - A named declaration matches `function (`; a function EXPRESSION + * (`= function(`) and the JS IIFE in the inline