Skip to content

Commit 71b3327

Browse files
authored
test: Add custom reporter for test metrics (#18960)
1 parent ec7eddc commit 71b3327

File tree

8 files changed

+256
-8
lines changed

8 files changed

+256
-8
lines changed

packages/testing/playwright/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"n8n-core": "workspace:*",
3535
"n8n-workflow": "workspace:*",
3636
"nanoid": "catalog:",
37-
"tsx": "catalog:"
37+
"tsx": "catalog:",
38+
"zod": "catalog:"
3839
}
3940
}

packages/testing/playwright/playwright.config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ export default defineConfig({
6969
['html', { open: 'never' }],
7070
['json', { outputFile: 'test-results.json' }],
7171
currentsReporter(currentsConfig),
72+
['./reporters/metrics-reporter.ts'],
7273
]
73-
: [['html']],
74+
: [['html'], ['./reporters/metrics-reporter.ts']],
7475
});
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Metrics Reporter Usage
2+
3+
Automatically collect performance metrics from Playwright tests and send them to a Webhook.
4+
5+
## Setup
6+
7+
```bash
8+
export QA_PERFORMANCE_METRICS_WEBHOOK_URL=https://your-webhook-endpoint.com/metrics
9+
export QA_PERFORMANCE_METRICS_WEBHOOK_USER=username
10+
export QA_PERFORMANCE_METRICS_WEBHOOK_PASSWORD=password
11+
```
12+
13+
## Attach Metrics in Tests
14+
15+
**Option 1: Helper function (recommended)**
16+
```javascript
17+
import { attachMetric } from '../../utils/performance-helper';
18+
19+
await attachMetric(testInfo, 'memory-usage', 1234567, 'bytes');
20+
```
21+
22+
**Option 2: Direct attach**
23+
```javascript
24+
await testInfo.attach('metric:memory-usage', {
25+
body: JSON.stringify({ value: 1234567, unit: 'bytes' })
26+
});
27+
```
28+
29+
## What Gets Sent to BigQuery
30+
31+
```json
32+
{
33+
"test_name": "My performance test",
34+
"metric_name": "memory-usage",
35+
"metric_value": 1234567,
36+
"metric_unit": "bytes",
37+
"git_commit": "abc123...",
38+
"git_branch": "main",
39+
"timestamp": "2025-08-29T..."
40+
}
41+
```
42+
43+
## Data Pipeline
44+
45+
**Playwright Test****n8n Webhook****BigQuery Table**
46+
47+
The n8n workflow that processes the metrics is here:
48+
https://internal.users.n8n.cloud/workflow/zSRjEwfBfCNjGXK8
49+
50+
## BigQuery Schema
51+
52+
```json
53+
{
54+
"fields": [
55+
{"name": "test_name", "type": "STRING", "mode": "REQUIRED"},
56+
{"name": "metric_name", "type": "STRING", "mode": "REQUIRED"},
57+
{"name": "metric_value", "type": "FLOAT", "mode": "REQUIRED"},
58+
{"name": "metric_unit", "type": "STRING", "mode": "REQUIRED"},
59+
{"name": "git_commit", "type": "STRING", "mode": "REQUIRED"},
60+
{"name": "git_branch", "type": "STRING", "mode": "REQUIRED"},
61+
{"name": "timestamp", "type": "TIMESTAMP", "mode": "REQUIRED"}
62+
]
63+
}
64+
```
65+
66+
That's it! Metrics are automatically collected and sent when you attach them to tests.
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import type { Reporter, TestCase, TestResult } from '@playwright/test/reporter';
2+
import { strict as assert } from 'assert';
3+
import { execSync } from 'child_process';
4+
import { z } from 'zod';
5+
6+
const metricDataSchema = z.object({
7+
value: z.number(),
8+
unit: z.string().optional(),
9+
});
10+
11+
interface Metric {
12+
name: string;
13+
value: number;
14+
unit: string | null;
15+
}
16+
17+
interface ReporterOptions {
18+
webhookUrl?: string;
19+
webhookUser?: string;
20+
webhookPassword?: string;
21+
}
22+
23+
/**
24+
* Automatically collect performance metrics from Playwright tests and send them to a Webhook.
25+
* If your test contains a testInfo.attach() call with a name starting with 'metric:', the metric will be collected and sent to the Webhook.
26+
*/
27+
class MetricsReporter implements Reporter {
28+
private webhookUrl: string | undefined;
29+
private webhookUser: string | undefined;
30+
private webhookPassword: string | undefined;
31+
private pendingRequests: Array<Promise<void>> = [];
32+
33+
constructor(options: ReporterOptions = {}) {
34+
this.webhookUrl = options.webhookUrl ?? process.env.QA_PERFORMANCE_METRICS_WEBHOOK_URL;
35+
this.webhookUser = options.webhookUser ?? process.env.QA_PERFORMANCE_METRICS_WEBHOOK_USER;
36+
this.webhookPassword =
37+
options.webhookPassword ?? process.env.QA_PERFORMANCE_METRICS_WEBHOOK_PASSWORD;
38+
}
39+
40+
async onTestEnd(test: TestCase, result: TestResult): Promise<void> {
41+
if (
42+
!this.webhookUrl ||
43+
!this.webhookUser ||
44+
!this.webhookPassword ||
45+
result.status === 'skipped'
46+
) {
47+
return;
48+
}
49+
50+
const metrics = this.collectMetrics(result);
51+
if (metrics.length > 0) {
52+
const sendPromise = this.sendMetrics(test, metrics);
53+
this.pendingRequests.push(sendPromise);
54+
await sendPromise;
55+
}
56+
}
57+
58+
private collectMetrics(result: TestResult): Metric[] {
59+
const metrics: Metric[] = [];
60+
61+
result.attachments.forEach((attachment) => {
62+
if (attachment.name.startsWith('metric:')) {
63+
const metricName = attachment.name.replace('metric:', '');
64+
try {
65+
const parsedData = JSON.parse(attachment.body?.toString() ?? '');
66+
const data = metricDataSchema.parse(parsedData);
67+
metrics.push({
68+
name: metricName,
69+
value: data.value,
70+
unit: data.unit ?? null,
71+
});
72+
} catch (e) {
73+
console.warn(
74+
`[MetricsReporter] Failed to parse metric ${metricName}: ${(e as Error).message}`,
75+
);
76+
}
77+
}
78+
});
79+
80+
return metrics;
81+
}
82+
83+
private async sendMetrics(test: TestCase, metrics: Metric[]): Promise<void> {
84+
const gitInfo = this.getGitInfo();
85+
86+
assert(gitInfo.commit, 'Git commit must be defined');
87+
assert(gitInfo.branch, 'Git branch must be defined');
88+
assert(gitInfo.author, 'Git author must be defined');
89+
90+
const payload = {
91+
test_name: test.title,
92+
git_commit: gitInfo.commit,
93+
git_branch: gitInfo.branch,
94+
git_author: gitInfo.author,
95+
timestamp: new Date().toISOString(),
96+
metrics: metrics.map((metric) => ({
97+
metric_name: metric.name,
98+
metric_value: metric.value,
99+
metric_unit: metric.unit,
100+
})),
101+
};
102+
103+
try {
104+
const auth = Buffer.from(`${this.webhookUser}:${this.webhookPassword}`).toString('base64');
105+
106+
const response = await fetch(this.webhookUrl!, {
107+
method: 'POST',
108+
headers: {
109+
'Content-Type': 'application/json',
110+
Authorization: `Basic ${auth}`,
111+
},
112+
body: JSON.stringify(payload),
113+
signal: AbortSignal.timeout(10000),
114+
});
115+
116+
if (!response.ok) {
117+
console.warn(`[MetricsReporter] Webhook failed (${response.status}): ${test.title}`);
118+
}
119+
} catch (e) {
120+
console.warn(
121+
`[MetricsReporter] Failed to send metrics for test ${test.title}: ${(e as Error).message}`,
122+
);
123+
}
124+
}
125+
126+
async onEnd(): Promise<void> {
127+
if (this.pendingRequests.length > 0) {
128+
await Promise.allSettled(this.pendingRequests);
129+
}
130+
}
131+
132+
private getGitInfo(): { commit: string | null; branch: string | null; author: string | null } {
133+
try {
134+
return {
135+
commit: execSync('git rev-parse HEAD', { encoding: 'utf8' }).trim(),
136+
branch: execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim(),
137+
author: execSync('git log -1 --pretty=format:"%an"', { encoding: 'utf8' }).trim(),
138+
};
139+
} catch (e) {
140+
console.error(`[MetricsReporter] Failed to get Git info: ${(e as Error).message}`);
141+
return { commit: null, branch: null, author: null };
142+
}
143+
}
144+
}
145+
146+
// eslint-disable-next-line import-x/no-default-export
147+
export default MetricsReporter;

