/** * 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; values: Array<[string, string]>; } interface LokiPushRequest { streams: LokiStream[]; } interface LokiReporterOptions { lokiUrl?: string; username?: string; password?: string; batchSize?: number; flushInterval?: number; labels?: Record; } /** * 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; 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, extraLabels: Record = {}) { 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 = { '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; } }