Skip to content

Commit 55a4625

Browse files
wheels-bot[bot]github-actions[bot]bpamiriclaude
authored
fix(cli): wheels test --ci emits GitHub Actions error annotations for failures (#3132)
* fix(cli): wheels test --ci emits GitHub Actions error annotations for failures The --ci flag was parsed and threaded into runTests() but never consumed, so `wheels test --ci` produced byte-identical output to a plain run despite testing.mdx documenting it as tightening output for GitHub Actions and similar runners (#3113). displayTestResults() now takes a ciMode argument and, when set, emits one GitHub Actions `::error` workflow-command annotation per failed/errored spec via a new pure $buildCiAnnotations() helper (newlines/percent encoded so each annotation stays a single line). The verbose per-spec tree is regression-locked by a new ModuleOutputCapture-based spec. Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * docs(web/guides): describe --ci annotation output in wheels test reference `wheels test --ci` now emits one GitHub Actions `::error` workflow-command annotation per failed or errored spec (issue #3113). Three guide pages previously described the flag as a no-op or forward-compat placeholder; update each to reflect the actual behavior. Signed-off-by: wheels-bot[bot] <wheels-bot[bot]@users.noreply.github.com> Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * fix(cli): reword TestCommandSpec docblock so the path glob cannot terminate the comment The $passingResult() docblock contained a literal '/wheels/*/tests' — the '*/' inside the path closed the block comment early, leaving stray tokens that fail compilation. Because the CLI suite compiles every CFC in the directory, the whole suite returned HTTP 500. Reword to '/wheels/app|core/tests'. Signed-off-by: Peter Amiri <peter@alurium.com> * docs(web/guides): align ci-integration --verbose row with audited inert behavior Two live audits (issue #3113 and the #3124 behavioral audit) found 'wheels test --verbose' output byte-identical to a plain run — the LuCLI picocli root declares -v/--verbose as a global runtime option, so the flag never reaches the module's renderer. The command reference (testing.mdx) already carries the audited wording from #3124; bring the ci-integration reporter table in line instead of claiming the bundle/suite/spec tree is printed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> --------- Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Signed-off-by: wheels-bot[bot] <wheels-bot[bot]@users.noreply.github.com> Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent cca91dc commit 55a4625

7 files changed

Lines changed: 242 additions & 8 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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)

cli/lucli/Module.cfc

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5321,7 +5321,7 @@ component extends="modules.BaseModule" {
53215321
break;
53225322
case "simple":
53235323
default:
5324-
displayTestResults(result, verboseOutput, resolvedDir);
5324+
displayTestResults(result, verboseOutput, resolvedDir, ciMode);
53255325
}
53265326

53275327
// Record failure so the command can exit non-zero AFTER the output
@@ -5485,7 +5485,8 @@ component extends="modules.BaseModule" {
54855485
private void function displayTestResults(
54865486
required any result,
54875487
boolean verboseOutput = false,
5488-
string testDirectory = ""
5488+
string testDirectory = "",
5489+
boolean ciMode = false
54895490
) {
54905491
if (!isStruct(result)) {
54915492
out(serializeJSON(result));
@@ -5593,6 +5594,105 @@ component extends="modules.BaseModule" {
55935594
}
55945595
}
55955596
}
5597+
5598+
// CI mode (--ci): emit GitHub Actions-style error annotations so each
5599+
// failure/error surfaces inline in CI logs and PR-check annotations.
5600+
// testing.mdx documents --ci as tightening output for GitHub Actions
5601+
// and similar runners; before #3113 the flag was parsed and threaded
5602+
// through to here but never consumed — byte-identical to a plain run.
5603+
if (arguments.ciMode) {
5604+
for (var annotation in $buildCiAnnotations(arguments.result)) {
5605+
out(annotation);
5606+
}
5607+
}
5608+
}
5609+
5610+
/**
5611+
* Build GitHub Actions workflow-command annotations (one `::error` line per
5612+
* failed or errored spec) from a TestBox result memento. Returns an empty
5613+
* array when nothing failed. Pure (no I/O) so it is unit-testable without a
5614+
* live server — the `--ci` consumer added for issue #3113.
5615+
*
5616+
* Format: `::error title=<spec>::<message>`. Message/title are encoded per
5617+
* the workflow-command rules (newlines → %0A, % → %25, and `:`/`,` in the
5618+
* title) so a multi-line failMessage stays a single annotation line.
5619+
*/
5620+
public array function $buildCiAnnotations(required any result) {
5621+
var annotations = [];
5622+
if (!isStruct(arguments.result)) {
5623+
return annotations;
5624+
}
5625+
5626+
// Walk bundle → suite (recursively) → spec, collecting failures. Mirror
5627+
// the emitTapResults() walker: the closure references itself by name and
5628+
// appends to a parent struct field (not a bare array) so the mutation is
5629+
// seen by reference — the established pattern on the CLI's bundled Lucee.
5630+
var ctx = {failures: []};
5631+
var walkSuite = function(suite) {
5632+
for (var spec in (suite.specStats ?: [])) {
5633+
var status = spec.status ?: "";
5634+
if (status == "Failed" || status == "Error") {
5635+
var message = "";
5636+
if (status == "Failed") {
5637+
message = spec.failMessage ?: "";
5638+
} else if (structKeyExists(spec, "error") && isStruct(spec.error)) {
5639+
message = spec.error.message ?: "";
5640+
}
5641+
arrayAppend(ctx.failures, {name: (spec.name ?: "(unnamed spec)"), message: message});
5642+
}
5643+
}
5644+
// Suite-level failure with no specs (compile error, beforeAll threw).
5645+
if (
5646+
arrayIsEmpty(suite.specStats ?: [])
5647+
&& listFindNoCase("Failed,Error", suite.status ?: "")
5648+
) {
5649+
arrayAppend(ctx.failures, {
5650+
name: (suite.name ?: "(unnamed suite)") & " (suite-level)",
5651+
message: suite.globalException ?: ""
5652+
});
5653+
}
5654+
for (var inner in (suite.suiteStats ?: [])) {
5655+
walkSuite(inner);
5656+
}
5657+
};
5658+
for (var bundle in (arguments.result.bundleStats ?: [])) {
5659+
for (var suite in (bundle.suiteStats ?: [])) {
5660+
walkSuite(suite);
5661+
}
5662+
}
5663+
5664+
for (var failure in ctx.failures) {
5665+
arrayAppend(
5666+
annotations,
5667+
"::error title=" & $encodeAnnotationProperty(failure.name)
5668+
& "::" & $encodeAnnotationData(failure.message)
5669+
);
5670+
}
5671+
return annotations;
5672+
}
5673+
5674+
/**
5675+
* Encode a GitHub Actions workflow-command data segment (the message after
5676+
* `::`). Percent must be escaped first, then carriage returns dropped and
5677+
* line feeds collapsed to %0A so the annotation stays one line.
5678+
*/
5679+
private string function $encodeAnnotationData(required string value) {
5680+
var encoded = replace(arguments.value, "%", "%25", "all");
5681+
encoded = replace(encoded, chr(13), "", "all");
5682+
encoded = replace(encoded, chr(10), "%0A", "all");
5683+
return encoded;
5684+
}
5685+
5686+
/**
5687+
* Encode a GitHub Actions workflow-command property value (e.g. `title=`).
5688+
* Properties additionally escape `:` and `,` so they don't terminate the
5689+
* property list.
5690+
*/
5691+
private string function $encodeAnnotationProperty(required string value) {
5692+
var encoded = $encodeAnnotationData(arguments.value);
5693+
encoded = replace(encoded, ":", "%3A", "all");
5694+
encoded = replace(encoded, ",", "%2C", "all");
5695+
return encoded;
55965696
}
55975697

55985698
private void function displaySuite(required struct suite, string indent = "") {

cli/lucli/tests/_fixtures/commands/ModuleOutputCapture.cfc

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,20 @@ component extends="cli.lucli.Module" {
3232
return arrayToList(variables.capturedLines, chr(10));
3333
}
3434

35+
/**
36+
* Render a TestBox result struct through the private displayTestResults()
37+
* path and return everything it printed. Lets specs assert the observable
38+
* effect of `--verbose` (per-spec tree) and `--ci` (GitHub Actions
39+
* annotations) without standing up a live test server (issue #3113).
40+
*/
41+
public string function renderResults(
42+
required any result,
43+
boolean verboseOutput = false,
44+
boolean ciMode = false
45+
) {
46+
variables.capturedLines = [];
47+
displayTestResults(arguments.result, arguments.verboseOutput, "", arguments.ciMode);
48+
return capturedOutput();
49+
}
50+
3551
}

cli/lucli/tests/specs/commands/TestCommandSpec.cfc

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,121 @@ component extends="wheels.wheelstest.system.BaseSpec" {
373373

374374
});
375375

