Skip to content

Commit febbc40

Browse files
authored
test/ci: improve GitHub Actions test logging and error visibility (#13787)
- Add GitHub Actions error annotations with file paths and test names - Add clear, searchable failure markers in CI logs - Group logs by bucket and step; ensure bucket failure annotations stay visible when groups are collapsed - Reduce noise: hide logs for passing tests and gate verbose output behind RUNNER_DEBUG / QUARTO_TEST_VERBOSE - Run all tests on Linux and Windows (no fail-fast) and report all failures at the end - Add grouping around R package restore - Consolidate GitHub Actions helper utilities into src/tools/github.ts - Remove temporary failing test used to validate annotations
1 parent 7e08077 commit febbc40

File tree

7 files changed

+244
-72
lines changed

7 files changed

+244
-72
lines changed

.github/workflows/test-smokes.yml

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -149,15 +149,23 @@ jobs:
149149
- name: Restore R packages
150150
working-directory: tests
151151
run: |
152+
cat("::group::Installing renv if needed\n")
152153
if (!requireNamespace('renv', quietly = TRUE)) install.packages('renv')
154+
cat("::endgroup::\n")
155+
cat("::group::Restoring R packages from renv.lock\n")
153156
renv::restore()
157+
cat("::endgroup::\n")
158+
cat("::group::Installing dev versions of knitr and rmarkdown\n")
154159
# Install dev versions for our testing
155160
# Use r-universe to avoid github api calls
156161
try(install.packages('rmarkdown', repos = c('https://rstudio.r-universe.dev', getOption('repos'))))
157162
try(install.packages('knitr', repos = c('https://yihui.r-universe.dev', getOption('repos'))))
163+
cat("::endgroup::\n")
158164
if ('${{ inputs.extra-r-packages }}' != '') {
159165
cat(sprintf("::notice::Running with the following extra R packages for renv: %s\n", "${{ inputs.extra-r-packages }}"))
166+
cat("::group::Installing extra R packages\n")
160167
renv::install(strsplit("${{ inputs.extra-r-packages }}", split = ",")[[1]])
168+
cat("::endgroup::\n")
161169
}
162170
shell: Rscript {0}
163171
env:
@@ -259,14 +267,34 @@ jobs:
259267
QUARTO_LOG_LEVEL: DEBUG
260268
run: |
261269
haserror=0
270+
failed_tests=()
262271
readarray -t my_array < <(echo '${{ inputs.buckets }}' | jq -rc '.[]')
263-
for file in "${my_array[@]}"; do
272+
for file in "${my_array[@]}"; do
273+
echo "::group::Running ${file}"
264274
echo ">>> ./run-tests.sh ${file}"
265-
shopt -s globstar && ./run-tests.sh $file
275+
# Run tests without -e so we don't exit on first failure
276+
set +e
277+
shopt -s globstar && ./run-tests.sh "$file"
266278
status=$?
267-
[ $status -eq 0 ] && echo ">>> No error in this test file" || haserror=1
279+
set -e
280+
echo "::endgroup::"
281+
if [ $status -ne 0 ]; then
282+
echo "::error title=Test Bucket Failed::Test bucket ${file} failed with exit code ${status}"
283+
echo ">>> Error found in test file: ${file}"
284+
haserror=1
285+
failed_tests+=("$file")
286+
fi
268287
done
269-
[ $haserror -eq 0 ] && echo ">>> All tests passed" || exit 1
288+
if [ $haserror -eq 1 ]; then
289+
echo "---- FAILING TESTS SUMMARY ----"
290+
echo " The following test buckets failed:"
291+
for failed in "${failed_tests[@]}"; do
292+
echo " - $failed"
293+
done
294+
exit 1
295+
else
296+
echo ">>> All tests passed"
297+
fi
270298
working-directory: tests
271299
shell: bash
272300

@@ -277,18 +305,26 @@ jobs:
277305
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
278306
run: |
279307
$haserror=$false
308+
$failed_tests=@()
280309
foreach ($file in ('${{ inputs.buckets }}' | ConvertFrom-Json)) {
310+
Write-Host "::group::Running ${file}"
281311
Write-Host ">>> ./run-tests.ps1 ${file}"
282312
./run-tests.ps1 $file
283313
$status=$LASTEXITCODE
284-
if ($status -eq 1) {
285-
Write-Host ">>> Error found in test file"
314+
Write-Host "::endgroup::"
315+
if ($status -ne 0) {
316+
Write-Host "::error title=Test Bucket Failed::Test bucket ${file} failed with exit code ${status}"
317+
Write-Host ">>> Error found in test file: ${file}"
286318
$haserror=$true
287-
} else {
288-
Write-Host ">>> No error in this test file"
319+
$failed_tests+=$file
289320
}
290321
}
291322
if ($haserror) {
323+
Write-Host "---- FAILING TESTS SUMMARY ----"
324+
Write-Host " The following test buckets failed:"
325+
foreach ($failed in $failed_tests) {
326+
Write-Host " - $failed"
327+
}
292328
Exit 1
293329
} else {
294330
Write-Host ">>> All tests have passed"

src/core/platform.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,6 @@ export function isInteractiveSession() {
9494
return isRStudio() || isInteractiveTerminal() || isVSCodeOutputChannel();
9595
}
9696

97-
export function isGithubAction() {
98-
return Deno.env.get("GITHUB_ACTIONS") === "true";
99-
}
100-
10197
export function nullDevice() {
10298
return isWindows ? "NUL" : "/dev/null";
10399
}

src/tools/github.ts

Lines changed: 118 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,128 @@
44
* Copyright (C) 2020-2022 Posit Software, PBC
55
*/
66

7-
import { runningInCI } from "../core/ci-info.ts";
87
import { GitHubRelease } from "./types.ts";
98

109
// deno-lint-ignore-file camelcase
1110

11+
// GitHub Actions Detection
12+
export function isGitHubActions(): boolean {
13+
return Deno.env.get("GITHUB_ACTIONS") === "true";
14+
}
15+
16+
export function isVerboseMode(): boolean {
17+
return Deno.env.get("RUNNER_DEBUG") === "1" ||
18+
Deno.env.get("QUARTO_TEST_VERBOSE") === "true";
19+
}
20+
21+
// GitHub Actions Workflow Command Escaping
22+
// See: https://docs.github.com/en/actions/reference/workflow-commands-for-github-actions
23+
export function escapeData(s: string): string {
24+
return s
25+
.replace(/%/g, "%25")
26+
.replace(/\r/g, "%0D")
27+
.replace(/\n/g, "%0A");
28+
}
29+
30+
export function escapeProperty(s: string): string {
31+
return s
32+
.replace(/%/g, "%25")
33+
.replace(/\r/g, "%0D")
34+
.replace(/\n/g, "%0A")
35+
.replace(/:/g, "%3A")
36+
.replace(/,/g, "%2C");
37+
}
38+
39+
// GitHub Actions Annotations
40+
export interface AnnotationProperties {
41+
file?: string;
42+
line?: number;
43+
endLine?: number;
44+
title?: string;
45+
}
46+
47+
function formatProperties(props: AnnotationProperties): string {
48+
const parts: string[] = [];
49+
if (props.file !== undefined) {
50+
parts.push(`file=${escapeProperty(props.file)}`);
51+
}
52+
if (props.line !== undefined) parts.push(`line=${props.line}`);
53+
if (props.endLine !== undefined) parts.push(`endLine=${props.endLine}`);
54+
if (props.title !== undefined) {
55+
parts.push(`title=${escapeProperty(props.title)}`);
56+
}
57+
return parts.length > 0 ? " " + parts.join(",") : "";
58+
}
59+
60+
export function error(
61+
message: string,
62+
properties?: AnnotationProperties,
63+
): void {
64+
if (!isGitHubActions()) return;
65+
const props = properties ? formatProperties(properties) : "";
66+
console.log(`::error${props}::${escapeData(message)}`);
67+
}
68+
69+
export function warning(
70+
message: string,
71+
properties?: AnnotationProperties,
72+
): void {
73+
if (!isGitHubActions()) return;
74+
const props = properties ? formatProperties(properties) : "";
75+
console.log(`::warning${props}::${escapeData(message)}`);
76+
}
77+
78+
export function notice(
79+
message: string,
80+
properties?: AnnotationProperties,
81+
): void {
82+
if (!isGitHubActions()) return;
83+
const props = properties ? formatProperties(properties) : "";
84+
console.log(`::notice${props}::${escapeData(message)}`);
85+
}
86+
87+
// GitHub Actions Log Grouping
88+
export function startGroup(title: string): void {
89+
if (!isGitHubActions()) return;
90+
console.log(`::group::${escapeData(title)}`);
91+
}
92+
93+
export function endGroup(): void {
94+
if (!isGitHubActions()) return;
95+
console.log("::endgroup::");
96+
}
97+
98+
export function withGroup<T>(title: string, fn: () => T): T {
99+
startGroup(title);
100+
try {
101+
return fn();
102+
} finally {
103+
endGroup();
104+
}
105+
}
106+
107+
export async function withGroupAsync<T>(
108+
title: string,
109+
fn: () => Promise<T>,
110+
): Promise<T> {
111+
startGroup(title);
112+
try {
113+
return await fn();
114+
} finally {
115+
endGroup();
116+
}
117+
}
118+
119+
// Legacy group function for backward compatibility and alia
120+
export async function group<T>(
121+
title: string,
122+
fn: () => Promise<T>,
123+
): Promise<T> {
124+
return await withGroupAsync(title, fn);
125+
}
126+
127+
// GitHub API
128+
12129
// A Github Release for a Github Repo
13130

14131
// Look up the latest release for a Github Repo
@@ -26,37 +143,3 @@ export async function getLatestRelease(repo: string): Promise<GitHubRelease> {
26143
return response.json();
27144
}
28145
}
29-
30-
// NB we do not escape these here - it's the caller's responsibility to do so
31-
function githubActionsWorkflowCommand(
32-
command: string,
33-
value = "",
34-
params?: Record<string, string>,
35-
) {
36-
let paramsStr = "";
37-
if (params) {
38-
paramsStr = " ";
39-
let first = false;
40-
for (const [key, val] of Object.entries(params)) {
41-
if (!first) {
42-
first = true;
43-
} else {
44-
paramsStr += ",";
45-
}
46-
paramsStr += `${key}=${val}`;
47-
}
48-
}
49-
return `::${command}${paramsStr}::${value}`;
50-
}
51-
52-
export async function group<T>(title: string, fn: () => Promise<T>) {
53-
if (!runningInCI()) {
54-
return fn();
55-
}
56-
console.log(githubActionsWorkflowCommand("group", title));
57-
try {
58-
return await fn();
59-
} finally {
60-
console.log(githubActionsWorkflowCommand("endgroup"));
61-
}
62-
}

tests/integration/playwright-tests.test.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { fail } from "testing/asserts";
1818
import { isWindows } from "../../src/deno_ral/platform.ts";
1919
import { join } from "../../src/deno_ral/path.ts";
2020
import { existsSync } from "../../src/deno_ral/fs.ts";
21+
import * as gha from "../../src/tools/github.ts";
2122

2223
async function fullInit() {
2324
await initYamlIntelligenceResourcesFromFilesystem();
@@ -86,9 +87,15 @@ Deno.test({
8687
cwd: "integration/playwright",
8788
});
8889
if (!res.success) {
89-
if (Deno.env.get("GITHUB_ACTIONS") && Deno.env.get("GITHUB_REPOSITORY") && Deno.env.get("GITHUB_RUN_ID")) {
90+
if (gha.isGitHubActions() && Deno.env.get("GITHUB_REPOSITORY") && Deno.env.get("GITHUB_RUN_ID")) {
9091
const runUrl = `https://github.com/${Deno.env.get("GITHUB_REPOSITORY")}/actions/runs/${Deno.env.get("GITHUB_RUN_ID")}`;
91-
console.log(`::error file=playwright-tests.test.ts, title=Playwright tests::Some tests failed. Download report uploaded as artifact at ${runUrl}`);
92+
gha.error(
93+
`Some tests failed. Download report uploaded as artifact at ${runUrl}`,
94+
{
95+
file: "playwright-tests.test.ts",
96+
title: "Playwright tests"
97+
}
98+
);
9299
}
93100
fail("Failed tests with playwright. Look at playwright report for more details.")
94101
}

tests/run-tests.ps1

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,14 @@
33
# Determine the path to this script (we'll use this to figure out relative positions of other files)
44
$SOURCE = $MyInvocation.MyCommand.Path
55

6+
# Check if verbose mode is enabled (GitHub Actions debug mode or explicit flag)
7+
$VERBOSE_MODE = $env:RUNNER_DEBUG -eq "1" -or $env:QUARTO_TEST_VERBOSE -eq "true"
8+
69
# ------ Setting all the paths required
710

8-
Write-Host "> Setting all the paths required..."
11+
if ($VERBOSE_MODE) {
12+
Write-Host "> Setting all the paths required..."
13+
}
914

1015
# Tests folder
1116
# e.g quarto-cli/tests folder
@@ -42,8 +47,9 @@ If ( $null -eq $Env:GITHUB_ACTION -and $null -eq $Env:QUARTO_TESTS_NO_CONFIG ) {
4247

4348
# ----- Preparing running tests ------------
4449

45-
46-
Write-Host "> Preparing running tests..."
50+
if ($VERBOSE_MODE) {
51+
Write-Host "> Preparing running tests..."
52+
}
4753

4854
# Exporting some variables with paths as env var required for running quarto
4955
$Env:QUARTO_ROOT = $QUARTO_ROOT
@@ -146,15 +152,21 @@ If ($null -eq $Env:QUARTO_TESTS_FORCE_NO_VENV -and $null -ne $Env:QUARTO_TESTS_F
146152
If ($null -eq $Env:QUARTO_TESTS_FORCE_NO_VENV) {
147153
# Save possible activated virtualenv for later restauration
148154
$OLD_VIRTUAL_ENV=$VIRTUAL_ENV
149-
Write-Host "> Activating virtualenv from .venv for Python tests in Quarto"
155+
if ($VERBOSE_MODE) {
156+
Write-Host "> Activating virtualenv from .venv for Python tests in Quarto"
157+
}
150158
. $(Join-Path $QUARTO_ROOT "tests" ".venv/Scripts/activate.ps1")
151-
Write-Host "> Using Python from " -NoNewline; Write-Host "$((gcm python).Source)" -ForegroundColor Blue;
152-
Write-Host "> VIRTUAL_ENV: " -NoNewline; Write-Host "$($env:VIRTUAL_ENV)" -ForegroundColor Blue;
159+
if ($VERBOSE_MODE) {
160+
Write-Host "> Using Python from " -NoNewline; Write-Host "$((gcm python).Source)" -ForegroundColor Blue;
161+
Write-Host "> VIRTUAL_ENV: " -NoNewline; Write-Host "$($env:VIRTUAL_ENV)" -ForegroundColor Blue;
162+
}
153163
$quarto_venv_activated = $true
154164
}
155165

156166

157-
Write-Host "> Running tests with `"$QUARTO_DENO $DENO_ARGS`" "
167+
if ($VERBOSE_MODE) {
168+
Write-Host "> Running tests with `"$QUARTO_DENO $DENO_ARGS`" "
169+
}
158170

159171
& $QUARTO_DENO $DENO_ARGS
160172

@@ -164,17 +176,25 @@ $DENO_EXIT_CODE = $LASTEXITCODE
164176
# Add Coverage handling
165177

166178
If($quarto_venv_activated) {
167-
Write-Host "> Exiting virtualenv activated for tests"
179+
if ($VERBOSE_MODE) {
180+
Write-Host "> Exiting virtualenv activated for tests"
181+
}
168182
deactivate
169-
Write-Host "> Using Python from " -NoNewline; Write-Host "$((gcm python).Source)" -ForegroundColor Blue;
170-
Write-Host "> VIRTUAL_ENV: " -NoNewline; Write-Host "$($env:VIRTUAL_ENV)" -ForegroundColor Blue;
183+
if ($VERBOSE_MODE) {
184+
Write-Host "> Using Python from " -NoNewline; Write-Host "$((gcm python).Source)" -ForegroundColor Blue;
185+
Write-Host "> VIRTUAL_ENV: " -NoNewline; Write-Host "$($env:VIRTUAL_ENV)" -ForegroundColor Blue;
186+
}
171187
Remove-Variable quarto_venv_activated
172188
}
173189
If($null -ne $OLD_VIRTUAL_ENV) {
174-
Write-Host "> Reactivating original virtualenv"
190+
if ($VERBOSE_MODE) {
191+
Write-Host "> Reactivating original virtualenv"
192+
}
175193
. "$OLD_VIRTUAL_ENV/Scripts/activate.ps1"
176-
Write-Host "> New Python from " -NoNewline; Write-Host "$((gcm python).Source)" -ForegroundColor Blue;
177-
Write-Host "> VIRTUAL_ENV: " -NoNewline; Write-Host "$($env:VIRTUAL_ENV)" -ForegroundColor Blue;
194+
if ($VERBOSE_MODE) {
195+
Write-Host "> New Python from " -NoNewline; Write-Host "$((gcm python).Source)" -ForegroundColor Blue;
196+
Write-Host "> VIRTUAL_ENV: " -NoNewline; Write-Host "$($env:VIRTUAL_ENV)" -ForegroundColor Blue;
197+
}
178198
Remove-Variable OLD_VIRTUAL_ENV
179199
}
180200

0 commit comments

Comments
 (0)