Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changelog.d/3251-app-test-html-report.fixed.md
Original file line number Diff line number Diff line change
@@ -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).
87 changes: 87 additions & 0 deletions vendor/wheels/tests/_assets/dispatch/TestFormatResolver.cfc
Original file line number Diff line number Diff line change
@@ -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
};
}
}

}
83 changes: 45 additions & 38 deletions vendor/wheels/tests/app-runner.cfm
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
16 changes: 11 additions & 5 deletions vendor/wheels/tests/html.cfm
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = [];
Expand Down
113 changes: 113 additions & 0 deletions vendor/wheels/tests/specs/dispatch/AppRunnerTestFormatSpec.cfc
Original file line number Diff line number Diff line change
@@ -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();
});

});

}

}
Loading
Loading