376+
describe("--ci annotation builder ($buildCiAnnotations, issue 3113)", () => {
377+
378+
it("returns an empty array when nothing failed", () => {
379+
var anns = mod.$buildCiAnnotations($passingResult());
380+
expect(anns).toBeArray();
381+
expect(arrayLen(anns)).toBe(0);
382+
});
383+
384+
it("emits one ::error annotation per failed and errored spec", () => {
385+
var anns = mod.$buildCiAnnotations($mixedResult());
386+
expect(arrayLen(anns)).toBe(2);
387+
var joined = arrayToList(anns, chr(10));
388+
expect(joined).toInclude("::error ");
389+
expect(joined).toInclude("fails a thing");
390+
expect(joined).toInclude("expected true to be false");
391+
expect(joined).toInclude("errors a thing");
392+
expect(joined).toInclude("boom NPE");
393+
});
394+
395+
it("encodes newlines and percent signs in the annotation message", () => {
396+
var result = $failingResult("line1" & chr(10) & "50% off");
397+
var anns = mod.$buildCiAnnotations(result);
398+
expect(anns[1]).toInclude("line1%0A");
399+
expect(anns[1]).toInclude("50%25 off");
400+
// The raw newline must not survive — annotations are single-line.
401+
expect(anns[1]).notToInclude(chr(10));
402+
});
403+
404+
});
405+
406+
describe("--ci / --verbose observable output (issue 3113)", () => {
407+
408+
it("a plain run prints neither a per-spec tree nor CI annotations", () => {
409+
var cap = new cli.lucli.tests._fixtures.commands.ModuleOutputCapture(cwd = variables.tempRoot);
410+
var printed = cap.renderResults($passingResult(), false, false);
411+
expect(printed).notToInclude("[PASS]");
412+
expect(printed).notToInclude("::error");
413+
});
414+
415+
it("--verbose prints per-spec PASS lines", () => {
416+
var cap = new cli.lucli.tests._fixtures.commands.ModuleOutputCapture(cwd = variables.tempRoot);
417+
var printed = cap.renderResults($passingResult(), true, false);
418+
expect(printed).toInclude("[PASS]");
419+
expect(printed).toInclude("passes a thing");
420+
});
421+
422+
it("--ci prints GitHub Actions error annotations for failures", () => {
423+
var cap = new cli.lucli.tests._fixtures.commands.ModuleOutputCapture(cwd = variables.tempRoot);
424+
var printed = cap.renderResults($mixedResult(), false, true);
425+
expect(printed).toInclude("::error");
426+
expect(printed).toInclude("fails a thing");
427+
});
428+
429+
});
430+
431+
}
432+
433+
/**
434+
* A TestBox result memento where every spec passed. Shaped like the
435+
* JSONReporter getMemento() the CLI deserializes from /wheels/app|core/tests.
436+
*/
437+
private struct function $passingResult() {
438+
return {
439+
totalPass: 1, totalFail: 0, totalError: 0, totalDuration: 12,
440+
bundleStats: [{
441+
name: "tests.specs.FooSpec",
442+
suiteStats: [{
443+
name: "Foo feature",
444+
status: "Passed",
445+
specStats: [{ name: "passes a thing", status: "Passed" }],
446+
suiteStats: []
447+
}]
448+
}]
449+
};
450+
}
451+
452+
/**
453+
* A result with one pass, one failure, and one error spec.
454+
*/
455+
private struct function $mixedResult() {
456+
return {
457+
totalPass: 1, totalFail: 1, totalError: 1, totalDuration: 34,
458+
bundleStats: [{
459+
name: "tests.specs.FooSpec",
460+
suiteStats: [{
461+
name: "Foo feature",
462+
status: "Failed",
463+
specStats: [
464+
{ name: "passes a thing", status: "Passed" },
465+
{ name: "fails a thing", status: "Failed", failMessage: "expected true to be false" },
466+
{ name: "errors a thing", status: "Error", error: { message: "boom NPE" } }
467+
],
468+
suiteStats: []
469+
}]
470+
}]
471+
};
472+
}
473+
474+
/**
475+
* A result with a single failure carrying the given fail message — used
476+
* to exercise annotation message encoding.
477+
*/
478+
private struct function $failingResult(required string failMessage) {
479+
return {
480+
totalPass: 0, totalFail: 1, totalError: 0, totalDuration: 5,
481+
bundleStats: [{
482+
name: "tests.specs.FooSpec",
483+
suiteStats: [{
484+
name: "Foo feature",
485+
status: "Failed",
486+
specStats: [{ name: "fails a thing", status: "Failed", failMessage: arguments.failMessage }],
487+
suiteStats: []
488+
}]
489+
}]
490+
};
376491
}
377492

