blob: 39d7c92513d9e5dba462338998d2cf5d232a99db [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* Renders the Playwright JSON report as Markdown for the run summary and the PR
* comment. Prints to stdout; the workflow redirects it.
*
* ## What it reports, and why
*
* The first version printed totals and, on failure, the failures. That answers "did it pass"
* and nothing else — a green run said `188 passed` and left no record of *what* passed, so
* a spec that quietly stopped running (renamed, `test.skip`ped, dropped from a project's
* `testMatch`) looked exactly like a spec that ran and passed. Sharding makes that worse:
* a test can now vanish because a shard died before reaching it, and the total is the only
* place that would show it.
*
* So this reports three things beyond the verdict:
*
* - a per-spec-file table, always visible, so a file that produced no tests is obvious;
* - the full per-test list, folded into a <details> block so it does not bury the verdict;
* - the slowest tests, which is what the shard counts should be tuned against.
*
* ## Size
*
* A GitHub PR comment caps at 65536 characters and the API rejects anything longer, so a
* suite large enough to overflow would silently lose its comment entirely — the workflow
* step is `continue-on-error`. `E2E_SUMMARY_MAX_BYTES` bounds the output: sections are
* dropped from the least important upward until it fits, and **what was dropped is always
* stated**. A summary that silently omits half the suite is worse than one that admits it.
*
* Never exits non-zero: this is reporting, and it runs with `if: always()`, so a missing or
* malformed report must not turn a passing run red or mask a real failure with a crash here.
*/
import { readFileSync } from 'node:fs';
import { pathToFileURL } from 'node:url';
const REPORT = process.env.PLAYWRIGHT_JSON_OUTPUT_NAME ?? 'playwright-results.json';
/** Output budget. The default is generous; the PR-comment invocation passes a smaller one. */
const MAX_BYTES = Number(process.env.E2E_SUMMARY_MAX_BYTES ?? 60000);
/** How many tests the folded full listing will name before it truncates. */
const MAX_LISTED = Number(process.env.E2E_SUMMARY_MAX_LISTED ?? 400);
const ICON = {
passed: '✅',
failed: '❌',
timedOut: '⏱️',
skipped: '⏭️',
interrupted: '⚠️',
};
/**
* Strips ANSI escape sequences.
*
* Playwright embeds terminal colour codes in `error.message` — an assertion failure arrives as
* `Error: \x1b[2mexpect(\x1b[22m...`. They are invisible in a terminal and unreadable
* everywhere else, and GitHub renders them literally.
*/
// eslint-disable-next-line no-control-regex -- escape sequences are exactly the target
const ANSI = /\u001B\[[0-9;]*m/g;
/**
* Flattens the suite tree, accumulating `describe` titles.
*
* The root suite's title is the spec's file path, which is already the heading every test is
* rendered under, so including it would print `client.spec.ts › client.spec.ts › creates a
* client`. Ancestry below that is kept: a nested `describe` is context worth having.
*/
function collect(suite, out, titlePath = []) {
const file = suite.file ?? '';
const isFileSuite = !suite.title || suite.title === file || suite.title === suite.location?.file;
const path = isFileSuite ? titlePath : [...titlePath, suite.title];
for (const spec of suite.specs ?? []) {
for (const test of spec.tests ?? []) {
const result = test.results?.[test.results.length - 1];
out.push({
title: [...path, spec.title].join(' › '),
file: spec.file ?? suite.file ?? '',
project: test.projectName ?? '',
status: test.status === 'skipped' ? 'skipped' : (result?.status ?? test.status),
expected: test.expectedStatus,
// A test that failed and then passed on retry is green overall but worth surfacing:
// it is the shape a flake takes, and flakes are what a sharded suite hides best.
retries: Math.max(0, (test.results?.length ?? 1) - 1),
durationMs: result?.duration ?? 0,
error: (result?.error?.message ?? '').replace(ANSI, ''),
});
}
}
for (const child of suite.suites ?? []) collect(child, out, path);
}
/** `1.2s`, or `340ms` below a second — a duration nobody has to convert in their head. */
function humanDuration(ms) {
if (ms < 1000) return `${Math.round(ms)}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
const minutes = Math.floor(ms / 60000);
return `${minutes}m ${Math.round((ms % 60000) / 1000)}s`;
}
/**
* Escapes a value for a Markdown table cell.
*
* Both replacements are load-bearing, and the order they happen in is why this is one pass
* rather than two:
*
* - **Backslash, then pipe.** Escaping only the pipe leaves `a\\|b` — a literal backslash
* followed by an *unescaped* pipe, which ends the cell. Escaping backslashes in a second
* `.replace` would then double the ones this call just added. A single pass over a
* character class avoids both traps.
* - **Whitespace collapse.** A newline in a test title ends the table row, not just the
* cell, so the rest of the table renders as prose.
*
* Reported by CodeQL as an incomplete escaping, which it was.
*/
function cell(text) {
return String(text).replace(/\s+/g, ' ').replace(/[\\|]/g, '\\$&').trim();
}
function firstErrorLine(error) {
const line = error.split('\n').find((l) => l.trim()) ?? '';
return line.replace(/\s+/g, ' ').slice(0, 200);
}
/** Groups tests by spec file, preserving first-seen order so it matches the run. */
function byFile(tests) {
const files = new Map();
for (const test of tests) {
if (!files.has(test.file)) files.set(test.file, []);
files.get(test.file).push(test);
}
return files;
}
function failureSection(failed) {
if (!failed.length) return [];
const lines = ['### ❌ Failures', ''];
for (const test of failed.slice(0, 20)) {
lines.push(`- **${test.title}** — \`${test.file}\``);
const first = firstErrorLine(test.error);
if (first) lines.push(` - ${first}`);
}
if (failed.length > 20) lines.push(`- …and ${failed.length - 20} more, in the HTML report.`);
lines.push('');
return lines;
}
function flakySection(flaky) {
if (!flaky.length) return [];
const lines = [
'### ⚠️ Passed on retry',
'',
'Green overall, but these needed more than one attempt — the shape a flake takes.',
'',
];
for (const test of flaky.slice(0, 15)) {
lines.push(`- **${test.title}** — \`${test.file}\` (${test.retries} retry/retries)`);
}
if (flaky.length > 15) lines.push(`- …and ${flaky.length - 15} more.`);
lines.push('');
return lines;
}
function perFileSection(files) {
const lines = [
'### By spec file',
'',
'| Spec | ✅ | ❌ | ⏭️ | Time |',
'| --- | ---: | ---: | ---: | ---: |',
];
for (const [file, tests] of files) {
const pass = tests.filter((t) => t.status === 'passed').length;
const fail = tests.filter((t) => t.status === 'failed' || t.status === 'timedOut').length;
const skip = tests.filter((t) => t.status === 'skipped').length;
const ms = tests.reduce((total, t) => total + t.durationMs, 0);
// A file whose tests all failed is worth spotting from the table alone.
const name = fail ? `**${cell(file)}**` : cell(file);
lines.push(`| ${name} | ${pass} | ${fail} | ${skip} | ${humanDuration(ms)} |`);
}
lines.push('');
return lines;
}
function fullListingSection(files, total) {
const lines = ['<details>', `<summary><b>All ${total} tests</b> — click to expand</summary>`, ''];
let listed = 0;
let truncated = false;
for (const [file, tests] of files) {
if (listed >= MAX_LISTED) {
truncated = true;
break;
}
lines.push(`**\`${file}\`**`, '');
for (const test of tests) {
if (listed >= MAX_LISTED) {
truncated = true;
break;
}
const icon = ICON[test.status] ?? '•';
const retry = test.retries ? ` _(retried ${test.retries}×)_` : '';
lines.push(`- ${icon} ${test.title} — \`${humanDuration(test.durationMs)}\`${retry}`);
listed += 1;
}
lines.push('');
}
if (truncated) {
lines.push(
`_Listing stopped at ${listed} of ${total} tests. The rest are in the HTML report artifact._`,
'',
);
}
lines.push('</details>', '');
return lines;
}
function slowestSection(tests) {
const slowest = [...tests]
.filter((t) => t.status !== 'skipped')
.sort((a, b) => b.durationMs - a.durationMs)
.slice(0, 10);
if (slowest.length < 2) return [];
const lines = [
'<details>',
'<summary><b>Slowest 10</b> — what the shard counts should be tuned against</summary>',
'',
'| Test | Spec | Time |',
'| --- | --- | ---: |',
];
for (const test of slowest) {
lines.push(
`| ${cell(test.title)} | \`${cell(test.file)}\` | ${humanDuration(test.durationMs)} |`,
);
}
lines.push('', '</details>', '');
return lines;
}
function main() {
let report;
try {
report = JSON.parse(readFileSync(REPORT, 'utf8'));
} catch {
console.log(
'## 🎭 E2E Tests\n\nNo Playwright JSON report was produced. ' +
'That is itself a result: the run did not get far enough to write one.',
);
return;
}
const tests = [];
for (const suite of report.suites ?? []) collect(suite, tests);
if (!tests.length) {
console.log(
'## 🎭 E2E Tests\n\n' +
'**⚠️ The report contains no tests.** A suite that runs nothing reports the same ' +
'"0 failed" as a suite that passes, so this is called out rather than rendered as green.',
);
return;
}
const failed = tests.filter((t) => t.status === 'failed' || t.status === 'timedOut');
const passed = tests.filter((t) => t.status === 'passed' && t.expected !== 'skipped').length;
const skipped = tests.filter((t) => t.status === 'skipped').length;
const flaky = tests.filter((t) => t.status === 'passed' && t.retries > 0);
const seconds = Math.round((report.stats?.duration ?? 0) / 1000);
const files = byFile(tests);
const verdict = failed.length === 0 ? '✅ All green' : `❌ ${failed.length} failing`;
const header = [
'## 🎭 E2E Tests',
'',
`**${verdict}** — ${passed} passed · ${failed.length} failed · ${skipped} skipped` +
`${flaky.length ? ` · ${flaky.length} flaky` : ''}, ` +
`across ${files.size} spec files in ${humanDuration(seconds * 1000)}.`,
'',
];
// Ordered most to least important. Sections are dropped from the end until the whole
// thing fits the budget, and the drop is always announced.
const sections = [
{ name: 'failures', lines: failureSection(failed) },
{ name: 'flaky', lines: flakySection(flaky) },
{ name: 'the per-file table', lines: perFileSection(files) },
{ name: 'the full test listing', lines: fullListingSection(files, tests.length) },
{ name: 'the slowest-10 table', lines: slowestSection(tests) },
];
const render = (kept) =>
[...header, ...kept.flatMap((section) => section.lines)].join('\n').trimEnd();
let kept = sections;
const dropped = [];
while (kept.length > 1 && Buffer.byteLength(render(kept), 'utf8') > MAX_BYTES) {
dropped.unshift(kept[kept.length - 1].name);
kept = kept.slice(0, -1);
}
let output = render(kept);
if (dropped.length) {
output +=
`\n\n_Omitted to fit the comment size limit: ${dropped.join(', ')}. ` +
'All of it is in the HTML report artifact._';
}
console.log(output);
}
export { cell, humanDuration, firstErrorLine };
// Only render when invoked as a command; importing this module (the tests do) must not print.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main();
}