Skip to content

Commit 1a232fe

Browse files
Peter Amiriclaude
andcommitted
fix(test): render the app test-runner HTML report (#3251)
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 <noreply@anthropic.com> Signed-off-by: Peter Amiri <petera@pai.com>
1 parent f2bb8de commit 1a232fe

6 files changed

Lines changed: 338 additions & 43 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
- `/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).
2+
- 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).
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/**
2+
* Helper extracted from app-runner.cfm so the output-format rule is
3+
* unit-testable without spinning up an HTTP request. app-runner.cfm reads
4+
* url.format, hands it to this resolver, and uses the returned reporter /
5+
* contentType / rendersHtml / recognized fields to drive the response.
6+
*
7+
* Issue #3251 (item 1): the html / no-format branch historically emitted
8+
* application/json for the app runner — a user opening
9+
* `/wheels/app/tests?format=html` (or hitting the no-format default) in a
10+
* browser got raw JSON instead of the TestBox-style HTML report the core
11+
* runner (`/wheels/core/tests`) renders. resolveFormat() marks that branch
12+
* rendersHtml=true so the app runner falls through to html.cfm with
13+
* type="App" — a branch html.cfm already supports — exactly like the core
14+
* runner falls through with type="Core".
15+
*
16+
* Recognized formats (case-insensitive, trimmed): html | json | txt | junit,
17+
* plus the no-format default (no `format` key) which is treated as html. Any
18+
* other value — an empty string or an arbitrary token like "xml" — resolves
19+
* to recognized=false. The app runner emits nothing for an unrecognized
20+
* format, preserving the historical behavior: html.cfm must NOT be rendered
21+
* for an arbitrary url.format. Its dev-tools navigation
22+
* (vendor/wheels/tests/_navigation.cfm) builds format-toggle links from
23+
* url.format and the framework's response content-negotiation throws a 500
24+
* on Adobe when the format is unknown.
25+
*/
26+
component {
27+
28+
variables.REPORTER_PACKAGE = "wheels.wheelstest.system.reports";
29+
30+
public struct function resolveFormat(required struct url) {
31+
var htmlChoice = {
32+
format: "html",
33+
reporter: variables.REPORTER_PACKAGE & ".JSONReporter",
34+
contentType: "text/html",
35+
rendersHtml: true,
36+
recognized: true
37+
};
38+
39+
// No format key at all is the no-format default → HTML report.
40+
if (!StructKeyExists(arguments.url, "format")) {
41+
return htmlChoice;
42+
}
43+
44+
var format = LCase(Trim(arguments.url.format));
45+
46+
switch (format) {
47+
case "html":
48+
return htmlChoice;
49+
case "json":
50+
return {
51+
format: "json",
52+
reporter: variables.REPORTER_PACKAGE & ".JSONReporter",
53+
contentType: "application/json",
54+
rendersHtml: false,
55+
recognized: true
56+
};
57+
case "txt":
58+
return {
59+
format: "txt",
60+
reporter: variables.REPORTER_PACKAGE & ".TextReporter",
61+
contentType: "text/plain",
62+
rendersHtml: false,
63+
recognized: true
64+
};
65+
case "junit":
66+
return {
67+
format: "junit",
68+
reporter: variables.REPORTER_PACKAGE & ".ANTJUnitReporter",
69+
contentType: "text/xml",
70+
rendersHtml: false,
71+
recognized: true
72+
};
73+
default:
74+
// Empty value or unrecognized token: not a known format. The app
75+
// runner emits nothing, matching the historical behavior and
76+
// avoiding an html.cfm render for an arbitrary url.format.
77+
return {
78+
format: format,
79+
reporter: "",
80+
contentType: "",
81+
rendersHtml: false,
82+
recognized: false
83+
};
84+
}
85+
}
86+
87+
}

vendor/wheels/tests/app-runner.cfm

Lines changed: 45 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -114,48 +114,55 @@
114114
bundlesDiscovered = local.bundlesDiscovered
115115
);
116116
117-
if (!StructKeyExists(url, "format") || url.format == "html") {
118-
result = testBox.run(reporter = "wheels.wheelstest.system.reports.JSONReporter");
119-
decoded = DeserializeJSON(result);
120-
cfheader(statuscode = (decoded.totalFail > 0 || decoded.totalError > 0) ? 417 : 200);
121-
// For the html case the framework runner falls through to html.cfm;
122-
// for the app-runner we just emit the JSON in this branch too since
123-
// app tests are typically requested over JSON (CLI/CI). Users hitting
124-
// the URL in a browser still get a structured response they can read.
125-
cfcontent(type="application/json");
126-
writeOutput(local.dirResolver.injectScopeMetadata(
127-
resultJson = result,
128-
scope = local.testScope,
129-
bundlesDiscovered = local.bundlesDiscovered,
130-
warnings = local.scopeWarnings
131-
));
132-
} else if (url.format == "json") {
133-
result = testBox.run(reporter = "wheels.wheelstest.system.reports.JSONReporter");
134-
decoded = DeserializeJSON(result);
135-
if (decoded.totalFail > 0 || decoded.totalError > 0) {
136-
if (!StructKeyExists(url, "cli") || !url.cli) {
137-
cfheader(statuscode = 417);
117+
// Resolve the output format (reporter + content type + whether to
118+
// render an HTML report) through TestFormatResolver so the rule is
119+
// unit-testable without an HTTP request (see AppRunnerTestFormatSpec,
120+
// issue #3251). An unrecognized format resolves to recognized=false:
121+
// the runner emits nothing, preserving the historical behavior.
122+
local.fmtResolver = new wheels.tests._assets.dispatch.TestFormatResolver();
123+
local.output = local.fmtResolver.resolveFormat(url);
124+
125+
if (local.output.recognized) {
126+
result = testBox.run(reporter = local.output.reporter);
127+
128+
if (local.output.rendersHtml) {
129+
// Render the TestBox-style HTML report for the html / no-format
130+
// default, mirroring the core runner (vendor/wheels/tests/runner.cfm).
131+
// html.cfm has a type="App" branch (package=tests.specs,
132+
// route=testbox) built for exactly this. Previously this branch
133+
// emitted raw JSON, so a user opening /wheels/app/tests?format=html
134+
// in a browser got JSON instead of the report (issue #3251 item 1).
135+
decoded = DeserializeJSON(result);
136+
cfheader(statuscode = (decoded.totalFail > 0 || decoded.totalError > 0) ? 417 : 200);
137+
type = "App";
138+
include "html.cfm";
139+
} else if (local.output.format == "json") {
140+
decoded = DeserializeJSON(result);
141+
if (decoded.totalFail > 0 || decoded.totalError > 0) {
142+
if (!StructKeyExists(url, "cli") || !url.cli) {
143+
cfheader(statuscode = 417);
144+
}
145+
} else {
146+
cfheader(statuscode = 200);
138147
}
148+
cfcontent(type = local.output.contentType);
149+
cfheader(name="Access-Control-Allow-Origin", value="*");
150+
writeOutput(local.dirResolver.injectScopeMetadata(
151+
resultJson = result,
152+
scope = local.testScope,
153+
bundlesDiscovered = local.bundlesDiscovered,
154+
warnings = local.scopeWarnings
155+
));
139156
} else {
140-
cfheader(statuscode = 200);
157+
// txt / junit: emit the reporter output verbatim under the
158+
// resolved content type.
159+
cfcontent(type = local.output.contentType);
160+
writeOutput(result);
141161
}
142-
cfcontent(type="application/json");
143-
cfheader(name="Access-Control-Allow-Origin", value="*");
144-
writeOutput(local.dirResolver.injectScopeMetadata(
145-
resultJson = result,
146-
scope = local.testScope,
147-
bundlesDiscovered = local.bundlesDiscovered,
148-
warnings = local.scopeWarnings
149-
));
150-
} else if (url.format == "txt") {
151-
result = testBox.run(reporter = "wheels.wheelstest.system.reports.TextReporter");
152-
cfcontent(type = "text/plain");
153-
writeOutput(result);
154-
} else if (url.format == "junit") {
155-
result = testBox.run(reporter = "wheels.wheelstest.system.reports.ANTJUnitReporter");
156-
cfcontent(type = "text/xml");
157-
writeOutput(result);
158162
}
163+
// Unrecognized format (empty value / unknown token): no output, and
164+
// testBox is not run — html.cfm must not be rendered for an arbitrary
165+
// url.format (it 500s on Adobe). Mirrors the pre-fix fall-through.
159166
} finally {
160167
// Restore the original datasource (via applyDataSource() so test-run cached model classes are invalidated).
161168
if (local.swappedDataSource) {

vendor/wheels/tests/html.cfm

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,18 @@
3434
3535
testResults.ok = (testResults.numFailures + testResults.numErrors) == 0;
3636
37-
// Recursive function to process nested suites
38-
function processNestedSuites(suites, bundleName) {
37+
// Recursive function to process nested suites. Declared as a variables-scoped
38+
// function expression (not a named `function` declaration) so repeated
39+
// includes of this template into the cached Public.cfc do not throw Adobe's
40+
// "Routines cannot be declared more than once" — the same reason the core
41+
// runner declares its helpers as closures (vendor/wheels/tests/runner.cfm).
42+
// A named declaration in an included .cfm leaks into the component scope on
43+
// Adobe and collides on the second request. See issue #3251.
44+
variables.processNestedSuites = function(suites, bundleName) {
3945
for (suite in suites) {
4046
// Process nested suites first (deeper level)
4147
if (structKeyExists(suite, "suiteStats") && arrayLen(suite.suiteStats) > 0) {
42-
processNestedSuites(suite.suiteStats, bundleName);
48+
variables.processNestedSuites(suite.suiteStats, bundleName);
4349
}
4450
4551
// Process individual specs in this suite
@@ -108,10 +114,10 @@
108114
arrayAppend(testResults.results, thisResult);
109115
}
110116
}
111-
}
117+
};
112118
113119
for (bundle in DeJsonResult.bundleStats) {
114-
processNestedSuites(bundle.suiteStats, bundle.name);
120+
variables.processNestedSuites(bundle.suiteStats, bundle.name);
115121
}
116122
117123
failures = [];
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
component extends="wheels.WheelsTest" {
2+
3+
function run() {
4+
5+
describe("app-runner output format resolution (issue 3251)", () => {
6+
7+
// Issue #3251 (item 1): `/wheels/app/tests?format=html` — which is
8+
// also the no-format default — historically emitted application/json.
9+
// A user opening that URL in a browser reasonably expects the
10+
// TestBox-style HTML report the core runner (`/wheels/core/tests`)
11+
// renders. resolveFormat() centralizes the format-to-output decision
12+
// so the html / no-format branch can be marked rendersHtml=true; the
13+
// app runner then falls through to html.cfm (type="App", a branch
14+
// html.cfm already supports) exactly like the core runner does.
15+
//
16+
// Recognized formats are html | json | txt | junit (plus the
17+
// no-format default). An UNrecognized value (an empty string or an
18+
// arbitrary token like "xml") resolves to recognized=false: the app
19+
// runner emits nothing for it, preserving the historical behavior.
20+
// html.cfm must NOT be rendered for an arbitrary url.format — its
21+
// dev-tools navigation builds format-toggle links from url.format and
22+
// the framework's response content-negotiation 500s on Adobe when the
23+
// format is unknown.
24+
25+
it("renders HTML when url has no format key (the no-format default)", () => {
26+
var resolver = new wheels.tests._assets.dispatch.TestFormatResolver();
27+
var resolved = resolver.resolveFormat(url = {});
28+
expect(resolved.recognized).toBeTrue();
29+
expect(resolved.rendersHtml).toBeTrue();
30+
expect(resolved.format).toBe("html");
31+
expect(resolved.contentType).toBe("text/html");
32+
});
33+
34+
it("renders HTML for format=html", () => {
35+
var resolver = new wheels.tests._assets.dispatch.TestFormatResolver();
36+
var resolved = resolver.resolveFormat(url = { format: "html" });
37+
expect(resolved.recognized).toBeTrue();
38+
expect(resolved.rendersHtml).toBeTrue();
39+
expect(resolved.contentType).toBe("text/html");
40+
});
41+
42+
it("uses the JSON reporter for the HTML branch (html.cfm needs the JSON payload to render)", () => {
43+
var resolver = new wheels.tests._assets.dispatch.TestFormatResolver();
44+
expect(resolver.resolveFormat(url = { format: "html" }).reporter)
45+
.toBe("wheels.wheelstest.system.reports.JSONReporter");
46+
});
47+
48+
it("emits JSON (not HTML) for format=json", () => {
49+
var resolver = new wheels.tests._assets.dispatch.TestFormatResolver();
50+
var resolved = resolver.resolveFormat(url = { format: "json" });
51+
expect(resolved.recognized).toBeTrue();
52+
expect(resolved.rendersHtml).toBeFalse();
53+
expect(resolved.format).toBe("json");
54+
expect(resolved.contentType).toBe("application/json");
55+
expect(resolved.reporter).toBe("wheels.wheelstest.system.reports.JSONReporter");
56+
});
57+
58+
it("emits text/plain for format=txt via the Text reporter", () => {
59+
var resolver = new wheels.tests._assets.dispatch.TestFormatResolver();
60+
var resolved = resolver.resolveFormat(url = { format: "txt" });
61+
expect(resolved.recognized).toBeTrue();
62+
expect(resolved.rendersHtml).toBeFalse();
63+
expect(resolved.format).toBe("txt");
64+
expect(resolved.contentType).toBe("text/plain");
65+
expect(resolved.reporter).toBe("wheels.wheelstest.system.reports.TextReporter");
66+
});
67+
68+
it("emits text/xml for format=junit via the ANTJUnit reporter", () => {
69+
var resolver = new wheels.tests._assets.dispatch.TestFormatResolver();
70+
var resolved = resolver.resolveFormat(url = { format: "junit" });
71+
expect(resolved.recognized).toBeTrue();
72+
expect(resolved.rendersHtml).toBeFalse();
73+
expect(resolved.format).toBe("junit");
74+
expect(resolved.contentType).toBe("text/xml");
75+
expect(resolved.reporter).toBe("wheels.wheelstest.system.reports.ANTJUnitReporter");
76+
});
77+
78+
it("matches the format token case-insensitively", () => {
79+
var resolver = new wheels.tests._assets.dispatch.TestFormatResolver();
80+
expect(resolver.resolveFormat(url = { format: "JSON" }).format).toBe("json");
81+
expect(resolver.resolveFormat(url = { format: "Txt" }).format).toBe("txt");
82+
expect(resolver.resolveFormat(url = { format: "HTML" }).rendersHtml).toBeTrue();
83+
});
84+
85+
it("trims surrounding whitespace before matching", () => {
86+
var resolver = new wheels.tests._assets.dispatch.TestFormatResolver();
87+
expect(resolver.resolveFormat(url = { format: " junit " }).format).toBe("junit");
88+
});
89+
90+
it("does NOT render HTML for an empty format value (preserves the historical no-output behavior)", () => {
91+
var resolver = new wheels.tests._assets.dispatch.TestFormatResolver();
92+
var resolved = resolver.resolveFormat(url = { format: "" });
93+
expect(resolved.recognized).toBeFalse();
94+
expect(resolved.rendersHtml).toBeFalse();
95+
});
96+
97+
it("does NOT render HTML for an unrecognized format token (avoids the Adobe html.cfm 500)", () => {
98+
// Rendering html.cfm for an arbitrary url.format 500s on Adobe
99+
// (the dev-tools nav reads url.format and response negotiation
100+
// chokes on the unknown format). An unrecognized token must
101+
// resolve to recognized=false so the app runner emits nothing,
102+
// exactly as it did before this fix.
103+
var resolver = new wheels.tests._assets.dispatch.TestFormatResolver();
104+
var resolved = resolver.resolveFormat(url = { format: "xml" });
105+
expect(resolved.recognized).toBeFalse();
106+
expect(resolved.rendersHtml).toBeFalse();
107+
});
108+
109+
});
110+
111+
}
112+
113+
}

0 commit comments

Comments
 (0)