378493
/**

web/sites/guides/src/content/docs/v4-0-0/command-line-tools/wheels-commands/testing.mdx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ A bare positional argument is treated as the filter directory — `wheels test m
5959
| `--db=<engine>` | Database engine for `--core` matrix runs only. Ignored for app tests (with a warning) — see [below](#testing-against-different-engines). |
6060
| `--reporter=<name>` | `simple` (default, colourful), `json` (raw runner JSON), `tap` (TAP v13 for CI consumers). |
6161
| `--verbose`, `-v` | Accepted but currently inert — output is identical to a plain run; no per-spec output is printed for passing specs. Wiring tracked in [#3113](https://github.com/wheels-dev/wheels/issues/3113). |
62-
| `--ci` | Accepted but currently inert — output is byte-identical to a plain run, and exit codes are already non-zero on failure without it. Intended to tighten output for GitHub Actions and similar runners; tracked in [#3113](https://github.com/wheels-dev/wheels/issues/3113). |
62+
| `--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. |
6363
| `--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. |
6464
| `--no-test-db` | Disable the auto-swap to `<datasource>_test`. App tests run against your dev datasource, with whatever data is already in it. |
6565
| `--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). |
@@ -155,8 +155,10 @@ wheels test
155155
# Narrow to one area while iterating
156156
wheels test --filter=models
157157

158-
# CI run — exit codes are already firm without extra flags; for machine-readable
159-
# output use a reporter (--ci itself is inert today, #3113)
158+
# CI run on GitHub Actions — --ci adds one ::error annotation per failed or
159+
# errored spec (exit codes are firm with or without it); for machine-readable
160+
# output use a reporter
161+
wheels test --ci
160162
wheels test --reporter=tap
161163

162164
# First-time browser setup, then exercise the browser specs

web/sites/guides/src/content/docs/v4-0-0/testing/ci-integration.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,8 @@ The `wheels test` command accepts a `--reporter=<name>` flag. The CLI always req
7878
| `--reporter=simple` (default) | Human-readable summary: `N passed (Xs)` on green, plus failure details on red |
7979
| `--reporter=json` | Emits the raw JSON result document — pipe it to `jq` or a post-processor |
8080
| `--reporter=tap` | Emits TAP version 13 (`1..N`, `ok` / `not ok` lines) for TAP-consuming CI tooling |
81-
| `--ci` | Accepted for forward-compatibility — currently changes nothing; every run already exits non-zero on failure |
82-
| `--verbose` / `-v` | Adds the full bundle/suite/spec tree to the output |
81+
| `--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. |
82+
| `--verbose` / `-v` | Accepted but currently inert — output is identical to a plain run; the per-spec tree wiring is tracked in [#3113](https://github.com/wheels-dev/wheels/issues/3113) |
8383

8484
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, emits a JUnit XML file that `actions/upload-artifact` ingests for the GitHub summary, and fails the build when the payload reports a rejected `directory=` scope or a 0-bundle discovery.
8585

web/sites/guides/src/content/docs/v4-0-0/testing/running-tests-locally.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ wheels test --reporter=tap
5555
# Target a specific test database — only meaningful with --core
5656
wheels test --core --db=mysql
5757

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

0 commit comments

Comments
 (0)