mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 04:31:13 -05:00
268 lines
6.8 KiB
TypeScript
268 lines
6.8 KiB
TypeScript
/**
|
|
* Custom Playwright Reporter for Grafana Loki
|
|
*
|
|
* Streams test events and results to Loki in real-time for centralized logging and monitoring.
|
|
*/
|
|
|
|
import {
|
|
FullConfig,
|
|
FullResult,
|
|
Reporter,
|
|
Suite,
|
|
TestCase,
|
|
TestResult,
|
|
TestStep,
|
|
} from '@playwright/test/reporter';
|
|
|
|
interface LokiStream {
|
|
stream: Record<string, string>;
|
|
values: Array<[string, string]>;
|
|
}
|
|
|
|
interface LokiPushRequest {
|
|
streams: LokiStream[];
|
|
}
|
|
|
|
interface LokiReporterOptions {
|
|
lokiUrl?: string;
|
|
username?: string;
|
|
password?: string;
|
|
batchSize?: number;
|
|
flushInterval?: number;
|
|
labels?: Record<string, string>;
|
|
}
|
|
|
|
/**
|
|
* Custom Playwright reporter that sends logs to Grafana Loki
|
|
*/
|
|
export default class LokiReporter implements Reporter {
|
|
private lokiUrl: string;
|
|
private basicAuth?: string;
|
|
private batchSize: number;
|
|
private flushInterval: number;
|
|
private buffer: LokiStream[] = [];
|
|
private flushTimer?: NodeJS.Timeout;
|
|
private labels: Record<string, string>;
|
|
private testStartTime?: number;
|
|
|
|
constructor(options: LokiReporterOptions = {}) {
|
|
this.lokiUrl = options.lokiUrl || process.env.GRAFANA_LOKI_URL || 'http://localhost:3100';
|
|
this.batchSize = options.batchSize || 10;
|
|
this.flushInterval = options.flushInterval || 5000;
|
|
|
|
// Setup basic auth if credentials provided
|
|
const username = options.username || process.env.GRAFANA_LOKI_USERNAME;
|
|
const password = options.password || process.env.GRAFANA_LOKI_PASSWORD;
|
|
if (username && password) {
|
|
this.basicAuth = Buffer.from(`${username}:${password}`).toString('base64');
|
|
}
|
|
|
|
// Base labels for all logs
|
|
this.labels = {
|
|
job: 'playwright-tests',
|
|
workflow: process.env.GITHUB_WORKFLOW || 'local',
|
|
branch: process.env.GITHUB_REF_NAME || 'local',
|
|
commit: process.env.GITHUB_SHA || 'local',
|
|
run_id: process.env.GITHUB_RUN_ID || 'local',
|
|
...options.labels,
|
|
};
|
|
|
|
// Setup periodic flush
|
|
this.flushTimer = setInterval(() => this.flush(), this.flushInterval);
|
|
}
|
|
|
|
/**
|
|
* Called once before running tests
|
|
*/
|
|
async onBegin(config: FullConfig, suite: Suite) {
|
|
this.testStartTime = Date.now();
|
|
|
|
const testCount = suite.allTests().length;
|
|
await this.log({
|
|
event: 'test_suite_start',
|
|
message: `Starting Playwright test suite with ${testCount} tests`,
|
|
total_tests: testCount,
|
|
workers: config.workers,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Called after a test has been started
|
|
*/
|
|
async onTestBegin(test: TestCase) {
|
|
await this.log({
|
|
event: 'test_start',
|
|
test_name: test.title,
|
|
test_file: this.getRelativePath(test.location.file),
|
|
project: test.parent.project()?.name || 'unknown',
|
|
message: `Test started: ${test.title}`,
|
|
}, {
|
|
browser: test.parent.project()?.name || 'unknown',
|
|
test_file: this.getRelativePath(test.location.file),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Called after a test has been finished
|
|
*/
|
|
async onTestEnd(test: TestCase, result: TestResult) {
|
|
const status = result.status;
|
|
const duration = result.duration;
|
|
const browser = test.parent.project()?.name || 'unknown';
|
|
const testFile = this.getRelativePath(test.location.file);
|
|
|
|
// Determine log message based on status
|
|
let message = `Test ${status}: ${test.title}`;
|
|
if (status === 'failed' || status === 'timedOut') {
|
|
message = `${message} - ${result.error?.message || 'Unknown error'}`;
|
|
}
|
|
|
|
await this.log({
|
|
event: 'test_end',
|
|
test_name: test.title,
|
|
test_file: testFile,
|
|
status,
|
|
duration_ms: duration,
|
|
retry: result.retry,
|
|
message,
|
|
error: status === 'failed' ? result.error?.message : undefined,
|
|
error_stack: status === 'failed' ? result.error?.stack : undefined,
|
|
}, {
|
|
browser,
|
|
test_file: testFile,
|
|
test_name: test.title,
|
|
status,
|
|
});
|
|
|
|
// Log individual test steps for failed tests
|
|
if (status === 'failed') {
|
|
for (const step of result.steps) {
|
|
await this.logStep(test, step, browser, testFile);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Log test step details
|
|
*/
|
|
private async logStep(test: TestCase, step: TestStep, browser: string, testFile: string) {
|
|
await this.log({
|
|
event: 'test_step',
|
|
test_name: test.title,
|
|
step_title: step.title,
|
|
step_category: step.category,
|
|
duration_ms: step.duration,
|
|
error: step.error?.message,
|
|
message: `Step: ${step.title}`,
|
|
}, {
|
|
browser,
|
|
test_file: testFile,
|
|
step_category: step.category,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Called after all tests have been finished
|
|
*/
|
|
async onEnd(result: FullResult) {
|
|
const duration = this.testStartTime ? Date.now() - this.testStartTime : 0;
|
|
|
|
await this.log({
|
|
event: 'test_suite_end',
|
|
status: result.status,
|
|
duration_ms: duration,
|
|
message: `Test suite ${result.status} in ${(duration / 1000).toFixed(2)}s`,
|
|
});
|
|
|
|
// Flush remaining logs
|
|
await this.flush();
|
|
|
|
// Clear flush timer
|
|
if (this.flushTimer) {
|
|
clearInterval(this.flushTimer);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Log a message to Loki
|
|
*/
|
|
private async log(data: Record<string, any>, extraLabels: Record<string, string> = {}) {
|
|
const timestamp = Date.now() * 1000000; // Convert to nanoseconds
|
|
|
|
const stream: LokiStream = {
|
|
stream: {
|
|
...this.labels,
|
|
...extraLabels,
|
|
event: data.event || 'log',
|
|
},
|
|
values: [[timestamp.toString(), JSON.stringify(data)]],
|
|
};
|
|
|
|
this.buffer.push(stream);
|
|
|
|
// Flush if buffer is full
|
|
if (this.buffer.length >= this.batchSize) {
|
|
await this.flush();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Flush buffered logs to Loki
|
|
*/
|
|
private async flush() {
|
|
if (this.buffer.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const payload: LokiPushRequest = {
|
|
streams: this.buffer,
|
|
};
|
|
|
|
this.buffer = [];
|
|
|
|
try {
|
|
const headers: Record<string, string> = {
|
|
'Content-Type': 'application/json',
|
|
};
|
|
|
|
if (this.basicAuth) {
|
|
headers['Authorization'] = `Basic ${this.basicAuth}`;
|
|
}
|
|
|
|
const response = await fetch(`${this.lokiUrl}/loki/api/v1/push`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
console.error(`Failed to send logs to Loki: ${response.status} ${response.statusText}`);
|
|
const errorText = await response.text();
|
|
console.error(`Response: ${errorText}`);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error sending logs to Loki:', error);
|
|
// Re-add to buffer to retry
|
|
this.buffer.push(...payload.streams);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get relative path from project root
|
|
*/
|
|
private getRelativePath(filePath: string): string {
|
|
const cwd = process.cwd();
|
|
if (filePath.startsWith(cwd)) {
|
|
return filePath.substring(cwd.length + 1);
|
|
}
|
|
return filePath;
|
|
}
|
|
|
|
/**
|
|
* Print summary to console
|
|
*/
|
|
printsToStdio() {
|
|
return false;
|
|
}
|
|
}
|