From c37520b9a8a60115d2f7a8bc5c6325fdac24b3dc Mon Sep 17 00:00:00 2001 From: prosdev Date: Thu, 20 Nov 2025 20:04:18 -0800 Subject: [PATCH 1/6] feat(adapters-internal): implement FirestoreAdapter with retry logic - Implement FirestoreAdapter class with ResultAdapter interface - Add retry logic with exponential backoff for transient failures - Support GCP authentication via ADC or Service Account JSON - Write to three collections: test_runs, individual_test_runs, latest_test_cases - Add skip conditions for pull requests - Non-blocking error handling to allow other adapters to continue --- .../src/firestore/FirestoreAdapter.ts | 343 ++++++++++++++++++ .../adapters-internal/src/firestore/index.ts | 2 + 2 files changed, 345 insertions(+) create mode 100644 packages/adapters-internal/src/firestore/FirestoreAdapter.ts create mode 100644 packages/adapters-internal/src/firestore/index.ts diff --git a/packages/adapters-internal/src/firestore/FirestoreAdapter.ts b/packages/adapters-internal/src/firestore/FirestoreAdapter.ts new file mode 100644 index 0000000..c75d028 --- /dev/null +++ b/packages/adapters-internal/src/firestore/FirestoreAdapter.ts @@ -0,0 +1,343 @@ +import { FieldValue, Firestore } from '@google-cloud/firestore'; +import type { CoreTestResult, CoreTestRun, ResultAdapter } from '@lytics/playwright-reporter'; + +/** + * Configuration for the Firestore adapter. + */ +export interface FirestoreAdapterConfig { + /** GCP project ID */ + projectId: string; + /** Service account credentials (JSON string or object). If not provided, uses Application Default Credentials */ + credentials?: string | Record; + /** Collection names for different data types */ + collections?: { + /** Collection for test run summaries (default: 'test_runs') */ + testRuns?: string; + /** Collection for individual test executions (default: 'individual_test_runs') */ + testCases?: string; + /** Collection for latest test case status (default: 'latest_test_cases') */ + latestTestCases?: string; + }; + /** Optional conditions to skip writes */ + skipConditions?: { + /** Skip writes for pull requests (default: false) */ + skipPullRequests?: boolean; + }; + /** Retry configuration */ + retry?: { + /** Maximum number of retries (default: 3) */ + maxRetries?: number; + /** Initial delay in milliseconds (default: 1000) */ + initialDelayMs?: number; + /** Maximum delay in milliseconds (default: 10000) */ + maxDelayMs?: number; + }; +} + +/** + * Adapter that writes test results to Google Cloud Firestore. + * + * Supports writing to multiple collections for different use cases: + * - `test_runs`: Aggregate test run summaries + * - `individual_test_runs`: Individual test execution records + * - `latest_test_cases`: Latest status for each test case (updated on each run) + * + * Features: + * - Automatic retry with exponential backoff for transient failures + * - GCP authentication via Application Default Credentials or Service Account JSON + * - Optional skip conditions (e.g., skip PR runs) + * - Non-blocking error handling (allows other adapters to continue) + * + * @example + * ```typescript + * import { FirestoreAdapter } from '@lytics/playwright-adapters-internal/firestore'; + * + * const adapter = new FirestoreAdapter({ + * projectId: 'my-gcp-project', + * credentials: process.env.GOOGLE_APPLICATION_CREDENTIALS_JSON, + * collections: { + * testRuns: 'test_runs', + * testCases: 'individual_test_runs', + * latestTestCases: 'latest_test_cases', + * }, + * }); + * ``` + */ +export class FirestoreAdapter implements ResultAdapter { + private db: Firestore | null = null; + private shouldSkipFirestoreWrites = false; + private config: { + projectId: string; + credentials?: string | Record; + skipConditions?: FirestoreAdapterConfig['skipConditions']; + retry: Required>; + }; + private collections: { + testRuns: string; + testCases: string; + latestTestCases: string; + }; + + constructor(config: FirestoreAdapterConfig) { + if (!config.projectId) { + throw new Error('FirestoreAdapter: projectId is required'); + } + + this.collections = { + testRuns: config.collections?.testRuns || 'test_runs', + testCases: config.collections?.testCases || 'individual_test_runs', + latestTestCases: config.collections?.latestTestCases || 'latest_test_cases', + }; + + this.config = { + projectId: config.projectId, + credentials: config.credentials, + skipConditions: config.skipConditions, + retry: { + maxRetries: config.retry?.maxRetries ?? 3, + initialDelayMs: config.retry?.initialDelayMs ?? 1000, + maxDelayMs: config.retry?.maxDelayMs ?? 10000, + }, + }; + } + + async initialize(): Promise { + try { + // Parse credentials if provided as string + const credentials = this.config.credentials + ? typeof this.config.credentials === 'string' + ? JSON.parse(this.config.credentials) + : this.config.credentials + : undefined; + + // Initialize Firestore client + // If credentials are not provided, Firestore will use Application Default Credentials + this.db = new Firestore({ + projectId: this.config.projectId, + ...(credentials && { credentials }), + }); + + // Check if we should skip Firestore writes (e.g., for PRs) + const triggerType = process.env.TRIGGER_TYPE; + this.shouldSkipFirestoreWrites = + this.config.skipConditions?.skipPullRequests === true && triggerType === 'pull_request'; + + if (this.shouldSkipFirestoreWrites) { + console.log('ℹ️ [FirestoreAdapter] Firestore writes disabled for pull request trigger'); + } + + // Test connection by getting a collection reference + // This will fail fast if authentication is incorrect + this.db.collection(this.collections.testRuns); + + console.log('✅ [FirestoreAdapter] Firestore client initialized successfully'); + } catch (error) { + console.error('⚠️ [FirestoreAdapter] Failed to initialize Firestore client:', error); + throw new Error( + `FirestoreAdapter: Failed to initialize - ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + async writeTestResult(result: CoreTestResult): Promise { + if (!this.db) { + console.warn('[FirestoreAdapter] DB not initialized, skipping test result write'); + return; + } + + if (this.shouldSkipFirestoreWrites) { + return; // Silently skip for PRs + } + + try { + await this.retryOperation(async () => { + // Write to latest_test_cases collection + const latestDoc = this.db!.collection(this.collections.latestTestCases).doc( + result.testCaseId + ); + + await latestDoc.set( + { + journeyId: result.journeyId, + title: result.title, + testSuiteName: result.annotations.testSuiteName || '', + lastRunResult: result.status, + lastRunTimestamp: FieldValue.serverTimestamp(), + lastRunDurationMs: result.durationMs, + lastRunBuildId: result.buildId, + lastRunReportLink: result.reportLink || null, + lastRunError: result.error ? this.serializeError(result.error) : null, + projectName: result.projectName, + automationStatus: 'done', + }, + { merge: true } + ); + + // Write to individual_test_runs collection + await this.db!.collection(this.collections.testCases).add({ + testCaseId: result.testCaseId, + journeyId: result.journeyId, + title: result.title, + status: result.status, + projectName: result.projectName, + errorMessage: result.error ? this.serializeError(result.error) : null, + testSuiteName: result.annotations.testSuiteName || '', + durationMs: result.durationMs, + runTimestamp: FieldValue.serverTimestamp(), + buildId: result.buildId, + reportLink: result.reportLink || null, + }); + + console.log(`✅ [FirestoreAdapter] Wrote test result: ${result.testCaseId}`); + }); + } catch (error) { + console.error(`❌ [FirestoreAdapter] Error writing test result:`, error); + // Don't throw - allow other adapters to continue + } + } + + async writeTestRun(run: CoreTestRun): Promise { + if (!this.db) { + console.warn('[FirestoreAdapter] DB not initialized, skipping test run write'); + return; + } + + if (this.shouldSkipFirestoreWrites) { + console.log('ℹ️ [FirestoreAdapter] Skipping test run write - triggered by pull request'); + return; + } + + try { + await this.retryOperation(async () => { + await this.db!.collection(this.collections.testRuns).add({ + runId: run.runId, + timestamp: run.timestamp.toISOString(), + overallStatus: run.overallStatus, + totalTests: run.totalTests, + totalExecutions: run.totalExecutions, + passed: run.passed, + failed: run.failed, + skipped: run.skipped, + durationMs: run.durationMs, + passRate: run.passRate, + averageTestDuration: run.averageTestDuration, + slowestTestDuration: run.slowestTestDuration, + flakyTests: run.flakyTests, + ...run.environment, + }); + + console.log(`✅ [FirestoreAdapter] Wrote test run: ${run.runId}`); + }); + } catch (error) { + console.error(`❌ [FirestoreAdapter] Error writing test run:`, error); + // Don't throw - allow other adapters to continue + } + } + + async close(): Promise { + // Firestore client doesn't require explicit cleanup, but we can set it to null + if (this.db) { + // Firestore client handles its own connection cleanup + this.db = null; + } + } + + /** + * Retry an operation with exponential backoff. + * Only retries on transient errors (network issues, rate limits, etc.). + */ + private async retryOperation(operation: () => Promise): Promise { + let lastError: Error | unknown; + let delay = this.config.retry.initialDelayMs; + + for (let attempt = 0; attempt <= this.config.retry.maxRetries; attempt++) { + try { + return await operation(); + } catch (error) { + lastError = error; + + // Don't retry on certain errors (auth, invalid data, etc.) + if (this.isNonRetryableError(error)) { + console.error(`❌ [FirestoreAdapter] Non-retryable error:`, error); + throw error; + } + + // If this was the last attempt, throw the error + if (attempt === this.config.retry.maxRetries) { + console.error( + `❌ [FirestoreAdapter] Operation failed after ${attempt + 1} attempts:`, + error + ); + throw error; + } + + // Wait before retrying with exponential backoff + console.warn( + `⚠️ [FirestoreAdapter] Operation failed (attempt ${attempt + 1}/${this.config.retry.maxRetries + 1}), retrying in ${delay}ms...` + ); + await this.sleep(delay); + + // Exponential backoff: double the delay, but cap at maxDelayMs + delay = Math.min(delay * 2, this.config.retry.maxDelayMs); + } + } + + // This should never be reached, but TypeScript needs it + throw lastError; + } + + /** + * Check if an error is non-retryable (e.g., authentication, invalid data). + */ + private isNonRetryableError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + + const message = error.message.toLowerCase(); + const code = (error as { code?: number }).code; + + // Authentication errors + if (message.includes('permission denied') || message.includes('unauthorized') || code === 7) { + return true; + } + + // Invalid argument errors + if (message.includes('invalid argument') || code === 3) { + return true; + } + + // Not found errors (collection/document doesn't exist is usually a config issue) + if (message.includes('not found') && code === 5) { + return true; + } + + return false; + } + + /** + * Sleep for a given number of milliseconds. + */ + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + /** + * Serialize TestError to a format suitable for Firestore storage. + */ + private serializeError(error: CoreTestResult['error']): Record | null { + if (!error) { + return null; + } + + return { + message: error.message, + matcher: error.matcher, + expected: error.expected, + actual: error.actual, + locator: error.locator, + location: error.location, + snippet: error.snippet, + }; + } +} diff --git a/packages/adapters-internal/src/firestore/index.ts b/packages/adapters-internal/src/firestore/index.ts new file mode 100644 index 0000000..3b3ad4a --- /dev/null +++ b/packages/adapters-internal/src/firestore/index.ts @@ -0,0 +1,2 @@ +export type { FirestoreAdapterConfig } from './FirestoreAdapter'; +export { FirestoreAdapter } from './FirestoreAdapter'; From 7b1978139c517e043d1aecd1201f8e4ba0a1a80b Mon Sep 17 00:00:00 2001 From: prosdev Date: Thu, 20 Nov 2025 20:04:41 -0800 Subject: [PATCH 2/6] test(adapters-internal): add comprehensive tests for FirestoreAdapter - Add 27 tests covering all functionality - Test constructor, initialization, writes, skip conditions - Test retry logic with exponential backoff - Test error handling and non-retryable errors - Mock Firestore client for unit testing --- .../src/firestore/FirestoreAdapter.test.ts | 615 ++++++++++++++++++ 1 file changed, 615 insertions(+) create mode 100644 packages/adapters-internal/src/firestore/FirestoreAdapter.test.ts diff --git a/packages/adapters-internal/src/firestore/FirestoreAdapter.test.ts b/packages/adapters-internal/src/firestore/FirestoreAdapter.test.ts new file mode 100644 index 0000000..42d2b97 --- /dev/null +++ b/packages/adapters-internal/src/firestore/FirestoreAdapter.test.ts @@ -0,0 +1,615 @@ +import type { CoreTestResult, CoreTestRun } from '@lytics/playwright-reporter'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { FirestoreAdapter } from './FirestoreAdapter'; + +// Mock Firestore - define mocks outside the factory +const mockCollection = vi.fn(); +const mockDoc = vi.fn(); +const mockSet = vi.fn(); +const mockAdd = vi.fn(); + +vi.mock('@google-cloud/firestore', () => { + // Create a proper constructor function + const FirestoreConstructor = vi.fn(function Firestore(this: unknown) { + return { + collection: mockCollection, + }; + }); + + return { + Firestore: FirestoreConstructor, + FieldValue: { + serverTimestamp: vi.fn(() => ({ _methodName: 'serverTimestamp' })), + }, + }; +}); + +describe('FirestoreAdapter', () => { + const originalEnv = process.env; + + beforeEach(() => { + vi.clearAllMocks(); + process.env = { ...originalEnv }; + delete process.env.TRIGGER_TYPE; + + // Setup default mocks + mockCollection.mockReturnValue({ + doc: mockDoc, + add: mockAdd, + }); + mockDoc.mockReturnValue({ + set: mockSet, + }); + mockSet.mockResolvedValue(undefined); + mockAdd.mockResolvedValue({ id: 'doc-id' }); + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('constructor', () => { + it('should create adapter with required config', () => { + const adapter = new FirestoreAdapter({ + projectId: 'test-project', + }); + expect(adapter).toBeInstanceOf(FirestoreAdapter); + }); + + it('should throw error if projectId is missing', () => { + expect(() => { + // @ts-expect-error - testing invalid config + new FirestoreAdapter({}); + }).toThrow('FirestoreAdapter: projectId is required'); + }); + + it('should use default collection names', () => { + const adapter = new FirestoreAdapter({ + projectId: 'test-project', + }); + expect(adapter).toBeInstanceOf(FirestoreAdapter); + }); + + it('should accept custom collection names', () => { + const adapter = new FirestoreAdapter({ + projectId: 'test-project', + collections: { + testRuns: 'custom_test_runs', + testCases: 'custom_test_cases', + latestTestCases: 'custom_latest', + }, + }); + expect(adapter).toBeInstanceOf(FirestoreAdapter); + }); + + it('should accept credentials as string', () => { + const adapter = new FirestoreAdapter({ + projectId: 'test-project', + credentials: JSON.stringify({ type: 'service_account' }), + }); + expect(adapter).toBeInstanceOf(FirestoreAdapter); + }); + + it('should accept credentials as object', () => { + const adapter = new FirestoreAdapter({ + projectId: 'test-project', + credentials: { type: 'service_account' }, + }); + expect(adapter).toBeInstanceOf(FirestoreAdapter); + }); + + it('should use default retry configuration', () => { + const adapter = new FirestoreAdapter({ + projectId: 'test-project', + }); + expect(adapter).toBeInstanceOf(FirestoreAdapter); + }); + + it('should accept custom retry configuration', () => { + const adapter = new FirestoreAdapter({ + projectId: 'test-project', + retry: { + maxRetries: 5, + initialDelayMs: 500, + maxDelayMs: 5000, + }, + }); + expect(adapter).toBeInstanceOf(FirestoreAdapter); + }); + }); + + describe('initialize', () => { + it('should initialize Firestore client with projectId', async () => { + const { Firestore } = await import('@google-cloud/firestore'); + const adapter = new FirestoreAdapter({ + projectId: 'test-project', + }); + + await adapter.initialize(); + + expect(vi.mocked(Firestore)).toHaveBeenCalledWith({ + projectId: 'test-project', + }); + }); + + it('should initialize Firestore client with credentials', async () => { + const { Firestore } = await import('@google-cloud/firestore'); + const credentials = { type: 'service_account' }; + const adapter = new FirestoreAdapter({ + projectId: 'test-project', + credentials, + }); + + await adapter.initialize(); + + expect(vi.mocked(Firestore)).toHaveBeenCalledWith({ + projectId: 'test-project', + credentials, + }); + }); + + it('should parse credentials from string', async () => { + const { Firestore } = await import('@google-cloud/firestore'); + const credentials = JSON.stringify({ type: 'service_account' }); + const adapter = new FirestoreAdapter({ + projectId: 'test-project', + credentials, + }); + + await adapter.initialize(); + + expect(vi.mocked(Firestore)).toHaveBeenCalledWith({ + projectId: 'test-project', + credentials: { type: 'service_account' }, + }); + }); + + it('should use Application Default Credentials when credentials not provided', async () => { + const { Firestore } = await import('@google-cloud/firestore'); + const adapter = new FirestoreAdapter({ + projectId: 'test-project', + }); + + await adapter.initialize(); + + expect(vi.mocked(Firestore)).toHaveBeenCalledWith({ + projectId: 'test-project', + }); + }); + + it('should set skip flag for pull requests when configured', async () => { + process.env.TRIGGER_TYPE = 'pull_request'; + const adapter = new FirestoreAdapter({ + projectId: 'test-project', + skipConditions: { + skipPullRequests: true, + }, + }); + + await adapter.initialize(); + + // Should not throw, skip flag should be set + expect(true).toBe(true); + }); + + it('should throw error on initialization failure', async () => { + const { Firestore } = await import('@google-cloud/firestore'); + vi.mocked(Firestore).mockImplementationOnce(() => { + throw new Error('Authentication failed'); + }); + + const adapter = new FirestoreAdapter({ + projectId: 'test-project', + }); + + await expect(adapter.initialize()).rejects.toThrow('FirestoreAdapter: Failed to initialize'); + }); + }); + + describe('writeTestResult', () => { + it('should write test result to latest_test_cases collection', async () => { + const adapter = new FirestoreAdapter({ + projectId: 'test-project', + }); + await adapter.initialize(); + + const testResult: CoreTestResult = { + testCaseId: 'TEST-001', + journeyId: 'JOURNEY-001', + title: 'Test Case 001', + annotations: { + testCaseId: 'TEST-001', + journeyId: 'JOURNEY-001', + testSuiteName: 'SUITE-001', + }, + status: 'passed', + projectName: 'test-project', + durationMs: 1000, + timestamp: new Date('2024-01-01T00:00:00Z'), + buildId: 'build-123', + }; + + await adapter.writeTestResult(testResult); + + expect(mockCollection).toHaveBeenCalledWith('latest_test_cases'); + expect(mockDoc).toHaveBeenCalledWith('TEST-001'); + expect(mockSet).toHaveBeenCalledWith( + expect.objectContaining({ + journeyId: 'JOURNEY-001', + title: 'Test Case 001', + testSuiteName: 'SUITE-001', + }), + { merge: true } + ); + }); + + it('should write test result to individual_test_runs collection', async () => { + const adapter = new FirestoreAdapter({ + projectId: 'test-project', + }); + await adapter.initialize(); + + const testResult: CoreTestResult = { + testCaseId: 'TEST-001', + journeyId: 'JOURNEY-001', + title: 'Test Case 001', + annotations: { + testCaseId: 'TEST-001', + journeyId: 'JOURNEY-001', + testSuiteName: 'SUITE-001', + }, + status: 'passed', + projectName: 'test-project', + durationMs: 1000, + timestamp: new Date('2024-01-01T00:00:00Z'), + buildId: 'build-123', + }; + + await adapter.writeTestResult(testResult); + + expect(mockCollection).toHaveBeenCalledWith('individual_test_runs'); + expect(mockAdd).toHaveBeenCalledWith( + expect.objectContaining({ + testCaseId: 'TEST-001', + journeyId: 'JOURNEY-001', + status: 'passed', + }) + ); + }); + + it('should skip writes when skipPullRequests is enabled', async () => { + process.env.TRIGGER_TYPE = 'pull_request'; + const adapter = new FirestoreAdapter({ + projectId: 'test-project', + skipConditions: { + skipPullRequests: true, + }, + }); + await adapter.initialize(); + + const testResult: CoreTestResult = { + testCaseId: 'TEST-001', + journeyId: 'JOURNEY-001', + title: 'Test Case 001', + annotations: { + testCaseId: 'TEST-001', + journeyId: 'JOURNEY-001', + testSuiteName: 'SUITE-001', + }, + status: 'passed', + projectName: 'test-project', + durationMs: 1000, + timestamp: new Date(), + buildId: 'build-123', + }; + + await adapter.writeTestResult(testResult); + + // Should not call Firestore methods + expect(mockSet).not.toHaveBeenCalled(); + expect(mockAdd).not.toHaveBeenCalled(); + }); + + it('should handle test result with error', async () => { + const adapter = new FirestoreAdapter({ + projectId: 'test-project', + }); + await adapter.initialize(); + + const testResult: CoreTestResult = { + testCaseId: 'TEST-001', + journeyId: 'JOURNEY-001', + title: 'Test Case 001', + annotations: { + testCaseId: 'TEST-001', + journeyId: 'JOURNEY-001', + testSuiteName: 'SUITE-001', + }, + status: 'failed', + projectName: 'test-project', + durationMs: 1000, + timestamp: new Date(), + buildId: 'build-123', + error: { + message: 'Test failed', + matcher: 'toBe', + expected: 'expected', + actual: 'actual', + locator: 'button', + location: { + file: 'test.js', + line: 1, + column: 1, + }, + snippet: [], + }, + }; + + await adapter.writeTestResult(testResult); + + expect(mockSet).toHaveBeenCalledWith( + expect.objectContaining({ + lastRunError: expect.objectContaining({ + message: 'Test failed', + }), + }), + { merge: true } + ); + }); + + it('should not throw on write failure (allows other adapters to continue)', async () => { + mockSet.mockRejectedValueOnce(new Error('Network error')); + const adapter = new FirestoreAdapter({ + projectId: 'test-project', + retry: { + maxRetries: 0, // Disable retries for this test + }, + }); + await adapter.initialize(); + + const testResult: CoreTestResult = { + testCaseId: 'TEST-001', + journeyId: 'JOURNEY-001', + title: 'Test Case 001', + annotations: { + testCaseId: 'TEST-001', + journeyId: 'JOURNEY-001', + testSuiteName: 'SUITE-001', + }, + status: 'passed', + projectName: 'test-project', + durationMs: 1000, + timestamp: new Date(), + buildId: 'build-123', + }; + + // Should not throw, but should log error + await expect(adapter.writeTestResult(testResult)).resolves.not.toThrow(); + }); + + it('should use custom collection names', async () => { + const adapter = new FirestoreAdapter({ + projectId: 'test-project', + collections: { + testRuns: 'custom_runs', + testCases: 'custom_cases', + latestTestCases: 'custom_latest', + }, + }); + await adapter.initialize(); + + const testResult: CoreTestResult = { + testCaseId: 'TEST-001', + journeyId: 'JOURNEY-001', + title: 'Test Case 001', + annotations: { + testCaseId: 'TEST-001', + journeyId: 'JOURNEY-001', + testSuiteName: 'SUITE-001', + }, + status: 'passed', + projectName: 'test-project', + durationMs: 1000, + timestamp: new Date(), + buildId: 'build-123', + }; + + await adapter.writeTestResult(testResult); + + expect(mockCollection).toHaveBeenCalledWith('custom_latest'); + expect(mockCollection).toHaveBeenCalledWith('custom_cases'); + }); + }); + + describe('writeTestRun', () => { + it('should write test run to test_runs collection', async () => { + const adapter = new FirestoreAdapter({ + projectId: 'test-project', + }); + await adapter.initialize(); + + const testRun: CoreTestRun = { + runId: 'run-123', + timestamp: new Date('2024-01-01T00:00:00Z'), + overallStatus: 'passed', + totalTests: 10, + totalExecutions: 10, + passed: 10, + failed: 0, + skipped: 0, + durationMs: 5000, + passRate: 1.0, + averageTestDuration: 500, + slowestTestDuration: 1000, + flakyTests: 0, + environment: { env: 'production' }, + }; + + await adapter.writeTestRun(testRun); + + expect(mockCollection).toHaveBeenCalledWith('test_runs'); + expect(mockAdd).toHaveBeenCalledWith( + expect.objectContaining({ + runId: 'run-123', + overallStatus: 'passed', + totalTests: 10, + passed: 10, + env: 'production', + }) + ); + }); + + it('should skip writes when skipPullRequests is enabled', async () => { + process.env.TRIGGER_TYPE = 'pull_request'; + const adapter = new FirestoreAdapter({ + projectId: 'test-project', + skipConditions: { + skipPullRequests: true, + }, + }); + await adapter.initialize(); + + const testRun: CoreTestRun = { + runId: 'run-123', + timestamp: new Date(), + overallStatus: 'passed', + totalTests: 10, + totalExecutions: 10, + passed: 10, + failed: 0, + skipped: 0, + durationMs: 5000, + passRate: 1.0, + averageTestDuration: 500, + slowestTestDuration: 1000, + flakyTests: 0, + environment: {}, + }; + + await adapter.writeTestRun(testRun); + + // Should not call Firestore methods + expect(mockAdd).not.toHaveBeenCalled(); + }); + + it('should not throw on write failure (allows other adapters to continue)', async () => { + mockAdd.mockRejectedValueOnce(new Error('Network error')); + const adapter = new FirestoreAdapter({ + projectId: 'test-project', + retry: { + maxRetries: 0, // Disable retries for this test + }, + }); + await adapter.initialize(); + + const testRun: CoreTestRun = { + runId: 'run-123', + timestamp: new Date(), + overallStatus: 'passed', + totalTests: 10, + totalExecutions: 10, + passed: 10, + failed: 0, + skipped: 0, + durationMs: 5000, + passRate: 1.0, + averageTestDuration: 500, + slowestTestDuration: 1000, + flakyTests: 0, + environment: {}, + }; + + // Should not throw, but should log error + await expect(adapter.writeTestRun(testRun)).resolves.not.toThrow(); + }); + }); + + describe('close', () => { + it('should complete without error', async () => { + const adapter = new FirestoreAdapter({ + projectId: 'test-project', + }); + await adapter.initialize(); + + await expect(adapter.close()).resolves.not.toThrow(); + }); + }); + + describe('retry logic', () => { + it('should retry on transient failures', async () => { + let attemptCount = 0; + mockSet.mockImplementation(async () => { + attemptCount++; + if (attemptCount < 2) { + throw new Error('Network error'); + } + return undefined; + }); + + const adapter = new FirestoreAdapter({ + projectId: 'test-project', + retry: { + maxRetries: 3, + initialDelayMs: 10, // Short delay for testing + maxDelayMs: 100, + }, + }); + await adapter.initialize(); + + const testResult: CoreTestResult = { + testCaseId: 'TEST-001', + journeyId: 'JOURNEY-001', + title: 'Test Case 001', + annotations: { + testCaseId: 'TEST-001', + journeyId: 'JOURNEY-001', + testSuiteName: 'SUITE-001', + }, + status: 'passed', + projectName: 'test-project', + durationMs: 1000, + timestamp: new Date(), + buildId: 'build-123', + }; + + await adapter.writeTestResult(testResult); + + expect(attemptCount).toBe(2); // Should succeed on second attempt + }); + + it('should not retry on non-retryable errors', async () => { + mockSet.mockRejectedValueOnce(new Error('Permission denied')); + + const adapter = new FirestoreAdapter({ + projectId: 'test-project', + retry: { + maxRetries: 3, + initialDelayMs: 10, + maxDelayMs: 100, + }, + }); + await adapter.initialize(); + + const testResult: CoreTestResult = { + testCaseId: 'TEST-001', + journeyId: 'JOURNEY-001', + title: 'Test Case 001', + annotations: { + testCaseId: 'TEST-001', + journeyId: 'JOURNEY-001', + testSuiteName: 'SUITE-001', + }, + status: 'passed', + projectName: 'test-project', + durationMs: 1000, + timestamp: new Date(), + buildId: 'build-123', + }; + + await adapter.writeTestResult(testResult); + + // Should only attempt once (no retries for auth errors) + expect(mockSet).toHaveBeenCalledTimes(1); + }); + }); +}); From 1c68348ea3dc06265d7ab524f09a51c4ca921f7c Mon Sep 17 00:00:00 2001 From: prosdev Date: Thu, 20 Nov 2025 20:04:54 -0800 Subject: [PATCH 3/6] refactor(adapters-internal): update exports and TypeScript config - Export FirestoreAdapter from main index - Update TypeScript references for annotations and reporter packages - Update index test to verify exports --- packages/adapters-internal/src/index.test.ts | 25 +++------ packages/adapters-internal/src/index.ts | 53 +++++++++++--------- packages/adapters-internal/tsconfig.json | 6 ++- 3 files changed, 40 insertions(+), 44 deletions(-) diff --git a/packages/adapters-internal/src/index.test.ts b/packages/adapters-internal/src/index.test.ts index fd7c905..8bbbe92 100644 --- a/packages/adapters-internal/src/index.test.ts +++ b/packages/adapters-internal/src/index.test.ts @@ -1,20 +1,9 @@ -import { describe, it, expect } from 'vitest'; -import { CoreService, createCoreService } from './index'; +import { describe, expect, it } from 'vitest'; +import { FirestoreAdapter } from './index'; -describe('CoreService', () => { - it('should create a CoreService instance', () => { - const service = new CoreService({ apiKey: 'test-key', debug: false }); - expect(service).toBeInstanceOf(CoreService); +describe('@lytics/playwright-adapters-internal', () => { + it('should export FirestoreAdapter', () => { + expect(FirestoreAdapter).toBeDefined(); + expect(typeof FirestoreAdapter).toBe('function'); }); - - it('should return the API key', () => { - const service = new CoreService({ apiKey: 'test-key', debug: false }); - expect(service.getApiKey()).toBe('test-key'); - }); - - it('should create a CoreService via factory function', () => { - const service = createCoreService({ apiKey: 'factory-key', debug: true }); - expect(service).toBeInstanceOf(CoreService); - expect(service.getApiKey()).toBe('factory-key'); - }); -}); \ No newline at end of file +}); diff --git a/packages/adapters-internal/src/index.ts b/packages/adapters-internal/src/index.ts index 2ea9722..b8293d3 100644 --- a/packages/adapters-internal/src/index.ts +++ b/packages/adapters-internal/src/index.ts @@ -1,26 +1,29 @@ -export interface CoreConfig { - apiKey: string; - debug: boolean; -} +/** + * @lytics/playwright-adapters-internal + * + * Contentstack-specific storage adapters for Playwright reporter. + * + * ## Adapters + * + * - **FirestoreAdapter** - Write test results to Google Cloud Firestore + * + * @example + * ```typescript + * import { CoreReporter } from '@lytics/playwright-reporter'; + * import { FirestoreAdapter } from '@lytics/playwright-adapters-internal/firestore'; + * + * const reporter = new CoreReporter({ + * adapters: [ + * new FirestoreAdapter({ + * projectId: 'my-gcp-project', + * credentials: process.env.GOOGLE_APPLICATION_CREDENTIALS_JSON, + * }) + * ] + * }); + * ``` + * + * @packageDocumentation + */ -export class CoreService { - private config: CoreConfig; - - constructor(config: CoreConfig) { - this.config = config; - } - - initialize(): void { - if (this.config.debug) { - console.log('CoreService initialized with config:', this.config); - } - } - - getApiKey(): string { - return this.config.apiKey; - } -} - -export function createCoreService(config: CoreConfig): CoreService { - return new CoreService(config); -} \ No newline at end of file +export type { FirestoreAdapterConfig } from './firestore'; +export { FirestoreAdapter } from './firestore'; diff --git a/packages/adapters-internal/tsconfig.json b/packages/adapters-internal/tsconfig.json index 8a34969..8229e20 100644 --- a/packages/adapters-internal/tsconfig.json +++ b/packages/adapters-internal/tsconfig.json @@ -5,5 +5,9 @@ "rootDir": "./src" }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "**/*.test.ts"] + "exclude": ["node_modules", "dist", "**/*.test.ts"], + "references": [ + { "path": "../annotations" }, + { "path": "../reporter" } + ] } \ No newline at end of file From 670bcc27fda9d9f7a239642996d975d6d73fa010 Mon Sep 17 00:00:00 2001 From: prosdev Date: Thu, 20 Nov 2025 20:05:07 -0800 Subject: [PATCH 4/6] chore(adapters-internal): update dependencies to published versions - Update @lytics/playwright-annotations to ^0.1.0 - Update @lytics/playwright-reporter to ^0.1.0 - Update package description to reference Contentstack --- packages/adapters-internal/package.json | 6 +++--- pnpm-lock.yaml | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/adapters-internal/package.json b/packages/adapters-internal/package.json index 166c05b..b75b174 100644 --- a/packages/adapters-internal/package.json +++ b/packages/adapters-internal/package.json @@ -1,7 +1,7 @@ { "name": "@lytics/playwright-adapters-internal", "version": "0.1.0", - "description": "Lytics-specific storage adapters (Firestore with Lytics schema)", + "description": "Contentstack-specific storage adapters (Firestore with Contentstack schema)", "private": false, "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -48,8 +48,8 @@ "access": "restricted" }, "dependencies": { - "@lytics/playwright-annotations": "workspace:*", - "@lytics/playwright-reporter": "workspace:*", + "@lytics/playwright-annotations": "^0.1.0", + "@lytics/playwright-reporter": "^0.1.0", "@google-cloud/firestore": "^7.0.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index baa94a9..b9a486a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,10 +67,10 @@ importers: specifier: ^7.0.0 version: 7.11.6 '@lytics/playwright-annotations': - specifier: workspace:* + specifier: ^0.1.0 version: link:../annotations '@lytics/playwright-reporter': - specifier: workspace:* + specifier: ^0.1.0 version: link:../reporter devDependencies: typescript: From 73dad44559d00fa7c4b2a372f295d0a5dd9732d9 Mon Sep 17 00:00:00 2001 From: prosdev Date: Thu, 20 Nov 2025 20:05:21 -0800 Subject: [PATCH 5/6] docs(adapters-internal): add comprehensive README with Contentstack branding - Document FirestoreAdapter usage and configuration - Add authentication setup guide (ADC and Service Account) - Document Firestore schema for all three collections - Add retry logic and error handling documentation - Update branding from Lytics to Contentstack --- packages/adapters-internal/README.md | 255 +++++++++++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 packages/adapters-internal/README.md diff --git a/packages/adapters-internal/README.md b/packages/adapters-internal/README.md new file mode 100644 index 0000000..8ede16b --- /dev/null +++ b/packages/adapters-internal/README.md @@ -0,0 +1,255 @@ +# @lytics/playwright-adapters-internal + +Contentstack-specific storage adapters for the Playwright reporter framework. This package is published to GitHub Packages and is intended for internal use within Contentstack. + +## Installation + +```bash +npm install @lytics/playwright-adapters-internal --registry=https://npm.pkg.github.com +# or +pnpm add @lytics/playwright-adapters-internal --registry=https://npm.pkg.github.com +``` + +**Note:** You must authenticate with GitHub Packages to install this package. See [GitHub Packages documentation](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-npm-registry) for authentication setup. + +## Adapters + +### FirestoreAdapter + +Writes test results to Google Cloud Firestore using the Contentstack schema. Supports automatic retry with exponential backoff for transient failures. + +**Features:** +- Writes to three collections: `test_runs`, `individual_test_runs`, and `latest_test_cases` +- Automatic retry with exponential backoff for transient failures +- GCP authentication via Application Default Credentials (ADC) or Service Account JSON +- Optional skip conditions (e.g., skip PR runs) +- Non-blocking error handling (allows other adapters to continue) + +**Example:** + +```typescript +import { CoreReporter } from '@lytics/playwright-reporter'; +import { FirestoreAdapter } from '@lytics/playwright-adapters-internal/firestore'; + +export default { + reporter: [ + ['list'], + [ + new CoreReporter({ + adapters: [ + new FirestoreAdapter({ + projectId: 'my-gcp-project', + credentials: process.env.GOOGLE_APPLICATION_CREDENTIALS_JSON, + collections: { + testRuns: 'test_runs', + testCases: 'individual_test_runs', + latestTestCases: 'latest_test_cases', + }, + skipConditions: { + skipPullRequests: true, + }, + }), + ], + }), + ], + ], +}; +``` + +**Configuration:** + +```typescript +interface FirestoreAdapterConfig { + /** GCP project ID (required) */ + projectId: string; + /** Service account credentials (JSON string or object). If not provided, uses Application Default Credentials */ + credentials?: string | Record; + /** Collection names for different data types */ + collections?: { + /** Collection for test run summaries (default: 'test_runs') */ + testRuns?: string; + /** Collection for individual test executions (default: 'individual_test_runs') */ + testCases?: string; + /** Collection for latest test case status (default: 'latest_test_cases') */ + latestTestCases?: string; + }; + /** Optional conditions to skip writes */ + skipConditions?: { + /** Skip writes for pull requests (default: false) */ + skipPullRequests?: boolean; + }; + /** Retry configuration */ + retry?: { + /** Maximum number of retries (default: 3) */ + maxRetries?: number; + /** Initial delay in milliseconds (default: 1000) */ + initialDelayMs?: number; + /** Maximum delay in milliseconds (default: 10000) */ + maxDelayMs?: number; + }; +} +``` + +## Authentication + +The FirestoreAdapter supports two authentication methods: + +### 1. Application Default Credentials (ADC) - Recommended + +When `credentials` is not provided, the adapter uses Application Default Credentials. This is the recommended approach for production environments. + +**Setup:** + +```bash +# Set the GOOGLE_APPLICATION_CREDENTIALS environment variable +export GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account-key.json + +# Or use gcloud CLI to authenticate +gcloud auth application-default login +``` + +**Usage:** + +```typescript +new FirestoreAdapter({ + projectId: 'my-gcp-project', + // credentials not provided - uses ADC +}); +``` + +### 2. Service Account JSON + +Provide service account credentials directly as a JSON string or object. + +**Usage:** + +```typescript +// From environment variable (JSON string) +new FirestoreAdapter({ + projectId: 'my-gcp-project', + credentials: process.env.GOOGLE_APPLICATION_CREDENTIALS_JSON, +}); + +// Or as an object +new FirestoreAdapter({ + projectId: 'my-gcp-project', + credentials: { + type: 'service_account', + project_id: 'my-gcp-project', + private_key_id: '...', + private_key: '...', + client_email: '...', + // ... other fields + }, +}); +``` + +## Firestore Schema + +The adapter writes to three collections with the following schemas: + +### `test_runs` Collection + +Test run summaries with aggregate statistics. + +```typescript +{ + runId: string; + timestamp: string; // ISO 8601 + overallStatus: 'passed' | 'failed' | 'timedOut' | 'skipped' | 'cancelled' | 'interrupted' | 'unknown'; + totalTests: number; + totalExecutions: number; + passed: number; + failed: number; + skipped: number; + durationMs: number; + passRate: number; + averageTestDuration: number; + slowestTestDuration: number; + flakyTests: number; + // ... environment variables spread here +} +``` + +### `individual_test_runs` Collection + +Individual test execution records (one document per test execution). + +```typescript +{ + testCaseId: string; + journeyId: string; + title: string; + status: 'passed' | 'failed' | 'timedOut' | 'skipped' | 'cancelled' | 'interrupted' | 'unknown'; + projectName: string; + errorMessage: Record | null; + testSuiteName: string; + durationMs: number; + runTimestamp: Timestamp; // Firestore server timestamp + buildId: string; + reportLink: string | null; +} +``` + +### `latest_test_cases` Collection + +Latest status for each test case (updated on each run, merged). + +```typescript +{ + journeyId: string; + title: string; + testSuiteName: string; + lastRunResult: 'passed' | 'failed' | 'timedOut' | 'skipped' | 'cancelled' | 'interrupted' | 'unknown'; + lastRunTimestamp: Timestamp; // Firestore server timestamp + lastRunDurationMs: number; + lastRunBuildId: string; + lastRunReportLink: string | null; + lastRunError: Record | null; + projectName: string; + automationStatus: 'done'; +} +``` + +**Document ID:** Uses `testCaseId` as the document ID for easy lookups. + +## Retry Logic + +The adapter automatically retries failed operations with exponential backoff. Retries are only attempted for transient errors (network issues, rate limits, etc.). Non-retryable errors (authentication, invalid data) fail immediately. + +**Retryable Errors:** +- Network timeouts +- Rate limit errors +- Temporary service unavailability + +**Non-Retryable Errors:** +- Permission denied (authentication) +- Invalid argument (data validation) +- Not found (configuration issues) + +## Skip Conditions + +The adapter can skip writes based on environment conditions: + +```typescript +// Skip writes for pull requests +new FirestoreAdapter({ + projectId: 'my-gcp-project', + skipConditions: { + skipPullRequests: true, // Skips when TRIGGER_TYPE=pull_request + }, +}); +``` + +## Error Handling + +The adapter uses non-blocking error handling. If a write operation fails after all retries, the error is logged but not thrown. This allows other adapters in the chain to continue processing. + +## Environment Variables + +- `TRIGGER_TYPE`: Used by `skipPullRequests` condition. Set to `'pull_request'` to skip writes. + +## License + +Internal use only - Contentstack. + From d723d2fe7d854d9dd9aff78e07d52a87dfdbf0f5 Mon Sep 17 00:00:00 2001 From: prosdev Date: Thu, 20 Nov 2025 20:21:34 -0800 Subject: [PATCH 6/6] fix(release): configure GitHub Packages authentication for internal packages - Add step to create .npmrc with GitHub Packages auth before changesets runs - Configure both project root and home directory .npmrc files - Add verification output to debug authentication issues - Update README to reference RELEASE_PROCESS.md - Add RELEASE_PROCESS.md documentation for contributors --- .github/workflows/release.yml | 32 +++++++-- README.md | 21 +++--- docs/RELEASE_PROCESS.md | 118 ++++++++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+), 16 deletions(-) create mode 100644 docs/RELEASE_PROCESS.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3d3a0df..54bcb2b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,14 +45,37 @@ jobs: - name: Build Packages run: pnpm build - - name: Create Release Pull Request or Publish to npm + # Configure npm/pnpm for GitHub Packages (for internal packages) + # Create .npmrc with GitHub Packages authentication before changesets runs + - name: Configure GitHub Packages authentication + run: | + # Create .npmrc in project root with GitHub Packages config + # This will be read by pnpm and changesets + cat > .npmrc << EOF + @lytics:registry=https://npm.pkg.github.com + //npm.pkg.github.com/:_authToken=${{ secrets.GITHUB_TOKEN }} + EOF + # Also create in home directory for npm (changesets might create its own, but this ensures auth is available) + mkdir -p ~/.npm + cat > ~/.npmrc << EOF + @lytics:registry=https://npm.pkg.github.com + //npm.pkg.github.com/:_authToken=${{ secrets.GITHUB_TOKEN }} + EOF + # Verify configuration + echo "Project .npmrc:" + cat .npmrc + echo "User .npmrc:" + cat ~/.npmrc + + - name: Create Release Pull Request or Publish to npm/GitHub Packages id: changesets uses: changesets/action@v1 with: # This creates a "Version Packages" PR when changesets are added version: pnpm changeset version - # This publishes to npm when the version PR is merged - # Uses OIDC trusted publishing - no NPM_TOKEN needed! + # This publishes to npm (public packages) or GitHub Packages (internal packages) + # Public packages use OIDC trusted publishing (no NPM_TOKEN needed) + # Internal packages use GITHUB_TOKEN for GitHub Packages via .npmrc publish: pnpm changeset publish # Commit message for version bumps commit: 'chore: release packages' @@ -62,4 +85,5 @@ jobs: createGithubReleases: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # No NPM_TOKEN needed - OIDC handles authentication! \ No newline at end of file + # No NPM_TOKEN needed - OIDC handles npm authentication! + # GITHUB_TOKEN is used via .npmrc for GitHub Packages authentication \ No newline at end of file diff --git a/README.md b/README.md index a888809..f2feab5 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ test.describe("My Feature @smoke", () => { - [Getting Started Guide](./docs/getting-started.md) - [Annotations Guide](./docs/annotations-guide.md) - [Adapters Guide](./docs/adapters-guide.md) +- [Release Process](./docs/RELEASE_PROCESS.md) - [Journey Guide](./docs/journey-guide.md) (Internal) ## 🏗️ Architecture @@ -149,20 +150,16 @@ pnpm typecheck ### Publishing -This monorepo uses [Changesets](https://github.com/changesets/changesets) for version management: +This monorepo uses [Changesets](https://github.com/changesets/changesets) for version management. See [RELEASE_PROCESS.md](./docs/RELEASE_PROCESS.md) for details. -```bash -# 1. Make your changes - -# 2. Create a changeset -pnpm changeset +**Quick overview:** +1. Make your changes +2. Create a changeset: `pnpm changeset` +3. Commit and push +4. CI creates a release PR +5. Merge the PR to publish -# 3. Commit changes -git commit -m "feat: add new feature" - -# 4. CI will create a release PR -# 5. Merge the release PR to publish -``` +Public packages publish to npm, internal packages publish to GitHub Packages automatically. ## 🤝 Contributing diff --git a/docs/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md new file mode 100644 index 0000000..e80718d --- /dev/null +++ b/docs/RELEASE_PROCESS.md @@ -0,0 +1,118 @@ +# Release Process + +This document explains how packages are released in this monorepo. + +## Overview + +This repository uses [Changesets](https://github.com/changesets/changesets) for version management and automated releases. Packages are published to different registries based on their configuration: + +- **Public packages** → Published to [npm](https://www.npmjs.com/) +- **Internal packages** → Published to [GitHub Packages](https://github.com/features/packages) + +## How Releases Work + +### 1. Create a Changeset + +After making changes and merging your PR: + +```bash +pnpm changeset +``` + +Follow the prompts: +- Select which packages changed +- Choose version bump (patch/minor/major) +- Write a summary of changes + +### 2. Commit and Push + +```bash +git add .changeset/ +git commit -m "chore: add changeset" +git push +``` + +### 3. Release PR Created + +The Changesets bot automatically creates a PR titled "chore: release packages" with: +- Updated version numbers +- Updated changelogs +- List of packages to publish + +### 4. Review and Merge + +Review the version changes, then merge the PR. The release workflow will: +- Publish public packages to npm +- Publish internal packages to GitHub Packages +- Create GitHub Releases + +## Package Configuration + +### Public Packages (npm) + +Packages without `publishConfig` are published to npm: + +```json +{ + "name": "@lytics/playwright-annotations", + "publishConfig": { + "access": "public" + } +} +``` + +### Internal Packages (GitHub Packages) + +Packages with `registry` configured are published to GitHub Packages: + +```json +{ + "name": "@lytics/playwright-adapters-internal", + "publishConfig": { + "registry": "https://npm.pkg.github.com", + "access": "restricted" + } +} +``` + +## Installing Internal Packages + +To install packages from GitHub Packages, configure npm authentication: + +### Option 1: Using GITHUB_TOKEN (CI/CD) + +```bash +# In your CI/CD environment +echo "@lytics:registry=https://npm.pkg.github.com" >> .npmrc +echo "//npm.pkg.github.com/:_authToken=$GITHUB_TOKEN" >> .npmrc +``` + +### Option 2: Using Personal Access Token (Local) + +Create `.npmrc` in your project: + +``` +@lytics:registry=https://npm.pkg.github.com +//npm.pkg.github.com/:_authToken=YOUR_PERSONAL_ACCESS_TOKEN +``` + +**Note:** Add `.npmrc` to `.gitignore` if it contains tokens! + +Then install: + +```bash +npm install @lytics/playwright-adapters-internal +``` + +## Versioning Guidelines + +- **Patch** (`0.1.0` → `0.1.1`): Bug fixes, small improvements +- **Minor** (`0.1.0` → `0.2.0`): New features, backward compatible +- **Major** (`0.1.0` → `1.0.0`): Breaking changes + +## Questions? + +- See [Changesets documentation](https://github.com/changesets/changesets) +- Check the [Release workflow](.github/workflows/release.yml) +- Open an [issue](https://github.com/lytics/playwright-core/issues) for questions +