Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions changelog.d/3113-cli-test-ci-annotations.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- `wheels test --ci` now has an observable effect: it emits GitHub Actions `::error` workflow-command annotations (one per failed or errored spec, with the message encoded to a single line) so failures surface inline in CI logs and PR checks, instead of being byte-identical to a plain run. The flag was previously parsed and threaded through to the runner but never consumed (#3113)
104 changes: 102 additions & 2 deletions cli/lucli/Module.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -5185,7 +5185,7 @@ component extends="modules.BaseModule" {
break;
case "simple":
default:
displayTestResults(result, verboseOutput, resolvedDir);
displayTestResults(result, verboseOutput, resolvedDir, ciMode);
}

// Record failure so the command can exit non-zero AFTER the output
Expand Down Expand Up @@ -5349,7 +5349,8 @@ component extends="modules.BaseModule" {
private void function displayTestResults(
required any result,
boolean verboseOutput = false,
string testDirectory = ""
string testDirectory = "",
boolean ciMode = false
) {
if (!isStruct(result)) {
out(serializeJSON(result));
Expand Down Expand Up @@ -5457,6 +5458,105 @@ component extends="modules.BaseModule" {
}
}
}

// CI mode (--ci): emit GitHub Actions-style error annotations so each
// failure/error surfaces inline in CI logs and PR-check annotations.
// testing.mdx documents --ci as tightening output for GitHub Actions
// and similar runners; before #3113 the flag was parsed and threaded
// through to here but never consumed — byte-identical to a plain run.
if (arguments.ciMode) {
for (var annotation in $buildCiAnnotations(arguments.result)) {
out(annotation);
}
}
}

/**
* Build GitHub Actions workflow-command annotations (one `::error` line per
* failed or errored spec) from a TestBox result memento. Returns an empty
* array when nothing failed. Pure (no I/O) so it is unit-testable without a
* live server — the `--ci` consumer added for issue #3113.
*
* Format: `::error title=<spec>::<message>`. Message/title are encoded per
* the workflow-command rules (newlines → %0A, % → %25, and `:`/`,` in the
* title) so a multi-line failMessage stays a single annotation line.
*/
public array function $buildCiAnnotations(required any result) {
var annotations = [];
if (!isStruct(arguments.result)) {
return annotations;
}

// Walk bundle → suite (recursively) → spec, collecting failures. Mirror
// the emitTapResults() walker: the closure references itself by name and
// appends to a parent struct field (not a bare array) so the mutation is
// seen by reference — the established pattern on the CLI's bundled Lucee.
var ctx = {failures: []};
var walkSuite = function(suite) {
for (var spec in (suite.specStats ?: [])) {
var status = spec.status ?: "";
if (status == "Failed" || status == "Error") {
var message = "";
if (status == "Failed") {
message = spec.failMessage ?: "";
} else if (structKeyExists(spec, "error") && isStruct(spec.error)) {
message = spec.error.message ?: "";
}
arrayAppend(ctx.failures, {name: (spec.name ?: "(unnamed spec)"), message: message});
}
}
// Suite-level failure with no specs (compile error, beforeAll threw).
if (
arrayIsEmpty(suite.specStats ?: [])
&& listFindNoCase("Failed,Error", suite.status ?: "")
) {
arrayAppend(ctx.failures, {
name: (suite.name ?: "(unnamed suite)") & " (suite-level)",
message: suite.globalException ?: ""
});
}
for (var inner in (suite.suiteStats ?: [])) {
walkSuite(inner);
}
};
for (var bundle in (arguments.result.bundleStats ?: [])) {
for (var suite in (bundle.suiteStats ?: [])) {
walkSuite(suite);
}
}

for (var failure in ctx.failures) {
arrayAppend(
annotations,
"::error title=" & $encodeAnnotationProperty(failure.name)
& "::" & $encodeAnnotationData(failure.message)
);
}
return annotations;
}

/**
* Encode a GitHub Actions workflow-command data segment (the message after
* `::`). Percent must be escaped first, then carriage returns dropped and
* line feeds collapsed to %0A so the annotation stays one line.
*/
private string function $encodeAnnotationData(required string value) {
var encoded = replace(arguments.value, "%", "%25", "all");
encoded = replace(encoded, chr(13), "", "all");
encoded = replace(encoded, chr(10), "%0A", "all");
return encoded;
}

/**
* Encode a GitHub Actions workflow-command property value (e.g. `title=`).
* Properties additionally escape `:` and `,` so they don't terminate the
* property list.
*/
private string function $encodeAnnotationProperty(required string value) {
var encoded = $encodeAnnotationData(arguments.value);
encoded = replace(encoded, ":", "%3A", "all");
encoded = replace(encoded, ",", "%2C", "all");
return encoded;
}

private void function displaySuite(required struct suite, string indent = "") {
Expand Down
16 changes: 16 additions & 0 deletions cli/lucli/tests/_fixtures/commands/ModuleOutputCapture.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,20 @@ component extends="cli.lucli.Module" {
return arrayToList(variables.capturedLines, chr(10));
}

/**
* Render a TestBox result struct through the private displayTestResults()
* path and return everything it printed. Lets specs assert the observable
* effect of `--verbose` (per-spec tree) and `--ci` (GitHub Actions
* annotations) without standing up a live test server (issue #3113).
*/
public string function renderResults(
required any result,
boolean verboseOutput = false,
boolean ciMode = false
) {
variables.capturedLines = [];
displayTestResults(arguments.result, arguments.verboseOutput, "", arguments.ciMode);
return capturedOutput();
}

}
115 changes: 115 additions & 0 deletions cli/lucli/tests/specs/commands/TestCommandSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,121 @@ component extends="wheels.wheelstest.system.BaseSpec" {

});

