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 000000000..8670ad022
--- /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 000000000..4bc3f7dfc
--- /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 5cba3d6d5..5f0166563 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 9a89842e5..c38c2963f 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 000000000..78faa5e56
--- /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 000000000..98bd3281e
--- /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