Skip to content

Commit 61cd1bf

Browse files
authored
Merge pull request #19827 from mozilla/add-trace-playwright-dev-urls
feat(tests): Add playwright trace url on failure
2 parents c7e86ae + 8db420c commit 61cd1bf

2 files changed

Lines changed: 189 additions & 30 deletions

File tree

packages/functional-tests/README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,35 @@ There's a `Functional Tests` launch target in the root `.vscode/launch.json`. Se
247247

248248
We record traces for failed tests locally and in CI. On CircleCI they are in the test artifacts. For more read the [Trace Viewer docs](https://playwright.dev/docs/trace-viewer).
249249

250+
#### Trace URLs in CI
251+
252+
When tests fail in CircleCI, the CI reporter automatically generates clickable trace URLs using [fxtrace](https://fxtrace.vercel.app). These URLs appear:
253+
254+
1. **Inline** - Immediately after each test failure
255+
2. **Summary** - At the end of the test run with all failed test traces grouped together
256+
257+
Example output:
258+
```
259+
[26/35] ❌ tests/settings/avatar.spec.ts: open and close avatar drop-down menu (19s)
260+
Error: Timed out 10000ms waiting for expect(locator).toBeVisible()
261+
...
262+
📊 View trace: https://fxtrace.vercel.app/?url=https://output.circle-artifacts.com/output/job/.../trace.zip
263+
```
264+
265+
The summary at the end groups traces by test (including retries):
266+
```
267+
📊 Failed test traces:
268+
tests/settings/avatar.spec.ts: open and close avatar drop-down menu
269+
https://fxtrace.vercel.app/?url=.../trace.zip
270+
https://fxtrace.vercel.app/?url=.../retry1/trace.zip
271+
```
272+
273+
The reporter also tracks:
274+
- **Progress**: `[5/35]` shows completed tests out of total
275+
- **Retries**: Total retry count and which attempt `(retry #1)`
276+
- **Flaky tests**: Tests that failed initially but passed on retry
277+
- **Duration**: Total test suite run time
278+
250279
Sync signin tests start a new browser instance and this causes problems with the recorded trace being blank; the second browsers trace is overwritten.
251280

252281
Here's what's happening with tracing order of operations

packages/functional-tests/lib/ci-reporter.ts

Lines changed: 160 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
44

55
import path from 'node:path';
6+
import { existsSync, readFileSync } from 'fs';
67
import {
78
FullConfig,
89
FullResult,
@@ -13,15 +14,21 @@ import {
1314
TestResult,
1415
} from '@playwright/test/reporter';
1516

17+
const FXTRACE_BASE_URL = 'https://fxtrace.vercel.app';
18+
const CIRCLECI_ARTIFACTS_BASE_URL = 'https://output.circle-artifacts.com/output/job';
19+
20+
const STATUS_ICONS: Record<string, string> = {
21+
passed: '✅',
22+
skipped: '↩️',
23+
timedOut: '⌛️',
24+
failed: '❌',
25+
interrupted: '❌',
26+
};
27+
1628
/**
17-
* Converts milliseconds to human-readable format
18-
* If the time is less than 1 second, it shows milliseconds
19-
* If the time is less than 1 minute, it shows seconds
20-
* And if over 1 minute, it shows minutes and seconds
21-
* @param ms
29+
* Converts milliseconds to human-readable format (e.g., "5s", "2m30s")
2230
*/
23-
const formatTime = (ms: number) => {
24-
// protect against bad input so we don't crash the reporter
31+
const formatTime = (ms: number): string => {
2532
if (ms === undefined || ms === null || isNaN(ms) || ms < 0) {
2633
return 'unknown';
2734
}
@@ -36,62 +43,185 @@ const formatTime = (ms: number) => {
3643
return `${minutes}m${seconds % 60}s`;
3744
};
3845

46+
/**
47+
* Walks up the directory tree looking for a package.json with `"name": "fxa"`.
48+
*/
49+
function findRootPackageJson(startDir: string = __dirname): string {
50+
let currentDir = startDir;
51+
let parentDir = '';
52+
53+
while (currentDir !== parentDir) {
54+
const packageJsonPath = path.join(currentDir, 'package.json');
55+
56+
if (existsSync(packageJsonPath)) {
57+
try {
58+
const json = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
59+
if (json.name === 'fxa') {
60+
return currentDir;
61+
}
62+
} catch {
63+
// Invalid JSON, continue searching
64+
}
65+
}
66+
67+
parentDir = currentDir;
68+
currentDir = path.dirname(currentDir);
69+
}
70+
71+
throw new Error('Could not find root package.json');
72+
}
73+
74+
/**
75+
* Generates an fxtrace URL for a trace file in CircleCI
76+
*/
77+
function generateTraceUrl(tracePath: string): string | null {
78+
if (!process.env.CI || !process.env.CIRCLECI) {
79+
return null;
80+
}
81+
82+
const workflowJobId = process.env.CIRCLE_WORKFLOW_JOB_ID;
83+
const nodeIndex = process.env.CIRCLE_NODE_INDEX || '0';
84+
85+
if (!workflowJobId) {
86+
return null;
87+
}
88+
89+
const projectRoot = findRootPackageJson();
90+
const absolutePath = path.resolve(process.cwd(), tracePath);
91+
const relativePath = path
92+
.relative(projectRoot, absolutePath)
93+
.replace(/\\/g, '/');
94+
95+
const artifactUrl = `${CIRCLECI_ARTIFACTS_BASE_URL}/${workflowJobId}/artifacts/${nodeIndex}/${relativePath}`;
96+
return `${FXTRACE_BASE_URL}/?url=${artifactUrl}`;
97+
}
98+
99+
interface FailedTestTrace {
100+
testTitle: string;
101+
testFile: string;
102+
traceUrls: string[];
103+
}
104+
39105
class CIReporter implements Reporter {
40106
private fixmeCount = 0;
41107
private passCount = 0;
42108
private skipCount = 0;
43109
private total = 0;
110+
private completedCount = 0;
111+
private retryCount = 0;
112+
private flakyTests = new Set<string>();
113+
private failedTestTraces = new Map<string, FailedTestTrace>();
44114

45115
onBegin(config: FullConfig, suite: Suite) {
46116
this.total = suite.allTests().length;
47117
console.log(`Running ${this.total} tests using ${config.workers} workers`);
48118
}
49119

50120
onTestEnd(test: TestCase, result: TestResult) {
51-
let status = '❌';
52-
switch (result.status) {
53-
case 'passed':
54-
status = '✅';
55-
this.passCount++;
56-
break;
57-
case 'skipped':
58-
status = '↩️';
59-
this.skipCount++;
60-
if (test.annotations.some((a) => a.type === 'fixme')) {
61-
this.fixmeCount++;
62-
}
63-
break;
64-
case 'timedOut':
65-
status = '⌛️';
66-
break;
67-
default:
68-
break;
121+
const testFile = path.relative(process.cwd(), test.location.file);
122+
const testKey = `${testFile}:${test.title}`;
123+
const isRetry = result.retry > 0;
124+
125+
if (isRetry) {
126+
this.retryCount++;
127+
} else {
128+
this.completedCount++;
129+
}
130+
131+
const status = STATUS_ICONS[result.status] || '❌';
132+
133+
if (result.status === 'passed') {
134+
this.passCount++;
135+
if (isRetry) {
136+
this.flakyTests.add(testKey);
137+
}
138+
} else if (result.status === 'skipped') {
139+
this.skipCount++;
140+
if (test.annotations.some((a) => a.type === 'fixme')) {
141+
this.fixmeCount++;
142+
}
69143
}
70144

145+
const progress = `[${this.completedCount}/${this.total}]`;
146+
const retryLabel = isRetry ? ` (retry #${result.retry})` : '';
71147
console.log(
72-
`${status} ${path.relative(process.cwd(), test.location.file)}: ${
73-
test.title
74-
} (${formatTime(result.duration)})`
148+
`${progress} ${status} ${testFile}: ${test.title}${retryLabel} (${formatTime(result.duration)})`
75149
);
150+
76151
if (test.outcome() === 'unexpected') {
77152
console.log(result.error?.stack);
78153
console.log(result.error?.message);
79154
}
155+
156+
this.collectTraceUrl(test, result, testKey, testFile);
157+
}
158+
159+
private collectTraceUrl(
160+
test: TestCase,
161+
result: TestResult,
162+
testKey: string,
163+
testFile: string
164+
) {
165+
if (result.status === 'passed') return;
166+
167+
const traceAttachment = result.attachments?.find(
168+
(a) => a.name === 'trace' && a.path
169+
);
170+
if (!traceAttachment?.path) return;
171+
172+
const traceUrl = generateTraceUrl(traceAttachment.path);
173+
if (!traceUrl) return;
174+
175+
console.log(`\n📊 View trace: ${traceUrl}`);
176+
177+
const existing = this.failedTestTraces.get(testKey);
178+
if (existing) {
179+
existing.traceUrls.push(traceUrl);
180+
} else {
181+
this.failedTestTraces.set(testKey, {
182+
testTitle: test.title,
183+
testFile,
184+
traceUrls: [traceUrl],
185+
});
186+
}
80187
}
81188

82189
onEnd(result: FullResult) {
83190
const failCount = this.total - (this.passCount + this.skipCount);
84191

85192
console.log(
86-
`Test suite: ${result.status} (` +
193+
`\nTest suite: ${result.status} (` +
87194
`Passed: ${this.passCount} ` +
88195
`Failed: ${failCount} ` +
89-
`Skipped: ${this.skipCount} (Fixme: ${this.fixmeCount}))`
196+
`Skipped: ${this.skipCount} (Fixme: ${this.fixmeCount}) ` +
197+
`Retries: ${this.retryCount}) ` +
198+
`in ${formatTime(result.duration)}`
90199
);
200+
201+
if (this.flakyTests.size > 0) {
202+
console.log(`\n⚠️ Flaky tests (${this.flakyTests.size}):`);
203+
for (const testKey of this.flakyTests) {
204+
console.log(` ${testKey}`);
205+
}
206+
}
207+
208+
if (this.failedTestTraces.size > 0) {
209+
const traces = this.failedTestTraces;
210+
process.on('exit', () => {
211+
console.log('\n📊 Failed test traces:');
212+
for (const trace of traces.values()) {
213+
console.log(` ${trace.testFile}: ${trace.testTitle}`);
214+
for (const url of trace.traceUrls) {
215+
console.log(` ${url}`);
216+
}
217+
}
218+
});
219+
}
91220
}
92221

93222
onError(error: TestError) {
94223
console.log(error.message);
95224
}
96225
}
226+
97227
export default CIReporter;

0 commit comments

Comments
 (0)