describe("--ci annotation builder ($buildCiAnnotations, issue 3113)", () => {

it("returns an empty array when nothing failed", () => {
var anns = mod.$buildCiAnnotations($passingResult());
expect(anns).toBeArray();
expect(arrayLen(anns)).toBe(0);
});

it("emits one ::error annotation per failed and errored spec", () => {
var anns = mod.$buildCiAnnotations($mixedResult());
expect(arrayLen(anns)).toBe(2);
var joined = arrayToList(anns, chr(10));
expect(joined).toInclude("::error ");
expect(joined).toInclude("fails a thing");
expect(joined).toInclude("expected true to be false");
expect(joined).toInclude("errors a thing");
expect(joined).toInclude("boom NPE");
});

it("encodes newlines and percent signs in the annotation message", () => {
var result = $failingResult("line1" & chr(10) & "50% off");
var anns = mod.$buildCiAnnotations(result);
expect(anns[1]).toInclude("line1%0A");
expect(anns[1]).toInclude("50%25 off");
// The raw newline must not survive — annotations are single-line.
expect(anns[1]).notToInclude(chr(10));
});

});

describe("--ci / --verbose observable output (issue 3113)", () => {

it("a plain run prints neither a per-spec tree nor CI annotations", () => {
var cap = new cli.lucli.tests._fixtures.commands.ModuleOutputCapture(cwd = variables.tempRoot);
var printed = cap.renderResults($passingResult(), false, false);
expect(printed).notToInclude("[PASS]");
expect(printed).notToInclude("::error");
});

it("--verbose prints per-spec PASS lines", () => {
var cap = new cli.lucli.tests._fixtures.commands.ModuleOutputCapture(cwd = variables.tempRoot);
var printed = cap.renderResults($passingResult(), true, false);
expect(printed).toInclude("[PASS]");
expect(printed).toInclude("passes a thing");
});

it("--ci prints GitHub Actions error annotations for failures", () => {
var cap = new cli.lucli.tests._fixtures.commands.ModuleOutputCapture(cwd = variables.tempRoot);
var printed = cap.renderResults($mixedResult(), false, true);
expect(printed).toInclude("::error");
expect(printed).toInclude("fails a thing");
});

});

}