packages/testing/playwright/tests/performance/large-node-cloud.spec.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
import { test, expect } from '../../fixtures/cloud';
99
import type { n8nPage } from '../../pages/n8nPage';
10-
import { measurePerformance } from '../../utils/performance-helper';
10+
import { measurePerformance, attachMetric } from '../../utils/performance-helper';
1111

1212
async function setupPerformanceTest(n8n: n8nPage, size: number) {
1313
await n8n.goHome();
@@ -25,7 +25,7 @@ async function setupPerformanceTest(n8n: n8nPage, size: number) {
2525
}
2626

2727
test.describe('Large Node Performance - Cloud Resources', () => {
28-
test('Large workflow with starter plan resources @cloud:starter', async ({ n8n }) => {
28+
test('Large workflow with starter plan resources @cloud:starter', async ({ n8n }, testInfo) => {
2929
await setupPerformanceTest(n8n, 30000);
3030
const loopSize = 20;
3131
const stats = [];
@@ -49,6 +49,10 @@ test.describe('Large Node Performance - Cloud Resources', () => {
4949
}
5050
const average = stats.reduce((a, b) => a + b, 0) / stats.length;
5151
console.log(`Average open node duration: ${average.toFixed(1)}ms`);
52+
53+
// Attach performance metric using helper method
54+
await attachMetric(testInfo, 'open-node-30000', average, 'ms');
55+
5256
expect(average).toBeLessThan(5000);
5357
});
5458
});

packages/testing/playwright/tests/performance/perf-examples.spec.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import { test, expect } from '../../fixtures/base';
22
import type { n8nPage } from '../../pages/n8nPage';
3-
import { getAllPerformanceMetrics, measurePerformance } from '../../utils/performance-helper';
3+
import {
4+
getAllPerformanceMetrics,
5+
measurePerformance,
6+
attachMetric,
7+
} from '../../utils/performance-helper';
48

59
async function setupPerformanceTest(n8n: n8nPage, size: number) {
6-
await n8n.goHome();
7-
await n8n.workflows.clickNewWorkflowCard();
10+
await n8n.start.fromNewProject();
811
await n8n.canvas.importWorkflow('large.json', 'Large Workflow');
912
await n8n.notifications.closeNotificationByText('Successful');
1013

@@ -51,6 +54,8 @@ test.describe('Performance Example: Multiple sets}', () => {
5154
});
5255
});
5356

57+
await attachMetric(test.info(), `trigger-workflow-${size}`, triggerDuration, 'ms');
58+
5459
// Assert trigger performance
5560
expect(triggerDuration).toBeLessThan(budgets.triggerWorkflow);
5661
console.log(
@@ -62,6 +67,9 @@ test.describe('Performance Example: Multiple sets}', () => {
6267
await n8n.canvas.openNode('Code');
6368
});
6469

70+
// Attach performance metric using helper method
71+
await attachMetric(test.info(), `open-large-node-${size}`, openNodeDuration, 'ms');
72+
6573
// Assert node opening performance
6674
expect(openNodeDuration).toBeLessThan(budgets.openLargeNode);
6775
console.log(

packages/testing/playwright/utils/performance-helper.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Page } from '@playwright/test';
1+
import type { Page, TestInfo } from '@playwright/test';
22

33
export async function measurePerformance(
44
page: Page,
@@ -28,3 +28,21 @@ export async function getAllPerformanceMetrics(page: Page) {
2828
return metrics;
2929
});
3030
}
31+
32+
/**
33+
* Attach a performance metric for collection by the metrics reporter
34+
* @param testInfo - The Playwright TestInfo object
35+
* @param metricName - Name of the metric (will be prefixed with 'metric:')
36+
* @param value - The numeric value to track
37+
* @param unit - The unit of measurement (e.g., 'ms', 'bytes', 'count')
38+
*/
39+
export async function attachMetric(
40+
testInfo: TestInfo,
41+
metricName: string,
42+
value: number,
43+
unit?: string,
44+
): Promise<void> {
45+
await testInfo.attach(`metric:${metricName}`, {
46+
body: JSON.stringify({ value, unit }),
47+
});
48+
}

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)