mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 04:51:11 -05:00
307 lines
8.6 KiB
TypeScript
307 lines
8.6 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;
|
|
private maxRetries: number = 3;
|
|
|
|
constructor(options: LokiReporterOptions = {}) {
|
|
this.lokiUrl = options.lokiUrl || process.env.GRAFANA_LOKI_URL || 'http://localhost:3100';
|
|
this.batchSize = options.batchSize || 5;
|
|
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 - sanitize to ensure Grafana Cloud compatibility
|
|
this.labels = this.sanitizeLabels({
|
|
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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sanitize label names to match Grafana Cloud requirements [a-zA-Z_][a-zA-Z0-9_]*
|
|
*/
|
|
private sanitizeLabels(labels: Record<string, string>): Record<string, string> {
|
|
const sanitized: Record<string, string> = {};
|
|
for (const [key, value] of Object.entries(labels)) {
|
|
const sanitizedKey = key.replace(/[^a-zA-Z0-9_]/g, '_').replace(/^[0-9]/, '_$&');
|
|
const sanitizedValue = String(value).replace(/[\n\r\t]/g, ' ');
|
|
sanitized[sanitizedKey] = sanitizedValue;
|
|
}
|
|
return sanitized;
|
|
}
|
|
|
|
/**
|
|
* 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.sanitizeLabels({
|
|
...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 with retry logic for Grafana Cloud
|
|
*/
|
|
private async flush() {
|
|
if (this.buffer.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const payload: LokiPushRequest = {
|
|
streams: this.buffer,
|
|
};
|
|
|
|
this.buffer = [];
|
|
|
|
// Retry with exponential backoff
|
|
for (let attempt = 0; attempt < this.maxRetries; attempt++) {
|
|
try {
|
|
const headers: Record<string, string> = {
|
|
'Content-Type': 'application/json',
|
|
'User-Agent': 'ThrillWiki-Playwright-Tests/1.0',
|
|
};
|
|
|
|
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 || response.status === 204) {
|
|
// Success
|
|
return;
|
|
}
|
|
|
|
if (response.status === 401 || response.status === 403) {
|
|
console.error(`Loki authentication failed: ${response.status}. Check GRAFANA_LOKI_USERNAME and GRAFANA_LOKI_PASSWORD`);
|
|
return; // Don't retry auth errors
|
|
}
|
|
|
|
if (response.status === 429) {
|
|
console.warn(`Loki rate limit hit, retrying... (attempt ${attempt + 1}/${this.maxRetries})`);
|
|
} else {
|
|
console.error(`Failed to send logs to Loki: ${response.status} ${response.statusText}`);
|
|
const errorText = await response.text();
|
|
console.error(`Response: ${errorText}`);
|
|
}
|
|
|
|
// Don't retry on last attempt
|
|
if (attempt < this.maxRetries - 1) {
|
|
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000));
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error sending logs to Loki (attempt ${attempt + 1}/${this.maxRetries}):`, error);
|
|
if (attempt < this.maxRetries - 1) {
|
|
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000));
|
|
} else {
|
|
// Re-add to buffer on final failure
|
|
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;
|
|
}
|
|
}
|