/**
* A TestBox result memento where every spec passed. Shaped like the
* JSONReporter getMemento() the CLI deserializes from /wheels/*/tests.
*/
private struct function $passingResult() {
return {
totalPass: 1, totalFail: 0, totalError: 0, totalDuration: 12,
bundleStats: [{
name: "tests.specs.FooSpec",
suiteStats: [{
name: "Foo feature",
status: "Passed",
specStats: [{ name: "passes a thing", status: "Passed" }],
suiteStats: []
}]
}]
};
}

/**
* A result with one pass, one failure, and one error spec.
*/
private struct function $mixedResult() {
return {
totalPass: 1, totalFail: 1, totalError: 1, totalDuration: 34,
bundleStats: [{
name: "tests.specs.FooSpec",
suiteStats: [{
name: "Foo feature",
status: "Failed",
specStats: [
{ name: "passes a thing", status: "Passed" },
{ name: "fails a thing", status: "Failed", failMessage: "expected true to be false" },
{ name: "errors a thing", status: "Error", error: { message: "boom NPE" } }
],
suiteStats: []
}]
}]
};
}

/**
* A result with a single failure carrying the given fail message — used
* to exercise annotation message encoding.
*/
private struct function $failingResult(required string failMessage) {
return {
totalPass: 0, totalFail: 1, totalError: 0, totalDuration: 5,
bundleStats: [{
name: "tests.specs.FooSpec",
suiteStats: [{
name: "Foo feature",
status: "Failed",
specStats: [{ name: "fails a thing", status: "Failed", failMessage: arguments.failMessage }],
suiteStats: []
}]
}]
};
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ A bare positional argument is treated as the filter directory — `wheels test m
| `--db=<engine>` | Database engine for `--core` matrix runs only. Ignored for app tests (with a warning) — see [below](#testing-against-different-engines). |
| `--reporter=<name>` | `simple` (default, colourful), `json` (raw runner JSON), `tap` (TAP v13 for CI consumers). |
| `--verbose`, `-v` | Print per-spec output instead of the summary line. |
| `--ci` | CI mode: tightens exit codes and output for GitHub Actions and similar runners. |
| `--ci` | CI mode: emits one GitHub Actions `::error` workflow-command annotation per failed or errored spec, so failures surface inline in CI logs and PR-check panels. Exit code is non-zero on failure regardless. |
| `--core` | Run framework self-tests (`vendor/wheels/tests/specs/`) instead of your app suite. App tests are the default; `--core` is the explicit opt-in. |
| `--no-test-db` | Disable the auto-swap to `<datasource>_test`. App tests run against your dev datasource, with whatever data is already in it. |
| `--base-path=<path>` | URL prefix the app is mounted under (e.g. `/myapp`). Auto-derived from `WHEELS_SUBPATH` or `set(subpath=...)` in `config/settings.cfm` when omitted. Leave unset for root-mounted apps (the default). |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ The `wheels test` command accepts a `--reporter=<name>` flag. The CLI always req
| `--reporter=simple` (default) | Human-readable summary: `N passed (Xs)` on green, plus failure details on red |
| `--reporter=json` | Emits the raw JSON result document — pipe it to `jq` or a post-processor |
| `--reporter=tap` | Emits TAP version 13 (`1..N`, `ok` / `not ok` lines) for TAP-consuming CI tooling |
| `--ci` | Accepted for forward-compatibility — currently changes nothing; every run already exits non-zero on failure |
| `--ci` | Emits one GitHub Actions `::error` workflow-command annotation per failed or errored spec, so failures appear inline in CI logs and PR-check panels. Exit code is non-zero on failure regardless. |
| `--verbose` / `-v` | Adds the full bundle/suite/spec tree to the output |

For machine-readable results you can also call the test runner URL directly and post-process the JSON. That is exactly what `tools/ci/run-tests.sh` does in this repo: it `curl`s `/wheels/core/tests?db=sqlite&format=json`, parses the totals in Python, and emits a JUnit XML file that `actions/upload-artifact` ingests for the GitHub summary.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ wheels test --reporter=tap
# Target a specific test database — only meaningful with --core
wheels test --core --db=mysql

# Accepted for forward-compat — every run already exits non-zero on failures
# CI mode: emits GitHub Actions ::error annotations per failed/errored spec
wheels test --ci
```

Expand Down
Loading