├── .nvmrc ├── src ├── internal │ ├── index.ts │ └── logger.ts ├── recurse │ ├── index.ts │ ├── fixtures.ts │ └── recurse-fixture.ts ├── log │ ├── fixtures.ts │ ├── index.ts │ ├── formatters │ │ └── colors.ts │ ├── utils │ │ ├── async.ts │ │ ├── flag.ts │ │ └── playwright-step-utils.ts │ ├── log-fixture.ts │ ├── outputs │ │ └── log-to-console.ts │ └── log-organizer.ts ├── file-utils │ ├── fixtures.ts │ ├── index.ts │ ├── file-utils-fixture.ts │ └── core │ │ └── file-downloader.ts ├── network-recorder │ ├── fixtures.ts │ ├── index.ts │ ├── core │ │ └── index.ts │ └── network-recorder.ts ├── intercept-network-call │ ├── fixtures.ts │ ├── index.ts │ ├── intercept-network-call-fixture.ts │ └── core │ │ ├── types.ts │ │ ├── utils │ │ └── matches-request.ts │ │ └── observe-network-call.ts ├── burn-in │ ├── index.ts │ ├── core │ │ ├── types.ts │ │ └── config.ts │ └── runner.ts ├── api-request │ ├── fixtures.ts │ ├── schema-validation │ │ ├── index.ts │ │ ├── internal │ │ │ └── promise-extension.ts │ │ ├── fixture.ts │ │ └── core.ts │ ├── index.ts │ └── api-request-fixture.ts ├── index.ts └── auth-session │ ├── global-setup-helper.ts │ ├── index.ts │ └── apply-user-cookies-to-browser-context.ts ├── .npmrc ├── .eslintignore ├── sample-app ├── frontend │ ├── src │ │ ├── vite-env.d.ts │ │ ├── components │ │ │ ├── movie-details │ │ │ │ ├── index.ts │ │ │ │ ├── movie-manager.tsx │ │ │ │ ├── movie-manager.vitest.tsx │ │ │ │ ├── movie-edit-form.vitest.tsx │ │ │ │ ├── movie-details.tsx │ │ │ │ └── movie-edit-form.tsx │ │ │ ├── loading-message.tsx │ │ │ ├── movie-form │ │ │ │ ├── index.ts │ │ │ │ ├── movie-input.tsx │ │ │ │ ├── movie-input.vitest.tsx │ │ │ │ └── movie-form.tsx │ │ │ ├── movie-item │ │ │ │ ├── index.ts │ │ │ │ ├── movie-info.tsx │ │ │ │ ├── movie-info.vitest.tsx │ │ │ │ ├── movie-item.vitest.tsx │ │ │ │ └── movie-item.tsx │ │ │ ├── error-component.tsx │ │ │ ├── loading-message.vitest.tsx │ │ │ ├── error-component.vitest.tsx │ │ │ ├── validation-error-display.tsx │ │ │ ├── file-download │ │ │ │ └── file-download.vitest.tsx │ │ │ ├── movie-list.tsx │ │ │ ├── header │ │ │ │ └── user-header.vitest.tsx │ │ │ ├── login │ │ │ │ ├── login-form-err.vitest.tsx │ │ │ │ └── login-form.vitest.tsx │ │ │ ├── validation-error-display.vitest.tsx │ │ │ └── movie-list.vitest.tsx │ │ ├── test-utils │ │ │ ├── vitest-utils │ │ │ │ ├── msw-setup.ts │ │ │ │ ├── vitest.setup.ts │ │ │ │ ├── auth-handlers.ts │ │ │ │ └── utils.tsx │ │ │ └── factories.ts │ │ ├── main.tsx │ │ ├── hooks │ │ │ ├── use-movie-detail.ts │ │ │ ├── use-movie-form.ts │ │ │ └── use-movie-edit-form.ts │ │ ├── styles │ │ │ └── styled-components.ts │ │ ├── App.tsx │ │ ├── favicon.svg │ │ └── logo.svg │ ├── .env │ ├── public │ │ ├── files │ │ │ ├── 2024636.pdf │ │ │ ├── simple.pdf │ │ │ ├── cases_export.zip │ │ │ └── seon_transactions_export_1750243320497.xlsx │ │ └── vite.svg │ ├── index.html │ ├── vitest.browser.config.ts │ ├── vitest.headless.config.ts │ ├── vitest.config.ts │ ├── vite.config.ts │ └── package.json ├── backend │ ├── test-results │ │ └── .last-run.json │ ├── prisma │ │ ├── dev.db │ │ ├── migrations │ │ │ ├── migration_lock.toml │ │ │ └── 20240903112501_initial_setup │ │ │ │ └── migration.sql │ │ ├── schema.prisma │ │ └── client.ts │ ├── nodemon.json │ ├── src │ │ ├── server.ts │ │ ├── middleware │ │ │ ├── validate-movie-id.ts │ │ │ ├── user-identifier-middleware.ts │ │ │ └── validate-movie-id.test.ts │ │ ├── api-docs │ │ │ └── openapi-writer.ts │ │ ├── events │ │ │ ├── log-file-path.ts │ │ │ └── movie-events.test.ts │ │ ├── utils │ │ │ └── format-response.ts │ │ └── movie-repository.ts │ ├── scripts │ │ ├── global-setup.ts │ │ ├── global-teardown.ts │ │ ├── setup-after-env.ts │ │ ├── env-setup.sh │ │ └── truncate-tables.ts │ ├── .env │ ├── jest.config.ts │ └── package.json └── shared │ ├── types │ ├── index.ts │ ├── movie-event-types.ts │ └── movie-types.ts │ └── user-factory.ts ├── har-files ├── auto-fallback │ └── network-traffic-should-automatically-switch-to-record-mode-when-ha.har.lock ├── network-recorder-tests │ └── recording-functionality │ │ ├── filtered-should-respect-url-filter-during-recording.har.lock │ │ ├── different-test-should-generate-unique-har-files-per-test.har.lock │ │ ├── network-traffic-should-generate-unique-har-files-per-test.har.lock │ │ ├── network-traffic-should-provide-accurate-status-and-stats.har.lock │ │ └── test-recording-should-create-har-file-with-correct-structure-duri.har.lock └── movie-crud-e2e-network-record-playback │ └── network-traffic-should-add-edit-and-delete-a-movie-using-only-brow.har.lock ├── playwright ├── har-files │ └── movie-crud-e2e-network-record-playback │ │ └── network-traffic-should-add-edit-and-delete-a-movie-using-only-brow.har.lock ├── support │ ├── auth │ │ ├── get-environment.ts │ │ ├── get-base-url.ts │ │ ├── get-user-identifier.ts │ │ ├── auth-fixture.ts │ │ └── token │ │ │ ├── is-expired.ts │ │ │ ├── extract.ts │ │ │ └── check-validity.ts │ ├── ui-helpers │ │ ├── add-movie.ts │ │ └── edit-movie.ts │ ├── utils │ │ ├── run-command.ts │ │ ├── movie-factories.ts │ │ └── parse-kafka-event.ts │ ├── merged-fixtures.ts │ └── global-setup.ts ├── tests │ ├── sample-app │ │ └── frontend │ │ │ ├── user-login-non-default-user-identifier.spec.ts │ │ │ ├── user-redirect.spec.ts │ │ │ ├── user-login.spec.ts │ │ │ ├── user-login-multi-user-identifiers.spec.ts │ │ │ ├── movie-crud-e2e-network-record-playback.spec.ts │ │ │ ├── movie-routes-helper-version.spec.ts │ │ │ └── movie-routes.spec.ts │ ├── auth-session │ │ ├── auth-session-ui-sanity.spec.ts │ │ └── auth-session-sanity.spec.ts │ ├── network-record-playback │ │ ├── playback-functionality.spec.ts │ │ └── auto-fallback.spec.ts │ ├── network-error-monitor │ │ └── basic-detection.spec.ts │ └── external │ │ └── network-mock-original.spec.ts ├── scripts │ └── burn-in-changed.ts └── config │ ├── .burn-in.config.ts │ ├── base.config.ts │ └── local.config.ts ├── .gitmodules ├── .prettierrc ├── .vscode └── settings.json ├── .gitleaksignore ├── vite-env.d.ts ├── .gitleaks.toml ├── tsconfig-cjs.json ├── tsconfig-esm.json ├── tsconfig-build-types.json ├── .gitignore ├── .github ├── workflows │ ├── gitleaks-check.yml │ ├── vitest-ct.yml │ └── pr-checks.yml ├── dependabot.yml └── actions │ ├── install │ └── action.yml │ └── setup-kafka │ └── action.yml ├── playwright.config.ts ├── setup-playwright-browsers └── action.yml ├── LICENSE ├── CHANGELOG.md ├── .eslintrc.js ├── .codeiumignore ├── AGENTS.md └── test.http /.nvmrc: -------------------------------------------------------------------------------- 1 | 22 -------------------------------------------------------------------------------- /src/internal/index.ts: -------------------------------------------------------------------------------- 1 | export * from './logger' 2 | -------------------------------------------------------------------------------- /src/recurse/index.ts: -------------------------------------------------------------------------------- 1 | export * from './recurse' 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | @seontechnologies:registry=https://registry.npmjs.org -------------------------------------------------------------------------------- /src/log/fixtures.ts: -------------------------------------------------------------------------------- 1 | export { test } from './log-fixture' 2 | -------------------------------------------------------------------------------- /src/recurse/fixtures.ts: -------------------------------------------------------------------------------- 1 | export { test } from './recurse-fixture' 2 | -------------------------------------------------------------------------------- /src/file-utils/fixtures.ts: -------------------------------------------------------------------------------- 1 | export { test } from './file-utils-fixture' 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .eslintrc.js 2 | wallaby.js 3 | playwright-report 4 | test-results -------------------------------------------------------------------------------- /sample-app/frontend/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/network-recorder/fixtures.ts: -------------------------------------------------------------------------------- 1 | export { test } from './network-recorder-fixture' 2 | -------------------------------------------------------------------------------- /src/intercept-network-call/fixtures.ts: -------------------------------------------------------------------------------- 1 | export { test } from './intercept-network-call-fixture' 2 | -------------------------------------------------------------------------------- /har-files/auto-fallback/network-traffic-should-automatically-switch-to-record-mode-when-ha.har.lock: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /sample-app/backend/test-results/.last-run.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": "failed", 3 | "failedTests": [] 4 | } 5 | -------------------------------------------------------------------------------- /har-files/network-recorder-tests/recording-functionality/filtered-should-respect-url-filter-during-recording.har.lock: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /sample-app/frontend/.env: -------------------------------------------------------------------------------- 1 | VITE_PORT=3000 2 | API_PORT=3001 # only for mockoon 3 | VITE_API_URL=http://localhost:3001 -------------------------------------------------------------------------------- /sample-app/shared/types/index.ts: -------------------------------------------------------------------------------- 1 | export type * from './movie-types' 2 | export type * from './movie-event-types' 3 | -------------------------------------------------------------------------------- /har-files/network-recorder-tests/recording-functionality/different-test-should-generate-unique-har-files-per-test.har.lock: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /har-files/network-recorder-tests/recording-functionality/network-traffic-should-generate-unique-har-files-per-test.har.lock: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /har-files/network-recorder-tests/recording-functionality/network-traffic-should-provide-accurate-status-and-stats.har.lock: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /har-files/movie-crud-e2e-network-record-playback/network-traffic-should-add-edit-and-delete-a-movie-using-only-brow.har.lock: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /har-files/network-recorder-tests/recording-functionality/test-recording-should-create-har-file-with-correct-structure-duri.har.lock: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /sample-app/backend/prisma/dev.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seontechnologies/playwright-utils/HEAD/sample-app/backend/prisma/dev.db -------------------------------------------------------------------------------- /playwright/har-files/movie-crud-e2e-network-record-playback/network-traffic-should-add-edit-and-delete-a-movie-using-only-brow.har.lock: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /sample-app/frontend/src/components/movie-details/index.ts: -------------------------------------------------------------------------------- 1 | import MovieDetails from './movie-details' 2 | export default MovieDetails 3 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | 2 | [submodule "bmad-submodule"] 3 | path = bmad-submodule 4 | url = https://github.com/muratkeremozcan/bmad-submodule.git 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "trailingComma": "none", 4 | "tabWidth": 2, 5 | "semi": false, 6 | "singleQuote": true 7 | } 8 | -------------------------------------------------------------------------------- /sample-app/frontend/public/files/2024636.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seontechnologies/playwright-utils/HEAD/sample-app/frontend/public/files/2024636.pdf -------------------------------------------------------------------------------- /sample-app/frontend/public/files/simple.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seontechnologies/playwright-utils/HEAD/sample-app/frontend/public/files/simple.pdf -------------------------------------------------------------------------------- /sample-app/frontend/public/files/cases_export.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seontechnologies/playwright-utils/HEAD/sample-app/frontend/public/files/cases_export.zip -------------------------------------------------------------------------------- /src/burn-in/index.ts: -------------------------------------------------------------------------------- 1 | // Main API - what users actually need 2 | export { runBurnIn } from './runner' 3 | export type { BurnInConfig, BurnInOptions } from './core/types' 4 | -------------------------------------------------------------------------------- /sample-app/frontend/src/components/loading-message.tsx: -------------------------------------------------------------------------------- 1 | export default function LoadingMessage() { 2 | return

Loading movies...

3 | } 4 | -------------------------------------------------------------------------------- /sample-app/backend/prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "sqlite" -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deepscan.enable": true, 3 | "editor.formatOnSave": true, 4 | "editor.formatOnPaste": true, 5 | "editor.defaultFormatter": "esbenp.prettier-vscode" 6 | } 7 | -------------------------------------------------------------------------------- /sample-app/frontend/src/components/movie-form/index.ts: -------------------------------------------------------------------------------- 1 | import MovieForm from './movie-form' 2 | import MovieInput from './movie-input' 3 | export { MovieInput } 4 | export default MovieForm 5 | -------------------------------------------------------------------------------- /src/network-recorder/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Network recorder module - exports for direct usage and fixtures 3 | */ 4 | 5 | export * from './network-recorder' 6 | export * from './fixtures' 7 | -------------------------------------------------------------------------------- /sample-app/frontend/src/components/movie-item/index.ts: -------------------------------------------------------------------------------- 1 | import MovieItem from './movie-item' 2 | import MovieInfo from './movie-info' 3 | 4 | export { MovieInfo } 5 | export default MovieItem 6 | -------------------------------------------------------------------------------- /.gitleaksignore: -------------------------------------------------------------------------------- 1 | # Ignore documentation files with example code 2 | README.md 3 | 4 | # Ignore test fixtures and sample data 5 | **/fixtures/** 6 | **/test-data/** 7 | **/*.spec.ts 8 | **/*.test.ts 9 | -------------------------------------------------------------------------------- /sample-app/backend/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["src"], 3 | "ext": "ts,js,json", 4 | "ignore": ["node_modules", "src/**/*.test.ts", "dist", "coverage"], 5 | "exec": "tsx src/server.ts" 6 | } 7 | -------------------------------------------------------------------------------- /sample-app/backend/src/server.ts: -------------------------------------------------------------------------------- 1 | import { server } from './server-config' 2 | 3 | const port = process.env.PORT || 3001 4 | 5 | server.listen(port, () => console.log(`Listening on port ${port}...`)) 6 | -------------------------------------------------------------------------------- /sample-app/frontend/public/files/seon_transactions_export_1750243320497.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seontechnologies/playwright-utils/HEAD/sample-app/frontend/public/files/seon_transactions_export_1750243320497.xlsx -------------------------------------------------------------------------------- /vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | readonly VITE_PORT: string 5 | readonly VITE_API_URL: string 6 | } 7 | 8 | interface ImportMeta { 9 | readonly env: ImportMetaEnv 10 | } 11 | -------------------------------------------------------------------------------- /sample-app/backend/prisma/migrations/20240903112501_initial_setup/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Movie" ( 3 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 4 | "name" TEXT NOT NULL, 5 | "year" INTEGER NOT NULL 6 | ); 7 | -------------------------------------------------------------------------------- /sample-app/frontend/src/components/error-component.tsx: -------------------------------------------------------------------------------- 1 | export default function ErrorComp() { 2 | return ( 3 | <> 4 |

Something went wrong!

5 |

Try reloading the page.

6 | 7 | ) 8 | } 9 | -------------------------------------------------------------------------------- /sample-app/backend/scripts/global-setup.ts: -------------------------------------------------------------------------------- 1 | import { truncateTables } from './truncate-tables' 2 | 3 | export default async function globalSetup() { 4 | console.log('Running global setup once before everything...') 5 | await truncateTables() 6 | } 7 | -------------------------------------------------------------------------------- /sample-app/backend/scripts/global-teardown.ts: -------------------------------------------------------------------------------- 1 | import { truncateTables } from './truncate-tables' 2 | 3 | export default async function globalTeardown(): Promise { 4 | console.log('Running global teardown once after everything...') 5 | await truncateTables() 6 | } 7 | -------------------------------------------------------------------------------- /src/log/index.ts: -------------------------------------------------------------------------------- 1 | // Export the main log object 2 | export { log } from './log' 3 | 4 | // decorator and function wrapper 5 | export { methodTestStep, functionTestStep } from './test-step' 6 | 7 | // re-export the test hooks and utilities for use in test files 8 | export { captureTestContext } from './log-organizer' 9 | -------------------------------------------------------------------------------- /sample-app/frontend/src/test-utils/vitest-utils/msw-setup.ts: -------------------------------------------------------------------------------- 1 | import { setupWorker } from 'msw/browser' 2 | import { http } from 'msw' 3 | import { authHandlers } from './auth-handlers' 4 | 5 | // Create worker with default handlers for auth endpoints 6 | export const worker = setupWorker(...authHandlers) 7 | 8 | export { http } 9 | -------------------------------------------------------------------------------- /sample-app/shared/types/movie-event-types.ts: -------------------------------------------------------------------------------- 1 | export type MovieAction = 'created' | 'updated' | 'deleted' 2 | 3 | type Event = { 4 | topic: `movie-${T}` 5 | messages: Array<{ 6 | key: string // id as a string 7 | value: string // serialized movie object 8 | }> 9 | } 10 | 11 | export type MovieEvent = Event 12 | -------------------------------------------------------------------------------- /src/file-utils/index.ts: -------------------------------------------------------------------------------- 1 | export type * from './core/types' 2 | export { handleDownload } from './core/file-downloader' 3 | export type { DownloadOptions } from './core/file-downloader' 4 | export * from './core/csv-reader' 5 | export * from './core/xlsx-reader' 6 | export * from './core/pdf-reader' 7 | export * from './core/zip-reader' 8 | export * from './fixtures' 9 | -------------------------------------------------------------------------------- /src/log/formatters/colors.ts: -------------------------------------------------------------------------------- 1 | /** Color utilities for terminal output */ 2 | 3 | /** ANSI color codes for terminal output */ 4 | export const colors = { 5 | reset: '\x1b[0m', 6 | cyan: '\x1b[36m', 7 | green: '\x1b[32m', 8 | yellow: '\x1b[33m', 9 | red: '\x1b[31m', 10 | gray: '\x1b[90m', 11 | white: '\x1b[37m', 12 | bold: '\x1b[1m' 13 | } 14 | -------------------------------------------------------------------------------- /.gitleaks.toml: -------------------------------------------------------------------------------- 1 | title = "gitleaks config" 2 | 3 | [allowlist] 4 | description = "Allowlist for documentation and test files" 5 | paths = [ 6 | '''README\.md''', 7 | '''.*\.md$''', 8 | '''docs/.*''', 9 | '''.*\.spec\.ts$''', 10 | '''.*\.test\.ts$''', 11 | '''.*/fixtures/.*''', 12 | '''.*/test-data/.*''', 13 | '''package-lock\.json$''', 14 | '''dist/.*''', 15 | '''coverage/.*''' 16 | ] 17 | -------------------------------------------------------------------------------- /tsconfig-cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src"], 4 | "exclude": ["jest.config.*s", "**/*.test.ts"], 5 | "compilerOptions": { 6 | "declaration": true, 7 | "declarationMap": true, 8 | "sourceMap": true, 9 | "module": "CommonJS", 10 | "moduleResolution": "Node", 11 | "noEmit": false, 12 | "outDir": "dist/cjs", 13 | "target": "es2020" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /playwright/support/auth/get-environment.ts: -------------------------------------------------------------------------------- 1 | import { type AuthOptions } from 'src/auth-session' 2 | 3 | export function getEnvironment(options: AuthOptions = {}) { 4 | // Environment priority: 5 | // 1. Options passed from test via auth.useEnvironment({ environment: 'staging' }) 6 | // 2. Environment variables 7 | // 3. Default environment 8 | return options.environment || process.env.TEST_ENV || 'local' 9 | } 10 | -------------------------------------------------------------------------------- /src/log/utils/async.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wraps a synchronous function in a Promise and resolves after I/O processing 3 | * Used for asynchronous console logging 4 | */ 5 | export const asPromise = (fn: () => void): Promise => 6 | new Promise((resolve) => { 7 | fn() 8 | // Use setImmediate to ensure I/O processing completes 9 | // before the promise resolves 10 | setImmediate(() => resolve()) 11 | }) 12 | -------------------------------------------------------------------------------- /tsconfig-esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src"], 4 | "exclude": ["jest.config.*s", "**/*.test.ts"], 5 | "compilerOptions": { 6 | "declaration": true, 7 | "declarationMap": true, 8 | "sourceMap": true, 9 | "module": "NodeNext", 10 | "moduleResolution": "NodeNext", 11 | "noEmit": false, 12 | "outDir": "dist/esm", 13 | "target": "es2020" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig-build-types.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src"], 4 | "exclude": ["jest.config.*s", "**/*.test.ts"], 5 | "compilerOptions": { 6 | "declaration": true, 7 | "declarationMap": true, 8 | "sourceMap": true, 9 | "module": "NodeNext", 10 | "moduleResolution": "NodeNext", 11 | "noEmit": false, 12 | "outDir": "dist", 13 | "target": "es2020" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /sample-app/frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite App 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /sample-app/frontend/src/components/loading-message.vitest.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | describe, 3 | expect, 4 | it, 5 | screen, 6 | wrappedRender 7 | } from '@vitest-utils/utils' 8 | import LoadingMessage from './loading-message' 9 | 10 | describe('', () => { 11 | it('should render a loading message', () => { 12 | wrappedRender() 13 | 14 | expect(screen.getByTestId('loading-message-comp')).toBeVisible() 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /sample-app/frontend/vitest.browser.config.ts: -------------------------------------------------------------------------------- 1 | import baseConfig from './vitest.config' 2 | import type { UserConfigExport } from 'vitest/config' 3 | import { defineConfig } from 'vitest/config' 4 | import merge from 'lodash/merge' 5 | 6 | const browserConfig: UserConfigExport = { 7 | test: { 8 | browser: { 9 | headless: false, 10 | name: 'chromium' 11 | } 12 | } 13 | } 14 | 15 | export default defineConfig(merge({}, baseConfig, browserConfig)) 16 | -------------------------------------------------------------------------------- /src/api-request/fixtures.ts: -------------------------------------------------------------------------------- 1 | // Export only the fixtures 2 | // Export the test object directly to match import pattern: 3 | // import { test as apiRequestFixture } from 'playwright-utils/api-request/fixtures'; 4 | export { test } from './api-request-fixture' 5 | 6 | // Also export the validateSchema fixture 7 | // import { test as validateSchemaFixture } from 'playwright-utils/api-request/fixtures'; 8 | export { test as validateSchemaFixture } from './schema-validation/fixture' 9 | -------------------------------------------------------------------------------- /sample-app/backend/prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // if this file changes 2 | // npm run db:migrate 3 | // npm run db:sync 4 | // npm run reset:db 5 | 6 | generator client { 7 | provider = "prisma-client-js" 8 | } 9 | 10 | datasource db { 11 | provider = "sqlite" 12 | url = env("DATABASE_URL") 13 | } 14 | 15 | model Movie { 16 | id Int @id @default(autoincrement()) 17 | name String 18 | year Int 19 | rating Float 20 | director String 21 | } 22 | -------------------------------------------------------------------------------- /sample-app/frontend/src/components/movie-item/movie-info.tsx: -------------------------------------------------------------------------------- 1 | import type { Movie } from '@shared/types/movie-types' 2 | 3 | type MovieInfoProps = { 4 | readonly movie: Movie 5 | } 6 | 7 | export default function MovieInfo({ movie }: MovieInfoProps) { 8 | return ( 9 |
10 |

{movie.name}

11 |

ID: {movie.id}

12 |

Year: {movie.year}

13 |

Rating: {movie.rating}

14 |
15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /sample-app/backend/scripts/setup-after-env.ts: -------------------------------------------------------------------------------- 1 | import { truncateTables } from './truncate-tables' 2 | 3 | // if this was a before, it would run once in each test file 4 | // since this is beforeEach, if there are multiple it blocks, it will run before each it block in each test file 5 | // as it is, it behaves the same as a before because there is 1 it block in the pact test 6 | beforeEach(async () => { 7 | console.log('Running before each test file, on the provider...') 8 | await truncateTables() 9 | }) 10 | -------------------------------------------------------------------------------- /sample-app/frontend/src/components/error-component.vitest.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | describe, 3 | expect, 4 | it, 5 | screen, 6 | wrappedRender 7 | } from '@vitest-utils/utils' 8 | import ErrorComponent from './error-component' 9 | 10 | describe('', () => { 11 | it('should render an error message', () => { 12 | wrappedRender() 13 | expect(screen.getByTestId('error')).toBeVisible() 14 | expect(screen.getByText('Try reloading the page.')) 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /playwright/support/ui-helpers/add-movie.ts: -------------------------------------------------------------------------------- 1 | import type { Page } from '@playwright/test' 2 | 3 | export const addMovie = async ( 4 | page: Page, 5 | name: string, 6 | year: number, 7 | rating: number, 8 | director: string 9 | ) => { 10 | await page.getByPlaceholder('Movie name').fill(name) 11 | await page.getByPlaceholder('Movie year').fill(String(year)) 12 | await page.getByPlaceholder('Movie rating').fill(String(rating)) 13 | await page.getByPlaceholder('Movie director').fill(director) 14 | } 15 | -------------------------------------------------------------------------------- /src/api-request/schema-validation/index.ts: -------------------------------------------------------------------------------- 1 | /** Schema validation exports */ 2 | 3 | export { validateSchema, detectSchemaFormat } from './core' 4 | export type { 5 | SupportedSchema, 6 | ValidationMode, 7 | ShapeValidator, 8 | ShapeAssertion, 9 | ValidateSchemaOptions, 10 | ValidationErrorDetail, 11 | ValidationResult, 12 | ValidatedApiResponse 13 | } from './types' 14 | export { ValidationError } from './types' 15 | 16 | // Export the validateSchema fixture 17 | export { test } from './fixture' 18 | -------------------------------------------------------------------------------- /playwright/support/auth/get-base-url.ts: -------------------------------------------------------------------------------- 1 | import { getEnvironment } from './get-environment' 2 | 3 | export function getBaseUrl() { 4 | const env = getEnvironment() 5 | 6 | // Example: Different URLs for different environments or roles 7 | if (env === 'local') { 8 | return 'http://localhost:3000' 9 | } 10 | 11 | if (env === 'staging') { 12 | return 'https://staging.example.com' 13 | } 14 | 15 | // Return undefined to fall back to browserContextOptions.baseURL or env vars 16 | return undefined 17 | } 18 | -------------------------------------------------------------------------------- /sample-app/backend/scripts/env-setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # load environment vartiables from .env file if it exists 4 | if [ -f .env ]; then 5 | set -a # all the vars that are defined subsequently, are exported to the env of subsequent commands 6 | # Use . instead of source for maximum shell compatibility (works in both bash and sh) 7 | . ./.env 8 | set +a # turn off allexport 9 | fi 10 | 11 | # git related things 12 | export GITHUB_SHA=$(git rev-parse --short HEAD) 13 | export GITHUB_BRANCH=$(git rev-parse --abbrev-ref HEAD) -------------------------------------------------------------------------------- /playwright/support/auth/get-user-identifier.ts: -------------------------------------------------------------------------------- 1 | import type { AuthOptions } from '@seontechnologies/playwright-utils/auth-session' 2 | import { VALID_TEST_USERS } from '../global-setup' 3 | 4 | export const getUserIdentifier = (options: Partial = {}) => { 5 | // Default to admin if no user identifier specified 6 | const testUser = options.userIdentifier || 'admin' 7 | 8 | // Check if the user is a valid key in VALID_TEST_USERS object 9 | return Object.keys(VALID_TEST_USERS).includes(testUser) ? testUser : 'admin' 10 | } 11 | -------------------------------------------------------------------------------- /sample-app/backend/src/middleware/validate-movie-id.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response, NextFunction } from 'express' 2 | 3 | /** middleware for validating movie id in the request url */ 4 | export function validateId(req: Request, res: Response, next: NextFunction) { 5 | const movieId = parseInt(req.params.id!) 6 | 7 | if (isNaN(movieId)) 8 | return res.status(400).json({ error: 'Invalid movie ID provided' }) 9 | 10 | req.params.id = movieId.toString() // pass validated MovieId forward 11 | 12 | next() // pass to the next middleware or route handler 13 | } 14 | -------------------------------------------------------------------------------- /sample-app/frontend/src/test-utils/factories.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker' 2 | import type { Movie } from '@shared/types/movie-types' 3 | 4 | export const generateMovieWithoutId = (): Omit => { 5 | return { 6 | name: faker.lorem.words(3), // random 3-word title 7 | year: faker.date.past({ years: 50 }).getFullYear(), // random year within the past 50 years 8 | rating: faker.number.float({ min: 1, max: 10, fractionDigits: 1 }), // random rating between 1 and 10 with one decimal place, 9 | director: faker.lorem.words(3) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /sample-app/frontend/vitest.headless.config.ts: -------------------------------------------------------------------------------- 1 | import baseConfig from './vitest.config' 2 | import type { UserConfigExport } from 'vitest/config' 3 | import { defineConfig } from 'vitest/config' 4 | import merge from 'lodash/merge' 5 | 6 | const browserConfig: UserConfigExport = { 7 | test: { 8 | browser: { 9 | instances: [ 10 | { 11 | browser: 'chromium', 12 | name: 'chromium-headless' 13 | } 14 | ], 15 | headless: true 16 | } 17 | } 18 | } 19 | 20 | export default defineConfig(merge({}, baseConfig, browserConfig)) 21 | -------------------------------------------------------------------------------- /src/intercept-network-call/index.ts: -------------------------------------------------------------------------------- 1 | export { interceptNetworkCall } from './intercept-network-call' 2 | export type { 3 | InterceptNetworkCall, 4 | InterceptOptionsFixture, 5 | InterceptNetworkCallFn 6 | } from './intercept-network-call' 7 | 8 | // Export enhanced types and error classes 9 | export type { NetworkCallResult } from './core/types' 10 | export { NetworkInterceptError, NetworkTimeoutError } from './core/types' 11 | 12 | // Note: Fixtures are exported separately via 'playwright-utils/fixtures' 13 | // to avoid conflicts in the main index that only exports plain functions 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # Dependencies 4 | node_modules/ 5 | /node_modules 6 | 7 | # Build outputs 8 | dist/ 9 | /dist 10 | /build 11 | /coverage 12 | 13 | # Environment and config 14 | .env 15 | .vscode 16 | .DS_Store 17 | 18 | # Logs 19 | *.log 20 | npm-debug.log* 21 | 22 | # Playwright 23 | /test-results/ 24 | /playwright-report/ 25 | /playwright-logs/ 26 | /playwright/playwright-logs 27 | /blob-report/ 28 | /playwright/.cache/ 29 | .auth 30 | **/__screenshots__/ 31 | downloads/ 32 | 33 | # Project specific 34 | !sample-app/*/.env 35 | sample-app/backend/test-events/movie-events.log -------------------------------------------------------------------------------- /playwright/tests/sample-app/frontend/user-login-non-default-user-identifier.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/support/merged-fixtures' 2 | 3 | const userIdentifier = 'freeUser' 4 | 5 | test.use({ 6 | authOptions: { 7 | userIdentifier: userIdentifier 8 | } 9 | }) 10 | 11 | test(`should login with a different user; ${userIdentifier}`, async ({ 12 | page, 13 | authToken 14 | }) => { 15 | expect(authToken).toBeDefined() // wait for auth token to be ready, to avoid race conditions 16 | 17 | await page.goto('/') 18 | 19 | await expect(page).toHaveURL('/movies') 20 | 21 | await expect(page.getByText(userIdentifier)).toBeVisible() 22 | }) 23 | -------------------------------------------------------------------------------- /sample-app/frontend/src/components/movie-form/movie-input.tsx: -------------------------------------------------------------------------------- 1 | import { SInput } from '@styles/styled-components' 2 | 3 | type MovieInputProps = Readonly<{ 4 | type: 'text' | 'number' 5 | value: string | number 6 | onChange: (event: React.ChangeEvent) => void 7 | placeholder: string 8 | }> 9 | 10 | export default function MovieInput({ 11 | type, 12 | value, 13 | onChange, 14 | placeholder 15 | }: MovieInputProps) { 16 | return ( 17 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /playwright/support/utils/run-command.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from 'node:child_process' 2 | 3 | /** 4 | * Runs a shell command and returns the output. 5 | * Handles errors gracefully and returns null if the command fails. 6 | * @param {string} command - The command to run. 7 | * @returns {string | null} - The output of the command or null if it fails. 8 | */ 9 | export function runCommand(command: string): string | null { 10 | try { 11 | return execSync(command, { encoding: 'utf-8' }).trim() 12 | } catch (error) { 13 | const typedError = error as Error 14 | console.error(typedError.message) 15 | return null // Return null to signify failure 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /sample-app/frontend/src/components/validation-error-display.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import type { z } from 'zod' 3 | 4 | type props = { 5 | readonly validationError: z.ZodError | null 6 | } 7 | 8 | export default function ValidationErrorDisplay({ validationError }: props) { 9 | if (!validationError) return null 10 | 11 | return ( 12 | 13 | {validationError.issues.map((err) => ( 14 |

15 | {err.message} 16 |

17 | ))} 18 |
19 | ) 20 | } 21 | 22 | const SError = styled.div` 23 | color: red; 24 | margin-bottom: 10px; 25 | ` 26 | -------------------------------------------------------------------------------- /sample-app/frontend/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query' 4 | import App from './App' 5 | 6 | const port = import.meta.env.VITE_PORT 7 | const apiUrl = import.meta.env.VITE_API_URL 8 | console.log(`React app is running on port: ${port}`) 9 | console.log(`API should be running on: ${apiUrl}`) 10 | 11 | const root = createRoot(document.getElementById('root') as HTMLElement) 12 | 13 | const queryClient = new QueryClient() 14 | 15 | root.render( 16 | 17 | 18 | 19 | 20 | 21 | ) 22 | -------------------------------------------------------------------------------- /.github/workflows/gitleaks-check.yml: -------------------------------------------------------------------------------- 1 | name: Security Scan (Gitleaks) 2 | 3 | on: 4 | pull_request: 5 | branches: ["main"] 6 | push: 7 | branches: ["main"] 8 | 9 | jobs: 10 | gitleaks: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | 17 | - name: Install Gitleaks 18 | run: | 19 | wget https://github.com/gitleaks/gitleaks/releases/download/v8.18.2/gitleaks_8.18.2_linux_x64.tar.gz 20 | tar -xzf gitleaks_8.18.2_linux_x64.tar.gz 21 | sudo mv gitleaks /usr/local/bin/ 22 | 23 | - name: Run Gitleaks 24 | run: gitleaks detect --source . --config .gitleaks.toml --verbose --redact --no-git 25 | -------------------------------------------------------------------------------- /src/log/utils/flag.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Utility function to extract boolean flag values from various formats. 3 | * Used to standardize how boolean flags are handled across the logging system. 4 | * 5 | * @param flag - The flag value which can be a boolean, an object with an enabled property, or undefined 6 | * @param defaultValue - The default value to use if flag is undefined or doesn't have an enabled property 7 | * @returns A single boolean value 8 | */ 9 | export function isEnabled( 10 | flag: boolean | { enabled?: boolean } | undefined, 11 | defaultValue = true 12 | ): boolean { 13 | if (typeof flag === 'boolean') return flag 14 | if (flag && typeof flag.enabled === 'boolean') return flag.enabled 15 | return defaultValue 16 | } 17 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { config as dotenvConfig } from 'dotenv' 2 | import path from 'path' 3 | 4 | dotenvConfig({ 5 | path: path.resolve(__dirname, '../../.env') 6 | }) 7 | 8 | const envConfigMap = { 9 | local: require('./playwright/config/local.config').default 10 | } 11 | 12 | const environment = process.env.TEST_ENV || 'local' 13 | 14 | // Validate environment config 15 | if (!Object.keys(envConfigMap).includes(environment)) { 16 | console.error(`No configuration found for environment: ${environment}`) 17 | console.error('Available environments:') 18 | Object.keys(envConfigMap).forEach((env) => console.error(`- ${env}`)) 19 | process.exit(1) 20 | } 21 | 22 | export default envConfigMap[environment as keyof typeof envConfigMap] 23 | -------------------------------------------------------------------------------- /sample-app/backend/.env: -------------------------------------------------------------------------------- 1 | # This was inserted by `prisma init`: 2 | # Environment variables declared in this file are automatically made available to Prisma. 3 | # See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema 4 | 5 | # Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB. 6 | # See the documentation for all the connection string options: https://pris.ly/d/connection-strings 7 | 8 | DATABASE_URL="file:./dev.db" 9 | # the local server port to use 10 | PORT=3001 11 | 12 | # less Kafka noise 13 | KAFKAJS_NO_PARTITIONER_WARNING=1 14 | KAFKA_UI_URL=http://localhost:8085 # defined at src/events/kafka-cluster.yml L85, purely optional -------------------------------------------------------------------------------- /playwright/tests/sample-app/frontend/user-redirect.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/support/merged-fixtures' 2 | import { log } from 'src/log' 3 | 4 | /** 5 | * This test verifies that when properly authenticated, 6 | * navigation to the root path redirects to the movies page 7 | */ 8 | test('authenticated redirect works correctly', async ({ page }) => { 9 | await log.step('Verifying authentication token is present') 10 | await log.info('Auth token is available') 11 | await log.step('Navigating to the root path') 12 | 13 | await page.goto('/') 14 | 15 | await log.step('Verifying redirect to authenticated route') 16 | await expect(page).toHaveURL('/movies') 17 | 18 | await expect(page.getByText('admin')).toBeVisible() 19 | }) 20 | -------------------------------------------------------------------------------- /sample-app/backend/src/api-docs/openapi-writer.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs' 2 | import path from 'node:path' 3 | import { stringify } from 'yaml' 4 | import { openApiDoc } from './openapi-generator' 5 | 6 | // Generate OpenAPI docs with Zod 7 | 8 | // convert OpenAPI document to YML 9 | const ymlDoc = stringify(openApiDoc) 10 | 11 | const scriptDir = path.resolve(__dirname) 12 | // write the YML file 13 | fs.writeFileSync(`${scriptDir}/openapi.yml`, ymlDoc) 14 | 15 | console.log('OpenAPI document generated in YML format.') 16 | 17 | // Json version 18 | const jsonDoc = JSON.stringify(openApiDoc, null, 2) 19 | 20 | // Write the JSON file 21 | fs.writeFileSync(`${scriptDir}/openapi.json`, jsonDoc) 22 | 23 | console.log('OpenAPI document generated in JSON format.') 24 | -------------------------------------------------------------------------------- /playwright/tests/sample-app/frontend/user-login.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/support/merged-fixtures' 2 | import { log } from 'src/log' 3 | 4 | test.use({ 5 | authSessionEnabled: false 6 | }) 7 | 8 | test( 9 | 'should login @smoke', 10 | { annotation: { type: 'skipNetworkMonitoring' } }, 11 | async ({ page }) => { 12 | await page.goto('/') 13 | await expect(page).toHaveURL('/login') 14 | 15 | await page.getByTestId('username-input').fill('admin') 16 | await page.getByTestId('password-input').fill('admin') 17 | 18 | await page.getByTestId('login-button').click() 19 | 20 | await expect(page).toHaveURL('/movies') 21 | await log.step('at movies page') 22 | } 23 | ) 24 | 25 | // testing smart burn-in, remove this line later 26 | -------------------------------------------------------------------------------- /sample-app/frontend/src/components/file-download/file-download.vitest.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | describe, 3 | expect, 4 | it, 5 | screen, 6 | wrappedRender 7 | } from '@vitest-utils/utils' 8 | import FileDownload from './file-download' 9 | 10 | describe('', () => { 11 | it('should render the file download component and each file type', () => { 12 | wrappedRender() 13 | expect(screen.getByTestId('files-list')).toBeVisible() 14 | 15 | // there should be multiple download-buttons and file-types 16 | const downloadButtons = screen.getAllByTestId(/download-button/) 17 | expect(downloadButtons).toHaveLength(5) 18 | const fileTypes = screen.getAllByTestId(/file-type/) 19 | expect(fileTypes).toHaveLength(5) 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /playwright/support/ui-helpers/edit-movie.ts: -------------------------------------------------------------------------------- 1 | import type { Page } from '@playwright/test' 2 | 3 | export const editMovie = async ( 4 | page: Page, 5 | editedName: string, 6 | editedYear: number, 7 | editedRating: number, 8 | editedDirector: string 9 | ) => { 10 | await page.getByTestId('edit-movie').click() 11 | 12 | const editForm = page.getByTestId('movie-edit-form-comp') 13 | 14 | await editForm.getByPlaceholder('Movie name').fill(editedName) 15 | await editForm.getByPlaceholder('Movie year').fill(String(editedYear)) 16 | await editForm.getByPlaceholder('Movie rating').fill(String(editedRating)) 17 | await editForm.getByPlaceholder('Movie director').fill(editedDirector) 18 | 19 | await editForm.getByTestId('update-movie').click() 20 | } 21 | 22 | // edit 23 | -------------------------------------------------------------------------------- /sample-app/frontend/src/hooks/use-movie-detail.ts: -------------------------------------------------------------------------------- 1 | import { useMovie } from '@hooks/use-movies' 2 | import { useParams, useSearchParams } from 'react-router-dom' 3 | 4 | export function useMovieDetails() { 5 | // Get the id from the route params or query parameters 6 | // .../movies/{{movieId}} 7 | const { id } = useParams<{ id: string }>() 8 | // .../movies?name={{movieName}} 9 | const [searchParams] = useSearchParams() 10 | const movieName = searchParams.get('name') 11 | 12 | const identifier = 13 | movieName ?? (id && !isNaN(Number(id)) ? parseInt(id, 10) : null) 14 | if (!identifier) 15 | return { movie: null, isLoading: false, hasIdentifier: false } 16 | 17 | const { data, isLoading } = useMovie(identifier) 18 | 19 | return { data, isLoading, hasIdentifier: true } 20 | } 21 | -------------------------------------------------------------------------------- /playwright/tests/auth-session/auth-session-ui-sanity.spec.ts: -------------------------------------------------------------------------------- 1 | import { test as pwTest } from '@playwright/test' 2 | import { test, expect } from '@playwright/support/merged-fixtures' 3 | 4 | test.describe('tests with auth session', () => { 5 | test('should navigate to base url with auth session', async ({ page }) => { 6 | // Use full URL for reliable navigation with auth 7 | await page.goto('/') 8 | expect(page.url()).toContain('http://localhost:3000/') 9 | }) 10 | }) 11 | 12 | // Use standard Playwright test for tests without auth 13 | pwTest.describe('tests without auth session', () => { 14 | pwTest('should navigate to base url directly', async ({ page }) => { 15 | // Use full URL for reliable navigation without auth 16 | await page.goto('/') 17 | expect(page.url()).toContain('http://localhost:3000/') 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /sample-app/frontend/src/components/movie-item/movie-info.vitest.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | wrappedRender, 3 | screen, 4 | describe, 5 | it, 6 | expect 7 | } from '@vitest-utils/utils' 8 | import MovieInfo from './movie-info' 9 | 10 | describe('', () => { 11 | it('should render the movie info', () => { 12 | const id = 1 13 | const name = 'Inception' 14 | const year = 2010 15 | const rating = 8.5 16 | const director = 'Christopher Nolan' 17 | const movie = { id, name, year, rating, director } 18 | 19 | wrappedRender() 20 | 21 | expect(screen.getByText(`ID: ${id}`)).toBeVisible() 22 | expect(screen.getByText(name)).toBeVisible() 23 | expect(screen.getByText(`Year: ${year}`)).toBeVisible() 24 | expect(screen.getByText(`Rating: ${rating}`)).toBeVisible() 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /playwright/scripts/burn-in-changed.ts: -------------------------------------------------------------------------------- 1 | import { runBurnIn } from '../../src/burn-in' 2 | 3 | async function main() { 4 | // Parse command line arguments 5 | const args = process.argv.slice(2) 6 | const baseBranchArg = args.find((arg) => arg.startsWith('--base-branch=')) 7 | const shardArg = args.find((arg) => arg.startsWith('--shard=')) 8 | 9 | const options: Parameters[0] = { 10 | // Always use the same config file - one source of truth 11 | configPath: 'playwright/config/.burn-in.config.ts' 12 | } 13 | 14 | if (baseBranchArg) { 15 | options.baseBranch = baseBranchArg.split('=')[1] 16 | } 17 | 18 | // Store shard info in environment for the burn-in runner to use 19 | if (shardArg) { 20 | process.env.PW_SHARD = shardArg.split('=')[1] 21 | } 22 | 23 | await runBurnIn(options) 24 | } 25 | 26 | main().catch(console.error) 27 | -------------------------------------------------------------------------------- /src/burn-in/core/types.ts: -------------------------------------------------------------------------------- 1 | export type BurnInConfig = { 2 | /** Patterns for files that should skip burn-in entirely */ 3 | skipBurnInPatterns?: string[] 4 | 5 | /** Patterns to identify test files */ 6 | testPatterns?: string[] 7 | 8 | /** Burn-in test execution settings */ 9 | burnIn?: { 10 | /** Number of times to repeat each test */ 11 | repeatEach?: number 12 | /** Number of retries for failed tests */ 13 | retries?: number 14 | } 15 | 16 | /** Percentage of tests to run AFTER skip patterns filter (e.g., 0.1 for 10%) */ 17 | burnInTestPercentage?: number 18 | 19 | /** Enable verbose debug output for pattern matching (can also use BURN_IN_DEBUG env var) */ 20 | debug?: boolean 21 | } 22 | 23 | export type BurnInOptions = { 24 | /** Base branch to compare against */ 25 | baseBranch?: string 26 | 27 | /** Path to configuration file */ 28 | configPath?: string 29 | } 30 | -------------------------------------------------------------------------------- /src/intercept-network-call/intercept-network-call-fixture.ts: -------------------------------------------------------------------------------- 1 | import { test as base } from '@playwright/test' 2 | import { 3 | interceptNetworkCall as interceptNetworkCallOriginal, 4 | type InterceptOptionsFixture, 5 | type InterceptNetworkCallFn 6 | } from './intercept-network-call' 7 | 8 | type InterceptNetworkMethods = { 9 | interceptNetworkCall: InterceptNetworkCallFn 10 | } 11 | 12 | export const test = base.extend({ 13 | interceptNetworkCall: async ({ page }, use) => { 14 | const interceptNetworkCallFn: InterceptNetworkCallFn = ({ 15 | method, 16 | url, 17 | fulfillResponse, 18 | handler 19 | }: InterceptOptionsFixture) => 20 | interceptNetworkCallOriginal({ 21 | method, 22 | url, 23 | fulfillResponse, 24 | handler, 25 | page 26 | }) 27 | 28 | await use(interceptNetworkCallFn) 29 | } 30 | }) 31 | -------------------------------------------------------------------------------- /sample-app/frontend/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, mergeConfig } from 'vitest/config' 2 | import viteConfig from './vite.config' 3 | 4 | const viteConfigBase = viteConfig({ 5 | mode: process.env.NODE_ENV || 'test', 6 | command: 'serve' 7 | }) 8 | 9 | const config = mergeConfig( 10 | viteConfigBase, 11 | defineConfig({ 12 | test: { 13 | retry: 3, 14 | browser: { 15 | enabled: true, 16 | provider: 'playwright', 17 | instances: [ 18 | { 19 | browser: 'chromium', 20 | name: 'chromium' 21 | } 22 | ] 23 | }, 24 | environment: 'happy-dom', 25 | setupFiles: ['./src/test-utils/vitest-utils/vitest.setup.ts'], 26 | include: ['src/**/*.vitest.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], 27 | exclude: ['node_modules/**', 'playwright/**', 'src/consumer.test.ts'] 28 | } 29 | }) 30 | ) 31 | 32 | export default config 33 | -------------------------------------------------------------------------------- /src/log/log-fixture.ts: -------------------------------------------------------------------------------- 1 | import { test as base } from '@playwright/test' 2 | import { log as logObject } from './log' 3 | import type { LogParams } from './types' 4 | 5 | // function to build log options 6 | const buildLoggingConfig = ({ 7 | console, 8 | testFile, 9 | testName, 10 | options = {} 11 | }: LogParams) => ({ 12 | ...options, 13 | ...(typeof console !== 'undefined' && { console }), 14 | ...(testFile && { testFile }), 15 | ...(testName && { testName }) 16 | }) 17 | 18 | export const test = base.extend<{ 19 | log: (params: LogParams) => Promise 20 | }>({ 21 | log: async ({}, use) => { 22 | const log = async (params: LogParams): Promise => { 23 | const { level = 'info', message } = params 24 | 25 | // ex: log.step('Testing adding todo items', { console: false })) 26 | return logObject[level](message, buildLoggingConfig(params)) 27 | } 28 | 29 | await use(log) 30 | } 31 | }) 32 | -------------------------------------------------------------------------------- /playwright/support/utils/movie-factories.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker' 2 | import type { Movie } from '@shared/types/movie-types' 3 | 4 | export const generateMovieWithoutId = (): Omit => { 5 | return { 6 | name: faker.lorem.words(3), // random 3-word title 7 | year: faker.date.past({ years: 50 }).getFullYear(), // random year between the past 50 years 8 | rating: faker.number.float({ min: 1, max: 10, fractionDigits: 1 }), // random rating between 1 and 10 with 1 decimal place 9 | director: faker.lorem.words(3) 10 | } 11 | } 12 | 13 | export const generateMovieWithId = (): Movie => { 14 | return { 15 | id: faker.number.int({ min: 1, max: 1000 }), // random ID between 1 and 1000 16 | name: faker.lorem.words(3), 17 | year: faker.date.past({ years: 50 }).getFullYear(), 18 | rating: faker.number.float({ min: 1, max: 10, fractionDigits: 1 }), 19 | director: faker.lorem.words(3) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/network-recorder/core/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Core network recorder exports 3 | */ 4 | 5 | // Main classes and functions 6 | export { NetworkRecorder, createNetworkRecorder } from './network-recorder' 7 | 8 | // HAR management utilities 9 | export { 10 | generateHarFilePath, 11 | ensureHarDirectory, 12 | validateHarFileForPlayback, 13 | acquireHarFileLock, 14 | removeHarFile, 15 | createUniqueHarFilePath, 16 | getHarFileStats 17 | } from './har-manager' 18 | 19 | // Mode detection utilities 20 | export { 21 | detectNetworkMode, 22 | isValidNetworkMode, 23 | getEffectiveNetworkMode, 24 | isNetworkModeActive, 25 | getModeDefaults, 26 | validateModeConfiguration, 27 | getModeDescription 28 | } from './mode-detector' 29 | 30 | // HAR builder utilities 31 | export { 32 | createHarFile, 33 | requestToHarEntry, 34 | addPageToHar, 35 | addEntryToHar 36 | } from './har-builder' 37 | 38 | // Types 39 | export type * from './types' 40 | -------------------------------------------------------------------------------- /src/api-request/index.ts: -------------------------------------------------------------------------------- 1 | // Export core API request functionality and types 2 | export { 3 | apiRequest, 4 | type ApiRequestParams, 5 | type ApiRequestResponse, 6 | type ApiRetryConfig, 7 | ApiRequestError, 8 | ApiNetworkError 9 | } from './api-request' 10 | 11 | // Re-export the fixture-specific type 12 | export { type ApiRequestFixtureParams } from './api-request-fixture' 13 | 14 | // Export schema validation types and functionality 15 | export type { EnhancedApiResponse } from './schema-validation/internal/response-extension' 16 | 17 | export type { EnhancedApiPromise } from './schema-validation/internal/promise-extension' 18 | 19 | export type { 20 | SupportedSchema, 21 | ValidationMode, 22 | ShapeValidator, 23 | ShapeAssertion, 24 | ValidateSchemaOptions, 25 | ValidationErrorDetail, 26 | ValidationResult, 27 | ValidatedApiResponse 28 | } from './schema-validation/types' 29 | 30 | export { ValidationError } from './schema-validation/types' 31 | -------------------------------------------------------------------------------- /sample-app/frontend/src/styles/styled-components.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | export const SAppContainer = styled.div` 4 | text-align: center; 5 | padding: 20px; 6 | ` 7 | 8 | export const STitle = styled.h1` 9 | color: #333; 10 | font-size: 2.5rem; 11 | margin-bottom: 20px; 12 | ` 13 | 14 | export const SButton = styled.button` 15 | background-color: #ff6347; 16 | color: #fff; 17 | border: none; 18 | border-radius: 3px; 19 | margin: 10px; 20 | padding: 5px 10px; 21 | cursor: pointer; 22 | font-size: 1rem; 23 | transition: background-color 0.3s; 24 | 25 | &:hover { 26 | background-color: #e5533d; 27 | } 28 | 29 | &:disabled { 30 | background-color: #ddd; 31 | cursor: not-allowed; 32 | } 33 | ` 34 | 35 | export const SInput = styled.input` 36 | padding: 10px; 37 | margin: 10px; 38 | border-radius: 5px; 39 | border: 1px solid #ddd; 40 | font-size: 1rem; 41 | 42 | &[type='number'] { 43 | width: 100px; 44 | } 45 | ` 46 | -------------------------------------------------------------------------------- /playwright/tests/sample-app/frontend/user-login-multi-user-identifiers.spec.ts: -------------------------------------------------------------------------------- 1 | import { VALID_TEST_USERS } from '@playwright/support/global-setup' 2 | import { test, expect } from '@playwright/support/merged-fixtures' 3 | 4 | const userIdentifiers = Object.values(VALID_TEST_USERS) 5 | 6 | // KEY: Describe block has to be used, and the forEach has to wrap the describe block 7 | userIdentifiers.forEach((userIdentifier) => { 8 | test.describe(`User: ${userIdentifier}`, () => { 9 | test.use({ 10 | authOptions: { 11 | userIdentifier 12 | } 13 | }) 14 | 15 | test(`should login with a different user; ${userIdentifier}`, async ({ 16 | page, 17 | authToken 18 | }) => { 19 | expect(authToken).toBeDefined() // wait for auth token to be ready, to avoid race conditions 20 | 21 | await page.goto('/') 22 | 23 | await expect(page).toHaveURL('/movies') 24 | 25 | await expect(page.getByText(userIdentifier)).toBeVisible() 26 | }) 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /setup-playwright-browsers/action.yml: -------------------------------------------------------------------------------- 1 | # With remote composite actions, for other repos to use this action, it has to be a root level folder 2 | # But, we are also using the composite action locally in this repo 3 | # therefore we are proxying the action to a nested folder 4 | 5 | name: 'Setup Playwright Browsers' 6 | description: 'Configures and caches Playwright browsers with caching for CI environments' 7 | 8 | inputs: 9 | browser-cache-bust: 10 | description: 'Optional value to force browser cache invalidation (set to "true" or a timestamp to bust cache)' 11 | required: false 12 | default: 'false' 13 | 14 | outputs: 15 | cache-hit: 16 | description: 'Whether there was a cache hit for the browser binaries' 17 | value: ${{ steps.nested-action.outputs.cache-hit }} 18 | 19 | runs: 20 | using: 'composite' 21 | steps: 22 | - id: nested-action 23 | uses: ../.github/actions/setup-playwright-browsers 24 | with: 25 | browser-cache-bust: ${{ inputs.browser-cache-bust }} 26 | -------------------------------------------------------------------------------- /sample-app/backend/scripts/truncate-tables.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client' 2 | 3 | const prisma = new PrismaClient() 4 | 5 | // In SQLite, TRUNCATE is not supported, so we use DELETE to remove all rows from the table. 6 | // Unlike TRUNCATE, DELETE logs each row deletion, but it's the proper way to clear tables in SQLite. 7 | // This script ensures that all rows are deleted, resetting the table's state for clean tests. 8 | 9 | // Additionally, SQLite maintains an auto-increment sequence for primary keys in the `sqlite_sequence` table. 10 | // We reset this sequence to ensure that the IDs start from 1 again after the deletion, simulating a "fresh" table. 11 | 12 | export async function truncateTables(): Promise { 13 | await prisma.$executeRaw`DELETE FROM "Movie"` // Clears the table by deleting all rows 14 | await prisma.$executeRaw`DELETE FROM sqlite_sequence WHERE name='Movie'` // Reset auto-increment if needed 15 | console.log('Tables truncated') 16 | await prisma.$disconnect() 17 | } 18 | -------------------------------------------------------------------------------- /sample-app/frontend/src/components/movie-item/movie-item.vitest.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | wrappedRender, 3 | screen, 4 | describe, 5 | it, 6 | expect 7 | } from '@vitest-utils/utils' 8 | import { vi } from 'vitest' 9 | 10 | import MovieItem from './movie-item' 11 | 12 | describe('', () => { 13 | const onDelete = vi.fn() 14 | 15 | it('should verify the movie and delete', async () => { 16 | const id = 3 17 | wrappedRender( 18 | 26 | ) 27 | 28 | const link = screen.getByText('my movie (2023) 8.5 my director') 29 | expect(link).toBeVisible() 30 | expect(link).toHaveAttribute('href', `/movies/${id}`) 31 | 32 | screen.getByRole('button', { name: /delete/i }).click() 33 | expect(onDelete).toHaveBeenCalledTimes(1) 34 | expect(onDelete).toHaveBeenCalledWith(id) 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /sample-app/frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useDeleteMovie, useMovies } from '@hooks/use-movies' 2 | import { SAppContainer } from '@styles/styled-components' 3 | import LoadingMessage from '@components/loading-message' 4 | import { Suspense } from 'react' 5 | import { ErrorBoundary } from 'react-error-boundary' 6 | import ErrorComponent from '@components/error-component' 7 | import AppRoutes from './App-routes' 8 | import type { Movie } from '@shared/types/movie-types' 9 | 10 | export default function App() { 11 | const { data } = useMovies() 12 | const moviesData = (data as unknown as { data: Movie[] }).data 13 | 14 | const deleteMovieMutation = useDeleteMovie() 15 | const handleDeleteMovie = deleteMovieMutation.mutate 16 | 17 | return ( 18 | }> 19 | }> 20 | 21 | 22 | 23 | 24 | 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /sample-app/frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, loadEnv } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | import path from 'path' 4 | 5 | export default defineConfig(({ mode }) => { 6 | const env = loadEnv(mode, process.cwd(), '') 7 | 8 | return { 9 | server: { 10 | port: Number(env.VITE_PORT), 11 | host: true 12 | }, 13 | plugins: [react()], 14 | resolve: { 15 | alias: { 16 | '@components': path.resolve(__dirname, './src/components'), 17 | '@hooks': path.resolve(__dirname, './src/hooks'), 18 | '@styles': path.resolve(__dirname, './src/styles'), 19 | '@playwright': path.resolve(__dirname, '../../playwright'), 20 | '@shared': path.resolve(__dirname, '../../sample-app/shared'), 21 | '@vitest-utils': path.resolve( 22 | __dirname, 23 | './src/test-utils/vitest-utils' 24 | ) 25 | } 26 | }, 27 | // Vite 6 changes the way environment variables are handled 28 | define: { 29 | 'process.env': {} 30 | } 31 | } 32 | }) 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 SEON Technologies 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /sample-app/backend/src/events/log-file-path.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, mkdirSync } from 'fs' 2 | import { join, resolve } from 'path' 3 | 4 | // Get the project root (backend directory) 5 | const getProjectRoot = () => { 6 | // If we're running in the backend directory, use it 7 | if (__dirname.includes('backend')) { 8 | // Go up from src/events to backend directory 9 | return resolve(__dirname, '../../') 10 | } 11 | // Fallback to current working directory 12 | return process.cwd() 13 | } 14 | 15 | const projectRoot = getProjectRoot() 16 | // Create test-events directory in the backend 17 | const eventsDir = resolve(projectRoot, 'test-events') 18 | 19 | // Ensure the directory exists 20 | if (!existsSync(eventsDir)) { 21 | try { 22 | console.log(`Creating events directory at: ${eventsDir}`) 23 | mkdirSync(eventsDir, { recursive: true }) 24 | } catch (error) { 25 | console.error( 26 | `Failed to create events directory: ${error instanceof Error ? error.message : String(error)}` 27 | ) 28 | } 29 | } 30 | 31 | // Export the full path to the log file 32 | export const logFilePath = join(eventsDir, 'movie-events.log') 33 | -------------------------------------------------------------------------------- /sample-app/frontend/src/components/movie-item/movie-item.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from 'react-router-dom' 2 | import { SButton } from '@styles/styled-components' 3 | import styled from 'styled-components' 4 | import type { Movie } from '@shared/types/movie-types' 5 | 6 | type MovieItemProps = Movie & { onDelete: (id: number) => void } 7 | 8 | export default function MovieItem({ 9 | id, 10 | name, 11 | year, 12 | rating, 13 | director, 14 | onDelete 15 | }: MovieItemProps) { 16 | return ( 17 | 18 | 19 | {name} ({year}) {rating} {director} 20 | 21 | onDelete(id)} 24 | > 25 | Delete 26 | 27 | 28 | ) 29 | } 30 | 31 | const SMovieItem = styled.li` 32 | background-color: #fff; 33 | border: 1px solid #ddd; 34 | border-radius: 5px; 35 | padding: 10px 20px; 36 | margin-bottom: 10px; 37 | display: flex; 38 | justify-content: space-between; 39 | align-items: center; 40 | ` 41 | -------------------------------------------------------------------------------- /sample-app/frontend/src/test-utils/vitest-utils/vitest.setup.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeAll, afterAll } from 'vitest' 2 | import '@testing-library/jest-dom/vitest' 3 | import { cleanup, configure } from '@testing-library/react' 4 | import { worker } from './msw-setup' 5 | 6 | configure({ testIdAttribute: 'data-testid' }) 7 | 8 | afterEach(() => { 9 | cleanup() 10 | }) 11 | 12 | // we need all this so msw works without flake in headless mode 13 | 14 | beforeAll(async () => { 15 | await worker.start({ onUnhandledRequest: 'bypass' }) 16 | if ('serviceWorker' in navigator) { 17 | await waitForServiceWorkerControl() 18 | } 19 | }) 20 | 21 | afterAll(() => { 22 | // If you want to stop the worker eventually, do it here: 23 | worker.stop() 24 | }) 25 | 26 | async function waitForServiceWorkerControl() { 27 | // If the page is already controlled, great 28 | if (navigator.serviceWorker.controller) return 29 | 30 | // Otherwise, wait up to ~2 seconds for it 31 | let attempts = 0 32 | while (!navigator.serviceWorker.controller && attempts < 20) { 33 | await new Promise((r) => setTimeout(r, 100)) 34 | attempts++ 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # GitHub Actions Updates 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | day: "sunday" 9 | time: "22:00" 10 | timezone: "America/New_York" 11 | open-pull-requests-limit: 2 12 | groups: 13 | github-actions: 14 | patterns: 15 | - "*" 16 | labels: 17 | - "type: dependencies" 18 | - "github-actions" 19 | 20 | # npm Updates 21 | - package-ecosystem: "npm" 22 | directory: "/" 23 | schedule: 24 | interval: "weekly" 25 | day: "sunday" 26 | time: "22:00" 27 | timezone: "America/New_York" 28 | open-pull-requests-limit: 2 29 | groups: 30 | minor-patch-updates: 31 | patterns: 32 | - "*" 33 | update-types: 34 | - "minor" 35 | - "patch" 36 | labels: 37 | - "type: dependencies" 38 | - "npm" 39 | ignore: 40 | - dependency-name: "*" 41 | update-types: ["version-update:semver-major"] 42 | - dependency-name: "madge" 43 | commit-message: 44 | prefix: "chore" 45 | include: "scope" -------------------------------------------------------------------------------- /playwright/config/.burn-in.config.ts: -------------------------------------------------------------------------------- 1 | import type { BurnInConfig } from '../../src/burn-in' 2 | 3 | const config: BurnInConfig = { 4 | // Files that should skip burn-in entirely (config, constants, types) 5 | skipBurnInPatterns: [ 6 | '**/config/**', 7 | '**/network-record-playback/**', 8 | '**/configuration/**', 9 | '**/playwright.config.ts', 10 | '**/*featureFlags*', 11 | '**/*constants*', 12 | '**/*config*', 13 | '**/*types*', 14 | '**/*interfaces*', 15 | '**/package.json', 16 | '**/tsconfig.json', 17 | '**/*.md' 18 | ], 19 | 20 | // Test file patterns (optional - defaults to *.spec.ts, *.test.ts) 21 | testPatterns: ['**/*.spec.ts', '**/*.test.ts', '**/*.e2e.ts'], 22 | 23 | // Burn-in execution settings 24 | burnIn: { 25 | repeatEach: process.env.CI ? 2 : 3, // Fewer repeats in CI for speed 26 | retries: process.env.CI ? 0 : 1 // No retries in CI to fail fast 27 | }, 28 | 29 | // Run this percentage of tests AFTER skip patterns filter (0.5 = 50%) 30 | // This controls test volume after skip patterns have filtered files 31 | burnInTestPercentage: process.env.CI ? 0.5 : 0.5 32 | } 33 | 34 | export default config 35 | -------------------------------------------------------------------------------- /src/log/outputs/log-to-console.ts: -------------------------------------------------------------------------------- 1 | import { asPromise } from '../utils/async' 2 | import type { LogLevel } from '../types' 3 | 4 | /** Maps log levels to appropriate console methods */ 5 | const getConsoleMethodForLevel = ( 6 | level: LogLevel 7 | ): ((message: string) => void) => { 8 | // Map log levels to console methods using a functional approach 9 | const methodMap: Record void> = { 10 | info: console.info, 11 | step: console.info, // Steps are considered informational 12 | success: console.log, // Success uses log with green formatting 13 | warning: console.warn, 14 | error: console.error, 15 | debug: console.debug 16 | } 17 | 18 | return methodMap[level] || console.log 19 | } 20 | 21 | /** Logs a message to the console using the method corresponding to the log level */ 22 | export const logToConsole = async ( 23 | message: string, 24 | level: LogLevel 25 | ): Promise => { 26 | // Get the appropriate console method for this log level 27 | const consoleMethod = getConsoleMethodForLevel(level) 28 | 29 | // Use asPromise to ensure I/O operations complete before resolving 30 | return asPromise(() => consoleMethod(message)) 31 | } 32 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Main entry point for playwright-utils plain functions 3 | * 4 | * IMPORTANT: This file only exports plain functions, NOT fixtures. 5 | * For fixtures, import from 'playwright-utils/fixtures' 6 | */ 7 | 8 | // Export core API request functionality (plain functions only) 9 | export * from './api-request' 10 | export * from './recurse' 11 | export * from './log/index' 12 | export * from './intercept-network-call' 13 | export * from './file-utils' 14 | export * from './network-recorder/network-recorder' 15 | 16 | /////////////////////// 17 | // Internal logger to use our log implementation instead of console.log 18 | // This avoids circular dependencies between modules 19 | /////////////////////// 20 | 21 | import { configureLogger } from './internal' 22 | import { log } from './log/index' 23 | 24 | // Shared logger interface for internal use 25 | configureLogger({ 26 | info: (message: string) => log.info(message), 27 | step: (message: string) => log.step(message), 28 | success: (message: string) => log.success(message), 29 | warning: (message: string) => log.warning(message), 30 | error: (message: string) => log.error(message), 31 | debug: (message: string) => log.debug(message) 32 | }) 33 | -------------------------------------------------------------------------------- /sample-app/frontend/src/components/movie-list.tsx: -------------------------------------------------------------------------------- 1 | import ErrorComp from '@components/error-component' 2 | import styled from 'styled-components' 3 | import type { ErrorResponse } from '../consumer' 4 | import MovieItem from './movie-item' 5 | import { STitle } from '@styles/styled-components' 6 | import type { Movie } from '@shared/types/movie-types' 7 | 8 | type MovieListProps = Readonly<{ 9 | movies: Movie[] | ErrorResponse | undefined 10 | onDelete: (id: number) => void 11 | }> 12 | 13 | export default function MovieList({ movies, onDelete }: MovieListProps) { 14 | if (Array.isArray(movies) && movies.length === 0) { 15 | return null 16 | } 17 | 18 | if (movies && 'error' in movies) { 19 | return 20 | } 21 | 22 | return ( 23 | <> 24 | Movie List 25 | 26 | {Array.isArray(movies) && 27 | movies.map((movie) => ( 28 | 29 | ))} 30 | 31 | 32 | ) 33 | } 34 | 35 | const SMovieList = styled.ul` 36 | list-style: none; 37 | padding: 0; 38 | max-width: 600px; 39 | margin: 0 auto 20px; 40 | ` 41 | -------------------------------------------------------------------------------- /playwright/support/merged-fixtures.ts: -------------------------------------------------------------------------------- 1 | import { test as base, mergeTests } from '@playwright/test' 2 | import { test as apiRequest } from '../../src/api-request/fixtures' 3 | import { test as validateSchema } from '../../src/api-request/schema-validation/fixture' 4 | import { test as fileUtils } from '../../src/file-utils/file-utils-fixture' 5 | import { test as interceptNetworkCall } from '../../src/intercept-network-call/fixtures' 6 | import { captureTestContext } from '../../src/log' 7 | import { test as networkRecorder } from '../../src/network-recorder/fixtures' 8 | import { test as authFixture } from './auth/auth-fixture' 9 | import { test as crudHelper } from './fixtures/crud-helper-fixture' 10 | import { test as networkErrorMonitorFixture } from '../../src/network-error-monitor/fixtures' 11 | 12 | // a hook that will run before each test in the suite 13 | base.beforeEach(async ({}, testInfo) => { 14 | captureTestContext(testInfo) 15 | }) 16 | 17 | const test = mergeTests( 18 | base, 19 | authFixture, 20 | interceptNetworkCall, 21 | apiRequest, 22 | validateSchema, 23 | crudHelper, 24 | fileUtils, 25 | networkRecorder, 26 | networkErrorMonitorFixture 27 | ) 28 | const expect = base.expect 29 | export { expect, test } 30 | -------------------------------------------------------------------------------- /sample-app/frontend/src/components/header/user-header.vitest.tsx: -------------------------------------------------------------------------------- 1 | import { UserHeader } from './user-header' 2 | import { 3 | wrappedRender, 4 | describe, 5 | it, 6 | screen, 7 | expect, 8 | beforeEach 9 | } from '@vitest-utils/utils' 10 | import { vi } from 'vitest' 11 | 12 | const username = 'testuser' 13 | const userIdentifier = 'admin' 14 | 15 | describe('', () => { 16 | beforeEach(() => { 17 | // Set up localStorage with user data 18 | Object.defineProperty(window, 'localStorage', { 19 | value: { 20 | getItem: vi.fn((key) => { 21 | if (key === 'seon-user-identity') { 22 | return JSON.stringify({ username, userIdentifier }) 23 | } 24 | return null 25 | }), 26 | setItem: vi.fn(), 27 | removeItem: vi.fn(), 28 | clear: vi.fn() 29 | }, 30 | writable: true 31 | }) 32 | }) 33 | 34 | it('renders with user information', () => { 35 | wrappedRender() 36 | 37 | expect(screen.getByText(username)).toBeInTheDocument() 38 | expect(screen.getByText(userIdentifier)).toBeInTheDocument() 39 | expect(screen.getByRole('button', { name: /logout/i })).toBeInTheDocument() 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /sample-app/shared/types/movie-types.ts: -------------------------------------------------------------------------------- 1 | import type { z } from 'zod' 2 | 3 | import type { 4 | CreateMovieResponseSchema, 5 | CreateMovieSchema, 6 | GetMovieResponseUnionSchema, 7 | MovieNotFoundResponseSchema, 8 | DeleteMovieResponseSchema, 9 | ConflictMovieResponseSchema, 10 | UpdateMovieSchema, 11 | UpdateMovieResponseSchema 12 | } from './schema' 13 | 14 | // Zod key feature 2: link the schemas to the types 15 | 16 | export type CreateMovieRequest = z.infer 17 | 18 | export type CreateMovieResponse = z.infer 19 | 20 | export type ConflictMovieResponse = z.infer 21 | 22 | export type GetMovieResponse = z.infer 23 | 24 | export type MovieNotFoundResponse = z.infer 25 | 26 | export type DeleteMovieResponse = z.infer 27 | 28 | export type UpdateMovieRequest = z.infer 29 | 30 | export type UpdateMovieResponse = z.infer 31 | 32 | export type Movie = { 33 | id: number 34 | name: string 35 | year: number 36 | rating: number 37 | director: string 38 | } 39 | -------------------------------------------------------------------------------- /src/api-request/schema-validation/internal/promise-extension.ts: -------------------------------------------------------------------------------- 1 | /** Promise extension to add validateSchema method to promises */ 2 | 3 | import type { 4 | SupportedSchema, 5 | ValidateSchemaOptions, 6 | ValidatedApiResponse 7 | } from '../types' 8 | import type { EnhancedApiResponse } from './response-extension' 9 | 10 | /** Enhanced Promise with validateSchema method */ 11 | export interface EnhancedApiPromise 12 | extends Promise> { 13 | validateSchema( 14 | schema: SupportedSchema, 15 | options?: ValidateSchemaOptions 16 | ): Promise> 17 | } 18 | 19 | /** Create enhanced promise with validateSchema method */ 20 | export function createEnhancedPromise( 21 | promise: Promise> 22 | ): EnhancedApiPromise { 23 | const enhanced = promise as EnhancedApiPromise 24 | 25 | enhanced.validateSchema = async ( 26 | schema: SupportedSchema, 27 | options: ValidateSchemaOptions = {} 28 | ): Promise> => { 29 | const response = await promise 30 | return response.validateSchema(schema, options) 31 | } 32 | 33 | return enhanced 34 | } 35 | -------------------------------------------------------------------------------- /src/network-recorder/network-recorder.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Network recorder main export - provides direct function access 3 | * 4 | * This module provides functional access to network recording capabilities 5 | * for users who prefer direct function calls over fixtures. 6 | */ 7 | 8 | // Re-export main functionality 9 | export { NetworkRecorder, createNetworkRecorder } from './core' 10 | 11 | // Re-export utility functions 12 | export { 13 | generateHarFilePath, 14 | ensureHarDirectory, 15 | validateHarFileForPlayback, 16 | acquireHarFileLock, 17 | removeHarFile, 18 | createUniqueHarFilePath, 19 | getHarFileStats 20 | } from './core' 21 | 22 | // Re-export mode detection functions 23 | export { 24 | detectNetworkMode, 25 | isValidNetworkMode, 26 | getEffectiveNetworkMode, 27 | isNetworkModeActive, 28 | getModeDefaults, 29 | validateModeConfiguration, 30 | getModeDescription 31 | } from './core' 32 | 33 | // Re-export types 34 | export type { 35 | NetworkMode, 36 | NETWORK_MODE_ENV_VAR, 37 | HarFileOptions, 38 | HarRecordingOptions, 39 | HarPlaybackOptions, 40 | NetworkRecorderConfig, 41 | NetworkRecorderContext, 42 | NetworkRecorderError, 43 | HarFileError, 44 | ModeDetectionError 45 | } from './core' 46 | -------------------------------------------------------------------------------- /src/auth-session/global-setup-helper.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Global setup helper for Playwright Auth Session 3 | * Provides utilities for initializing authentication in global setup 4 | */ 5 | import type { APIRequestContext } from '@playwright/test' 6 | import { getAuthToken } from './core' 7 | 8 | /** 9 | * Initialize authentication token during Playwright's global setup. 10 | * This helper simplifies the integration into the globalSetup function. 11 | * 12 | * @param request - Playwright APIRequestContext for making API calls 13 | * @param options - Optional environment and user identifier settings 14 | * @returns Promise that resolves when auth initialization is complete 15 | */ 16 | export async function initializeAuthForGlobalSetup( 17 | request: APIRequestContext, 18 | options?: { environment?: string; userIdentifier?: string } 19 | ): Promise { 20 | console.log('Initializing auth token') 21 | 22 | try { 23 | // Fetch and store the token 24 | await getAuthToken(request, options) 25 | console.log('Auth token initialized successfully') 26 | // Function returns void, no need to return the token 27 | } catch (error) { 28 | console.error('Failed to initialize auth token:', error) 29 | throw error 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /sample-app/backend/jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { JestConfigWithTsJest } from 'ts-jest' 2 | 3 | export const config: JestConfigWithTsJest = { 4 | clearMocks: true, 5 | testTimeout: 10000, 6 | collectCoverageFrom: [ 7 | 'src/**/*.ts', 8 | '!**/*.json', 9 | '!?(**)/?(*.|*-)types.ts', 10 | '!**/models/*', 11 | '!**/__snapshots__/*', 12 | '!**/scripts/*' 13 | ], 14 | coverageDirectory: './coverage', 15 | coverageReporters: [ 16 | 'clover', 17 | 'json', 18 | 'lcov', 19 | ['text', { skipFull: true }], 20 | 'json-summary' 21 | ], 22 | coverageThreshold: { 23 | global: { 24 | statements: 0, 25 | branches: 0, 26 | lines: 0, 27 | functions: 0 28 | } 29 | }, 30 | moduleDirectories: ['node_modules', 'src'], 31 | modulePathIgnorePatterns: ['dist'], 32 | testMatch: ['**/*.test.ts'], 33 | testEnvironment: 'node', 34 | 35 | // Added ESM support 36 | preset: 'ts-jest/presets/default-esm', 37 | extensionsToTreatAsEsm: ['.ts'], 38 | moduleNameMapper: { 39 | '^(\\.{1,2}/.*)\\.js$': '$1' 40 | }, 41 | transform: { 42 | '^.+\\.tsx?$': [ 43 | 'ts-jest', 44 | { 45 | useESM: true 46 | } 47 | ] 48 | } 49 | } 50 | 51 | export default config 52 | -------------------------------------------------------------------------------- /src/intercept-network-call/core/types.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response } from '@playwright/test' 2 | 3 | /** 4 | * Generic network call result with typed request and response data 5 | */ 6 | export type NetworkCallResult = { 7 | request: Request | null 8 | response: Response | null 9 | responseJson: TResponse | null 10 | status: number 11 | requestJson: TRequest | null 12 | } 13 | 14 | /** 15 | * Custom error for network interception operations 16 | */ 17 | export class NetworkInterceptError extends Error { 18 | constructor( 19 | message: string, 20 | public readonly operation: 'observe' | 'fulfill', 21 | public readonly url?: string, 22 | public readonly method?: string 23 | ) { 24 | super(message) 25 | this.name = 'NetworkInterceptError' 26 | } 27 | } 28 | 29 | /** 30 | * Timeout error for network operations 31 | */ 32 | export class NetworkTimeoutError extends NetworkInterceptError { 33 | constructor( 34 | message: string, 35 | operation: 'observe' | 'fulfill', 36 | public readonly timeoutMs: number, 37 | url?: string, 38 | method?: string 39 | ) { 40 | super(message, operation, url, method) 41 | this.name = 'NetworkTimeoutError' 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /sample-app/frontend/src/components/login/login-form-err.vitest.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | wrappedRender, 3 | screen, 4 | worker, 5 | http, 6 | describe, 7 | it, 8 | expect, 9 | userEvent 10 | } from '@vitest-utils/utils' 11 | import LoginForm from './login-form' 12 | 13 | // Setup user event 14 | const user = userEvent.setup() 15 | 16 | describe('LoginForm err', () => { 17 | it('should show error message on authentication failure', async () => { 18 | const errorMessage = 19 | 'Authentication failed: Server did not set authentication cookies' 20 | 21 | worker.use( 22 | http.post('/api/auth/identity-token', () => { 23 | return new Response( 24 | JSON.stringify({ 25 | error: 'auth_failed', 26 | message: errorMessage 27 | }), 28 | { 29 | status: 401, 30 | headers: { 'Content-Type': 'application/json' } 31 | } 32 | ) 33 | }) 34 | ) 35 | 36 | wrappedRender() 37 | 38 | await user.type(screen.getByPlaceholderText('Username'), 'testuser') 39 | await user.type(screen.getByPlaceholderText('Password'), 'wrongpassword') 40 | await user.click(screen.getByText('Log In')) 41 | 42 | expect(await screen.findByText(errorMessage)).toBeInTheDocument() 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /src/api-request/schema-validation/fixture.ts: -------------------------------------------------------------------------------- 1 | import { test as base } from '@playwright/test' 2 | import { validateSchema as validateSchemaFunction } from './core' 3 | import type { 4 | SupportedSchema, 5 | ValidateSchemaOptions, 6 | ValidationResult 7 | } from './types' 8 | 9 | /** 10 | * Fixture that provides the validateSchema function for schema validation 11 | */ 12 | export const test = base.extend<{ 13 | /** 14 | * Validates data against a schema with optional shape assertions 15 | * 16 | * @example 17 | * test('validate response schema', async ({ validateSchema }) => { 18 | * const response = await fetch('/api/data') 19 | * const data = await response.json() 20 | * 21 | * const validated = await validateSchema(MySchema, data) 22 | * expect(validated.status).toBe(200) 23 | * }) 24 | */ 25 | validateSchema: ( 26 | schema: SupportedSchema, 27 | data: unknown, 28 | options?: ValidateSchemaOptions 29 | ) => Promise 30 | }>({ 31 | validateSchema: async ({}, use) => { 32 | const validateSchema = async ( 33 | schema: SupportedSchema, 34 | data: unknown, 35 | options?: ValidateSchemaOptions 36 | ): Promise => { 37 | return await validateSchemaFunction(data, schema, options) 38 | } 39 | 40 | await use(validateSchema) 41 | } 42 | }) 43 | -------------------------------------------------------------------------------- /sample-app/backend/src/utils/format-response.ts: -------------------------------------------------------------------------------- 1 | import type { Response } from 'express' 2 | import type { 3 | ConflictMovieResponse, 4 | CreateMovieResponse, 5 | DeleteMovieResponse, 6 | GetMovieResponse, 7 | MovieNotFoundResponse, 8 | UpdateMovieResponse 9 | } from '../../../shared/types' 10 | 11 | type MovieResponse = 12 | | DeleteMovieResponse 13 | | GetMovieResponse 14 | | UpdateMovieResponse 15 | | CreateMovieResponse 16 | | MovieNotFoundResponse 17 | | ConflictMovieResponse 18 | 19 | export function formatResponse(res: Response, result: MovieResponse): Response { 20 | if ('error' in result && result.error) { 21 | return res 22 | .status(result.status) 23 | .json({ status: result.status, error: result.error }) 24 | } else if ('data' in result) { 25 | if (result.data === null) { 26 | return res 27 | .status(result.status) 28 | .json({ status: result.status, error: 'No movies found' }) 29 | } 30 | return res 31 | .status(result.status) 32 | .json({ status: result.status, data: result.data }) 33 | } else if ('message' in result) { 34 | // Handle delete movie case with a success message 35 | return res 36 | .status(result.status) 37 | .json({ status: result.status, message: result.message }) 38 | } else { 39 | return res.status(500).json({ error: 'Unexpected error occurred' }) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /sample-app/frontend/src/components/validation-error-display.vitest.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { 3 | describe, 4 | expect, 5 | it, 6 | screen, 7 | wrappedRender 8 | } from '@vitest-utils/utils' 9 | import ValidationErrorDisplay from './validation-error-display' 10 | import { ZodError } from 'zod' 11 | 12 | describe('', () => { 13 | it('should not render when there is no validation error', () => { 14 | wrappedRender() 15 | 16 | expect(screen.queryByTestId('validation-error')).not.toBeInTheDocument() 17 | }) 18 | 19 | it('should render validation errors correctly', () => { 20 | const mockError = new ZodError([ 21 | { 22 | path: ['name'], 23 | message: 'Name is required', 24 | code: 'invalid_type', 25 | expected: 'string' 26 | } as any, 27 | { 28 | path: ['year'], 29 | message: 'Year must be a number', 30 | code: 'invalid_type', 31 | expected: 'number' 32 | } as any 33 | ]) 34 | 35 | wrappedRender() 36 | 37 | expect(screen.getAllByTestId('validation-error')).toHaveLength(2) 38 | expect(screen.getByText('Name is required')).toBeVisible() 39 | expect(screen.getByText('Year must be a number')).toBeVisible() 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /sample-app/frontend/src/components/movie-details/movie-manager.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { useState } from 'react' 3 | import MovieEditForm from './movie-edit-form' 4 | import { SButton } from '@styles/styled-components' 5 | import { MovieInfo } from '@components/movie-item' 6 | import type { Movie } from '@shared/types/movie-types' 7 | 8 | export type MovieManagerProps = { 9 | readonly movie: Movie 10 | readonly onDelete: (id: number) => void 11 | } 12 | 13 | export default function MovieManager({ movie, onDelete }: MovieManagerProps) { 14 | const [isEditing, setIsEditing] = useState(false) 15 | 16 | return ( 17 | 18 | {isEditing ? ( 19 | setIsEditing(false)} /> 20 | ) : ( 21 | <> 22 | 23 | setIsEditing(true)}> 24 | Edit 25 | 26 | onDelete(movie.id)} 29 | > 30 | Delete 31 | 32 | 33 | )} 34 | 35 | ) 36 | } 37 | 38 | const SMovieManager = styled.div` 39 | h2 { 40 | margin-top: 20px; 41 | color: #333; 42 | font-size: 24px; 43 | } 44 | p { 45 | font-size: 18px; 46 | color: #555; 47 | } 48 | ` 49 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [3.9.0] - 2025-11-19 (Current Release) 9 | 10 | ### Added 11 | 12 | - Network recorder utility with HAR-based recording and playback 13 | - Intelligent CRUD detection for stateful mocking 14 | - Network error monitor for automatic HTTP error detection 15 | - Burn-in utility with smart test filtering 16 | - API request utility with schema validation support (JSON Schema, OpenAPI, Zod) 17 | - Auth session management with token persistence 18 | - File utilities for CSV, XLSX, PDF, ZIP handling 19 | - Structured logging integrated with Playwright reports 20 | - Network interception for request/response mocking 21 | - Polling utility (recurse) for async conditions 22 | - Dual module support (CommonJS and ES Modules) 23 | - Comprehensive TypeScript type definitions 24 | - Full Playwright fixtures integration 25 | - "Functional core, fixture shell" pattern throughout 26 | 27 | ### Changed 28 | 29 | - Open sourced under MIT license 30 | - Repository made public at github.com/seontechnologies/playwright-utils 31 | - Published to public npm registry for easier installation 32 | 33 | ## [Earlier versions] 34 | 35 | See git commit history for version details prior to open source release. 36 | -------------------------------------------------------------------------------- /sample-app/frontend/src/components/movie-details/movie-manager.vitest.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | wrappedRender, 3 | screen, 4 | describe, 5 | it, 6 | expect, 7 | userEvent 8 | } from '@vitest-utils/utils' 9 | import { vi } from 'vitest' 10 | import type { MovieManagerProps } from './movie-manager' 11 | import MovieManager from './movie-manager' 12 | 13 | describe('', () => { 14 | const id = 1 15 | const name = 'Inception' 16 | const year = 2010 17 | const rating = 8.5 18 | const director = 'Christopher Nolan' 19 | 20 | it('should toggle between movie info and movie edit components', async () => { 21 | const onDelete = vi.fn() 22 | const props: MovieManagerProps = { 23 | movie: { 24 | id, 25 | name, 26 | year, 27 | rating, 28 | director 29 | }, 30 | onDelete 31 | } 32 | 33 | wrappedRender() 34 | 35 | await userEvent.click(screen.getByTestId('delete-movie')) 36 | expect(onDelete).toHaveBeenCalledOnce() 37 | expect(onDelete).toHaveBeenCalledWith(id) 38 | 39 | expect(screen.getByTestId('movie-info-comp')).toBeVisible() 40 | expect(screen.queryByTestId('movie-edit-form-comp')).not.toBeInTheDocument() 41 | 42 | await userEvent.click(screen.getByTestId('edit-movie')) 43 | expect(screen.queryByTestId('movie-info-comp')).not.toBeInTheDocument() 44 | expect(screen.getByTestId('movie-edit-form-comp')).toBeVisible() 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /sample-app/frontend/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/internal/logger.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Shared logger interface and utility for internal use 3 | * This avoids circular dependencies between modules 4 | * 5 | * Usage: 6 | * - Configure at startup from index.ts 7 | * - Use getLogger() in other modules 8 | */ 9 | 10 | export interface Logger { 11 | info: (message: string) => Promise 12 | step: (message: string) => Promise 13 | success: (message: string) => Promise 14 | warning: (message: string) => Promise 15 | error: (message: string) => Promise 16 | debug: (message: string) => Promise 17 | } 18 | 19 | // Singleton instance to be configured at startup, with default fallback to console 20 | let logger: Logger = { 21 | info: async (message: string) => console.log(message), 22 | step: async (message: string) => console.log(message), 23 | success: async (message: string) => console.log(message), 24 | warning: async (message: string) => console.warn(message), 25 | error: async (message: string) => console.error(message), 26 | debug: async (message: string) => console.debug(message) 27 | } 28 | 29 | /** 30 | * Configure the shared logger instance 31 | * Called once at app initialization from index.ts */ 32 | export const configureLogger = (loggerImplementation: Logger): void => { 33 | logger = loggerImplementation 34 | } 35 | 36 | /** 37 | * Get the shared logger instance 38 | * Used by all modules that need logging functionality */ 39 | export const getLogger = (): Logger => logger 40 | -------------------------------------------------------------------------------- /src/intercept-network-call/core/utils/matches-request.ts: -------------------------------------------------------------------------------- 1 | import type { Request } from '@playwright/test' 2 | 3 | import picomatch from 'picomatch' 4 | 5 | /** Creates a URL matcher function based on the provided glob pattern. 6 | * @param {string} [pattern] - Glob pattern to match URLs against. 7 | * @returns {(url: string) => boolean} - A function that takes a URL and returns whether it matches the pattern. 8 | */ 9 | const createUrlMatcher = (pattern?: string) => { 10 | if (!pattern) return () => true 11 | 12 | const globPattern = pattern.startsWith('**') ? pattern : `**${pattern}` 13 | const isMatch = picomatch(globPattern) 14 | 15 | return isMatch 16 | } 17 | 18 | /** Determines whether a network request matches the specified method and URL pattern. 19 | * * @param {Request} request - The network request to evaluate. 20 | * @param {string} [method] - HTTP method to match. 21 | * @param {string} [urlPattern] - URL pattern to match. 22 | * @returns {boolean} - `true` if the request matches both the method and URL pattern; otherwise, `false`. 23 | */ 24 | export const matchesRequest = ( 25 | request: Request, 26 | method?: string, 27 | urlPattern?: string 28 | ): boolean => { 29 | const matchesMethod = !method || request.method() === method 30 | 31 | const matcher = createUrlMatcher(urlPattern) // Step 1: Create the matcher function 32 | const matchesUrl = matcher(request.url()) // Step 2: Use the matcher with the URL 33 | 34 | return matchesMethod && matchesUrl 35 | } 36 | -------------------------------------------------------------------------------- /sample-app/frontend/src/test-utils/vitest-utils/auth-handlers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Mock Service Worker handlers for authentication endpoints 3 | * These handlers will intercept authentication-related requests during tests 4 | */ 5 | import { http, HttpResponse } from 'msw' 6 | 7 | const API_URL = 'http://localhost:3001' 8 | 9 | // Generate fake token response 10 | const generateTokenResponse = () => { 11 | const now = new Date() 12 | const expiresAt = new Date(now.getTime() + 60 * 60 * 1000) // 1 hour expiration 13 | const refreshExpiresAt = new Date(now.getTime() + 24 * 60 * 60 * 1000) // 24 hours refresh expiration 14 | 15 | return { 16 | token: `fake-jwt-token-${Date.now()}`, 17 | refreshToken: `fake-refresh-token-${Date.now()}`, 18 | expiresAt: expiresAt.toISOString(), 19 | refreshExpiresAt: refreshExpiresAt.toISOString() 20 | } 21 | } 22 | 23 | // Mock auth handlers 24 | export const authHandlers = [ 25 | // POST /auth/fake-token - Initial token acquisition 26 | http.post(`${API_URL}/auth/fake-token`, () => { 27 | return HttpResponse.json(generateTokenResponse(), { status: 200 }) 28 | }), 29 | 30 | // POST /auth/renew - Token renewal 31 | http.post(`${API_URL}/auth/renew`, () => { 32 | return HttpResponse.json(generateTokenResponse(), { status: 200 }) 33 | }), 34 | 35 | // POST /auth/identity-token - Identity-based authentication 36 | http.post(`${API_URL}/auth/identity-token`, () => { 37 | return HttpResponse.json(generateTokenResponse(), { status: 200 }) 38 | }) 39 | ] 40 | -------------------------------------------------------------------------------- /sample-app/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sample-app-frontend", 3 | "version": "1.0.0", 4 | "description": "Sample frontend for testing playwright-utils", 5 | "main": "index.js", 6 | "private": true, 7 | "author": "Murat Ozcan", 8 | "license": "ISC", 9 | "scripts": { 10 | "start": "vite", 11 | "build": "tsc && vite build", 12 | "serve": "vite preview", 13 | "vitest:run": "vitest run --config vitest.headless.config.ts", 14 | "vitest:open": "vitest --config vitest.browser.config.ts" 15 | }, 16 | "dependencies": { 17 | "@tanstack/react-query": "5.90.7", 18 | "axios": "1.13.2", 19 | "jsdom": "26.1.0", 20 | "react": "19.2.0", 21 | "react-dom": "19.2.0", 22 | "react-error-boundary": "5.0.0", 23 | "react-router-dom": "7.9.5", 24 | "styled-components": "6.1.19" 25 | }, 26 | "devDependencies": { 27 | "@testing-library/dom": "10.4.1", 28 | "@testing-library/jest-dom": "6.9.1", 29 | "@testing-library/react": "16.3.0", 30 | "@testing-library/user-event": "14.6.1", 31 | "@types/node": "22.19.0", 32 | "@types/picomatch": "4.0.2", 33 | "@types/react": "19.2.2", 34 | "@types/react-dom": "19.2.2", 35 | "@types/sinon": "17.0.3", 36 | "@vitejs/plugin-react": "4.7.0", 37 | "@vitest/browser": "3.2.4", 38 | "happy-dom": "16.8.1", 39 | "identity-obj-proxy": "3.0.0", 40 | "msw": "2.12.1", 41 | "nock": "13.5.6", 42 | "sinon": "19.0.5", 43 | "vite": "^6.4.1", 44 | "vitest-browser-react": "0.3.0" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/file-utils/file-utils-fixture.ts: -------------------------------------------------------------------------------- 1 | import { test as base } from '@playwright/test' 2 | import { handleDownload } from './core/file-downloader' 3 | import * as csvReader from './core/csv-reader' 4 | import * as xlsxReader from './core/xlsx-reader' 5 | import * as pdfReader from './core/pdf-reader' 6 | import * as zipReader from './core/zip-reader' 7 | 8 | type AutoHandleDownload = ( 9 | options: Omit[0], 'page'> 10 | ) => ReturnType 11 | 12 | type FileUtilsFixtures = { 13 | handleDownload: AutoHandleDownload 14 | readCSV: typeof csvReader.readCSV 15 | readXLSX: typeof xlsxReader.readXLSX 16 | readPDF: typeof pdfReader.readPDF 17 | readZIP: typeof zipReader.readZIP 18 | } 19 | 20 | export const test = base.extend({ 21 | // File Downloader (with automatic page injection) 22 | handleDownload: async ({ page }, use) => { 23 | // Create a wrapped version that auto-injects the page parameter 24 | const wrappedHandleDownload: AutoHandleDownload = (options) => { 25 | return handleDownload({ ...options, page }) 26 | } 27 | await use(wrappedHandleDownload) 28 | }, 29 | 30 | readCSV: async ({}, use) => { 31 | await use(csvReader.readCSV) 32 | }, 33 | 34 | readXLSX: async ({}, use) => { 35 | await use(xlsxReader.readXLSX) 36 | }, 37 | 38 | readPDF: async ({}, use) => { 39 | await use(pdfReader.readPDF) 40 | }, 41 | 42 | readZIP: async ({}, use) => { 43 | await use(zipReader.readZIP) 44 | } 45 | }) 46 | -------------------------------------------------------------------------------- /.github/workflows/vitest-ct.yml: -------------------------------------------------------------------------------- 1 | name: Run ct with Vitest 2 | on: 3 | push: 4 | workflow_dispatch: 5 | 6 | # if this branch is pushed back to back, cancel the older branch's workflow 7 | concurrency: 8 | group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} 9 | cancel-in-progress: true 10 | 11 | env: 12 | VITE_PORT: 3000 13 | API_PORT: 3001 14 | VITE_API_URL: 'http://localhost:3001' 15 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 16 | 17 | jobs: 18 | vitest-ct: 19 | timeout-minutes: 10 20 | runs-on: kubernetes-default 21 | steps: 22 | - uses: actions/checkout@v4 23 | 24 | - uses: actions/setup-node@v4 25 | with: 26 | node-version-file: '.nvmrc' 27 | cache: 'npm' 28 | 29 | - name: Cache Playwright Browsers 30 | uses: actions/cache@v4 31 | with: 32 | path: ~/.cache/ms-playwright 33 | key: playwright-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} 34 | restore-keys: | 35 | playwright-${{ runner.os }}- 36 | 37 | - name: Cache node modules 38 | uses: actions/cache@v4 39 | with: 40 | path: ~/.npm 41 | key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} 42 | restore-keys: | 43 | npm-${{ runner.os }}- 44 | 45 | - name: Install dependencies 46 | run: npm ci 47 | 48 | - name: Install Playwright browsers 49 | run: npx playwright install --with-deps 50 | 51 | - name: Run Vitest 52 | run: npm run test:frontend 53 | -------------------------------------------------------------------------------- /.github/workflows/pr-checks.yml: -------------------------------------------------------------------------------- 1 | name: Run PR checks 2 | on: 3 | pull_request: 4 | workflow_dispatch: 5 | 6 | # if this branch is pushed back to back, cancel the older branch's workflow 7 | concurrency: 8 | group: ${{ github.ref }} && ${{ github.workflow }} 9 | cancel-in-progress: true 10 | 11 | env: 12 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 13 | 14 | jobs: 15 | pr-check: 16 | runs-on: kubernetes-default 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - uses: actions/setup-node@v4 21 | with: 22 | node-version-file: '.nvmrc' 23 | cache: 'npm' 24 | 25 | - name: Cache node modules 26 | uses: actions/cache@v4 27 | with: 28 | path: ~/.npm 29 | key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} 30 | restore-keys: | 31 | npm-${{ runner.os }}- 32 | 33 | - name: Install dependencies 34 | run: npm ci 35 | 36 | # Prisma needs to generate TypeScript types from our schema before typechecking runs 37 | # Locally, this happens when we start the app. 38 | - name: Generate Prisma types 39 | run: cd sample-app/backend && npx prisma generate 40 | 41 | - name: Run typecheck 42 | run: npm run typecheck 43 | 44 | - name: Run lint 45 | run: npm run lint 46 | 47 | - name: Verify build process 48 | run: npm run build 49 | 50 | - name: Run unit test for backend 51 | run: npm run test:backend 52 | 53 | # frontend tests are in their own file: vitest-ct.yml 54 | -------------------------------------------------------------------------------- /sample-app/frontend/src/components/movie-details/movie-edit-form.vitest.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | wrappedRender, 3 | screen, 4 | worker, 5 | http, 6 | describe, 7 | it, 8 | expect, 9 | userEvent, 10 | waitFor 11 | } from '@vitest-utils/utils' 12 | import { vi } from 'vitest' 13 | import MovieEditForm from './movie-edit-form' 14 | import { generateMovieWithoutId } from '../../test-utils/factories' 15 | import type { Movie } from '@shared/types/movie-types' 16 | 17 | describe('', () => { 18 | const id = 7 19 | const movie: Movie = { id, ...generateMovieWithoutId() } 20 | 21 | it('should cancel and submit a movie update', async () => { 22 | const onCancel = vi.fn() 23 | 24 | wrappedRender() 25 | 26 | await userEvent.click(screen.getByTestId('cancel')) 27 | expect(onCancel).toHaveBeenCalledOnce() 28 | 29 | let putRequest: Record | undefined 30 | worker.use( 31 | http.put(`http://localhost:3001/movies/${id}`, async ({ request }) => { 32 | const requestBody = await request.json() 33 | putRequest = requestBody as Record 34 | return new Response(undefined, { status: 200 }) 35 | }) 36 | ) 37 | 38 | await userEvent.click(screen.getByTestId('update-movie')) 39 | 40 | await waitFor(() => { 41 | expect(putRequest).toMatchObject({ 42 | name: movie.name, 43 | year: movie.year, 44 | rating: movie.rating, 45 | director: movie.director 46 | }) 47 | }) 48 | }) 49 | }) 50 | -------------------------------------------------------------------------------- /sample-app/frontend/src/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /sample-app/backend/src/middleware/user-identifier-middleware.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response, NextFunction } from 'express' 2 | 3 | /** 4 | * Middleware to check if a user has the required user identifier(s) 5 | * @param requiredIdentifiers - One or more user identifiers that are allowed to access the route 6 | * @returns Middleware function that checks if the user has one of the required identifiers 7 | */ 8 | export function requireUserIdentifier(requiredIdentifiers: string | string[]) { 9 | // Convert single identifier to array 10 | const identifiers = Array.isArray(requiredIdentifiers) 11 | ? requiredIdentifiers 12 | : [requiredIdentifiers] 13 | 14 | return function userIdentifierMiddleware( 15 | req: Request, 16 | res: Response, 17 | next: NextFunction 18 | ) { 19 | // Get the user identity from the auth middleware 20 | const user = res.locals.user 21 | 22 | // No user identity found 23 | if (!user || !user.identity || !user.identity.userIdentifier) { 24 | return res.status(403).json({ 25 | error: 'Access denied: Identity information missing', 26 | status: 403 27 | }) 28 | } 29 | 30 | // Check if user has one of the required identifiers 31 | const userIdentifier = user.identity.userIdentifier 32 | 33 | if (!identifiers.includes(userIdentifier)) { 34 | return res.status(403).json({ 35 | error: `Access denied: User identifier '${userIdentifier}' is not authorized for this resource`, 36 | status: 403 37 | }) 38 | } 39 | 40 | // User has required user identifier, proceed 41 | next() 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/auth-session/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Playwright Auth Session Library 3 | * A reusable authentication session management system for Playwright 4 | */ 5 | 6 | // Public Types 7 | export type { 8 | TokenDataFormatter, 9 | PlaywrightStorageState, 10 | DefaultTokenDataFormatter, 11 | RetryConfig, 12 | AuthSessionOptions, 13 | AuthOptions, 14 | AuthFixtures 15 | } from './internal/types' 16 | 17 | // Core API functions 18 | export { 19 | configureAuthSession, 20 | getAuthToken, 21 | clearAuthToken, 22 | loadStorageState 23 | } from './core' 24 | 25 | // Global setup helper (optional) 26 | export { initializeAuthForGlobalSetup } from './global-setup-helper' 27 | 28 | // Ephemeral auth 29 | export { applyUserCookiesToBrowserContext } from './apply-user-cookies-to-browser-context' 30 | 31 | // Storage utilities 32 | export { 33 | getStorageStatePath, 34 | getTokenFilePath, 35 | getStorageDir, 36 | saveStorageState 37 | } from './internal/auth-storage-utils' 38 | 39 | // Global initialization utilities 40 | export { authStorageInit, authGlobalInit } from './internal/auth-global-setup' 41 | 42 | // Token management 43 | export { AuthSessionManager } from './internal/auth-session' 44 | 45 | // Auth Provider API 46 | export { 47 | type AuthProvider, 48 | setAuthProvider, 49 | getAuthProvider 50 | } from './internal/auth-provider' 51 | 52 | // Cache Management 53 | export { 54 | TokenCacheManager, 55 | globalTokenCache, 56 | type CacheConfig, 57 | type CacheMetrics 58 | } from './internal/cache-manager' 59 | 60 | // Test fixtures 61 | export { createAuthFixtures, createRoleSpecificTest } from './fixtures' 62 | -------------------------------------------------------------------------------- /sample-app/backend/src/movie-repository.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | GetMovieResponse, 3 | CreateMovieRequest, 4 | CreateMovieResponse, 5 | MovieNotFoundResponse, 6 | ConflictMovieResponse, 7 | DeleteMovieResponse, 8 | UpdateMovieRequest, 9 | UpdateMovieResponse 10 | } from '../../shared/types' 11 | 12 | // MovieRepository: this is the interface/contract that defines the methods 13 | // for interacting with the data layer. 14 | // It's a port in hexagonal architecture. 15 | 16 | /* 17 | API (Driving Adapter - entry point) 18 | | 19 | v 20 | +----------------------------+ 21 | | MovieService | 22 | | (Application Core/Hexagon) | 23 | +----------------------------+ 24 | | 25 | v 26 | MovieRepository (Port) 27 | | 28 | v 29 | MovieAdapter (Driven Adapter - 2ndary, interacts with outside) 30 | | 31 | v 32 | Database 33 | */ 34 | 35 | export interface MovieRepository { 36 | getMovies(): Promise 37 | getMovieById(id: number): Promise 38 | getMovieByName( 39 | name: string 40 | ): Promise 41 | deleteMovieById( 42 | id: number 43 | ): Promise 44 | addMovie( 45 | data: CreateMovieRequest, 46 | id?: number 47 | ): Promise 48 | updateMovie( 49 | data: UpdateMovieRequest, 50 | id: number 51 | ): Promise< 52 | UpdateMovieResponse | MovieNotFoundResponse | ConflictMovieResponse 53 | > 54 | } 55 | -------------------------------------------------------------------------------- /playwright/tests/network-record-playback/playback-functionality.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | import { promises as fs } from 'fs' 3 | import path from 'node:path' 4 | import { log } from 'src/log' 5 | 6 | test.describe('Network Recorder - Playback Functionality', () => { 7 | const testHarDir = 'har-files/network-recorder-tests' 8 | 9 | test.beforeEach(async () => { 10 | await log.step('Clean up test directory') 11 | try { 12 | await fs.rm(testHarDir, { recursive: true, force: true }) 13 | } catch { 14 | // Directory doesn't exist, that's fine 15 | } 16 | }) 17 | 18 | test('should handle missing HAR file gracefully', async () => { 19 | const harPath = path.join(testHarDir, 'non-existent.har') 20 | 21 | await log.step('Verify HAR file does not exist') 22 | const harExists = await fs 23 | .access(harPath) 24 | .then(() => true) 25 | .catch(() => false) 26 | expect(harExists).toBe(false) 27 | 28 | await log.step( 29 | 'This test validates the error handling path without needing the full network recorder setup' 30 | ) 31 | expect(true).toBe(true) 32 | }) 33 | 34 | test('should handle malformed HAR files gracefully', async () => { 35 | const testDir = path.join(testHarDir, 'test') 36 | const harPath = path.join(testDir, 'invalid.har') 37 | await fs.mkdir(path.dirname(harPath), { recursive: true }) 38 | await fs.writeFile(harPath, 'not valid json') 39 | 40 | await log.step('Verify file exists but is invalid JSON') 41 | const content = await fs.readFile(harPath, 'utf-8') 42 | expect(content).toBe('not valid json') 43 | expect(() => JSON.parse(content)).toThrow() 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /src/recurse/recurse-fixture.ts: -------------------------------------------------------------------------------- 1 | import { test as base } from '@playwright/test' 2 | import { recurse as recurseFunction } from './recurse' 3 | 4 | export const test = base.extend<{ 5 | /** 6 | * Re-runs a function until the predicate returns true or timeout is reached. 7 | * This fixture version provides the same functionality as the direct import 8 | * but can be used within test fixtures. 9 | * 10 | * @example 11 | * // Poll until session becomes active 12 | * test('wait for activation', async ({ recurse }) => { 13 | * const session = await recurse( 14 | * () => apiRequest({ method: 'GET', url: '/session' }), 15 | * (response) => response.body.status === 'ACTIVE', 16 | * { timeout: 60000, interval: 2000 } 17 | * ); 18 | * 19 | * expect(session.body.id).toBeDefined(); 20 | * }); 21 | * 22 | * @example 23 | * // Poll with custom logging 24 | * test('custom logging', async ({ recurse }) => { 25 | * await recurse( 26 | * () => fetchData(), 27 | * (data) => data.isReady, 28 | * { 29 | * log: 'Waiting for data to be ready', 30 | * timeout: 15000 31 | * } 32 | * ); 33 | * }); 34 | */ 35 | recurse: ( 36 | command: () => Promise, 37 | predicate: (value: T) => boolean, 38 | options?: Record 39 | ) => Promise 40 | }>({ 41 | recurse: async ({}, use) => { 42 | const recurse = async ( 43 | command: () => Promise, 44 | predicate: (value: T) => boolean, 45 | options: Record = {} 46 | ): Promise => recurseFunction(command, predicate, options) 47 | 48 | await use(recurse) 49 | } 50 | }) 51 | 52 | export { expect } from '@playwright/test' 53 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | es2021: true, 4 | node: true 5 | }, 6 | extends: [ 7 | 'eslint:recommended', 8 | 'plugin:@typescript-eslint/recommended', 9 | 'plugin:import/typescript', 10 | 'plugin:import/recommended', 11 | 'plugin:prettier/recommended' 12 | ], 13 | parser: '@typescript-eslint/parser', 14 | parserOptions: { 15 | ecmaVersion: 'latest', 16 | sourceType: 'module', 17 | project: ['./tsconfig.json'] 18 | }, 19 | plugins: [ 20 | '@typescript-eslint', 21 | 'filenames', 22 | 'implicit-dependencies', 23 | 'no-only-tests' 24 | ], 25 | settings: { 26 | 'import/resolver': { 27 | typescript: { 28 | alwaysTryTypes: true // Always try to resolve types under `@types` directory even if it's not in `package.json` 29 | } 30 | } 31 | }, 32 | ignorePatterns: ['dist', 'node_modules', 'scripts', 'coverage'], 33 | root: true, 34 | rules: { 35 | '@typescript-eslint/consistent-type-imports': 'error', 36 | '@typescript-eslint/consistent-type-exports': 'error', 37 | '@typescript-eslint/await-thenable': 'error', 38 | '@typescript-eslint/no-floating-promises': 'error', 39 | 'no-only-tests/no-only-tests': 'error', 40 | '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], 41 | 'filenames/match-regex': ['error', '^[a-z0-9-._\\[\\]]+$', true], 42 | complexity: ['warn', 15], 43 | 'object-curly-spacing': ['error', 'always'], 44 | 'linebreak-style': ['error', 'unix'], 45 | quotes: ['error', 'single'], 46 | semi: ['error', 'never'], 47 | 'import/default': 'off', 48 | '@typescript-eslint/no-require-imports': 'off', 49 | 'no-empty-pattern': 'off', 50 | 'import/no-named-as-default': 'off' 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /sample-app/frontend/src/components/movie-list.vitest.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | describe, 3 | expect, 4 | it, 5 | screen, 6 | wrappedRender 7 | } from '@vitest-utils/utils' 8 | import { vi } from 'vitest' 9 | import { generateMovieWithoutId } from '../test-utils/factories' 10 | import MovieList from './movie-list' 11 | 12 | describe('', () => { 13 | const onDelete = vi.fn() 14 | 15 | it('should show nothing with no movies', () => { 16 | wrappedRender() 17 | 18 | expect(screen.queryByTestId('movie-list-comp')).not.toBeInTheDocument() 19 | }) 20 | 21 | it('should show error with error', () => { 22 | wrappedRender() 23 | 24 | expect(screen.queryByTestId('movie-list-comp')).not.toBeInTheDocument() 25 | expect(screen.getByTestId('error')).toBeInTheDocument() 26 | }) 27 | 28 | it('should verify the movie and delete', () => { 29 | const movie1Id = 7 30 | const movie2Id = 42 31 | const movie1 = { id: movie1Id, ...generateMovieWithoutId() } 32 | const movie2 = { id: movie2Id, ...generateMovieWithoutId() } 33 | 34 | wrappedRender() 35 | 36 | expect(screen.getByTestId('movie-list-comp')).toBeVisible() 37 | 38 | const movieItems = screen.getAllByTestId('movie-item-comp') 39 | expect(movieItems).toHaveLength(2) 40 | movieItems.forEach((movieItem) => expect(movieItem).toBeVisible()) 41 | 42 | screen.getByTestId(`delete-movie-${movie1.name}`).click() 43 | screen.getByTestId(`delete-movie-${movie2.name}`).click() 44 | expect(onDelete).toBeCalledTimes(2) 45 | expect(onDelete).toBeCalledWith(movie1Id) 46 | expect(onDelete).toBeCalledWith(movie2Id) 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /src/log/log-organizer.ts: -------------------------------------------------------------------------------- 1 | /** Extends Playwright's test object to organize logs by test file and name. 2 | * When tests import the test object from this module, 3 | * logs will be organized by test file and test name in separate folders. */ 4 | import { setTestContextInfo } from './config' 5 | import type { TestInfo } from '@playwright/test' 6 | // import { test as base } from '@playwright/test' 7 | import * as path from 'path' 8 | 9 | /** Resolves a test file path relative to the project root. */ 10 | const resolveTestFile = (projectRoot: string, testFile?: string) => 11 | testFile 12 | ? path.isAbsolute(testFile) 13 | ? testFile 14 | : path.resolve(projectRoot, testFile) 15 | : undefined 16 | 17 | /** Sets the test context information to capture test metadata for logging purposes. */ 18 | const setContext = (testInfo: TestInfo): void => { 19 | // Use file path relative to project root for better organization 20 | const projectRoot = process.cwd() 21 | 22 | const testFile = testInfo.file 23 | ? { 24 | testFile: resolveTestFile(projectRoot, testInfo.file) 25 | } 26 | : {} 27 | 28 | setTestContextInfo({ 29 | ...testFile, 30 | testName: testInfo.title, 31 | workerIndex: testInfo.workerIndex 32 | }) 33 | } 34 | 35 | /** 36 | * A utility function to capture test context when using the standard Playwright test object 37 | * This should be imported in test files that use @playwright/test directly but still want organized logs 38 | * 39 | * Example usage: 40 | * ```ts 41 | * import { test } from '@playwright/test' 42 | * import { captureTestContext } from '../../src' 43 | * 44 | * test.beforeEach(async ({}, testInfo) => { 45 | * captureTestContext(testInfo) 46 | * }) 47 | * ``` 48 | */ 49 | export function captureTestContext(testInfo: TestInfo): void { 50 | setContext(testInfo) 51 | } 52 | -------------------------------------------------------------------------------- /playwright/support/auth/auth-fixture.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Extended test fixture that adds authentication support for both API and UI testing 3 | * 4 | * @see https://playwright.dev/docs/test-fixtures 5 | * @see https://playwright.dev/docs/api-testing#authentication 6 | * @see https://playwright.dev/docs/auth 7 | */ 8 | 9 | import { test as base } from '@playwright/test' 10 | import { 11 | createAuthFixtures, 12 | type AuthOptions, 13 | type AuthFixtures, 14 | setAuthProvider 15 | } from '../../../src/auth-session' 16 | 17 | // Import our custom auth provider 18 | import myCustomProvider from './custom-auth-provider' 19 | import { BASE_URL } from '@playwright/config/local.config' 20 | import { getEnvironment } from './get-environment' 21 | import { getUserIdentifier } from './get-user-identifier' 22 | 23 | // Register the custom auth provider early to ensure it's available for all tests 24 | setAuthProvider(myCustomProvider) 25 | 26 | // Default auth options using the current environment 27 | const defaultAuthOptions: AuthOptions = { 28 | environment: getEnvironment(), 29 | userIdentifier: getUserIdentifier(), 30 | baseUrl: BASE_URL // Pass baseUrl explicitly to auth session, or use our own getBaseUrl helper 31 | } 32 | 33 | // Get the fixtures from the factory function 34 | const fixtures = createAuthFixtures() 35 | 36 | // Export the test object with auth fixtures 37 | export const test = base.extend({ 38 | // For authOptions, we need to define it directly using the Playwright array format 39 | authOptions: [defaultAuthOptions, { option: true }], 40 | 41 | // Auth session toggle - enables/disables auth functionality completely 42 | // Default: true (auth enabled) 43 | authSessionEnabled: [true, { option: true }], 44 | 45 | // Use the other fixtures directly 46 | authToken: fixtures.authToken, 47 | context: fixtures.context, 48 | page: fixtures.page 49 | }) 50 | -------------------------------------------------------------------------------- /sample-app/frontend/src/components/movie-form/movie-input.vitest.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | wrappedRender, 3 | screen, 4 | describe, 5 | it, 6 | expect 7 | } from '@vitest-utils/utils' 8 | import { vi } from 'vitest' 9 | import userEvent from '@testing-library/user-event' 10 | import MovieInput from './movie-input' 11 | import { generateMovieWithoutId } from '../../test-utils/factories' 12 | 13 | describe('', () => { 14 | const movie = generateMovieWithoutId() 15 | const onChange = vi.fn() 16 | const user = userEvent.setup() 17 | 18 | it('should render a text input', async () => { 19 | const { name } = movie 20 | 21 | wrappedRender( 22 | 28 | ) 29 | 30 | const input = screen.getByPlaceholderText('place holder') 31 | expect(input).toBeVisible() 32 | expect(input).toHaveValue(name) 33 | 34 | await user.type(input, 'a') 35 | expect(onChange).toHaveBeenCalledTimes(1) 36 | 37 | // @ts-expect-error okay 38 | expect(onChange.mock.calls[0][0].target.value).toBe(`${name}`) 39 | // alternative 40 | expect(onChange).toHaveBeenCalledWith( 41 | expect.objectContaining({ 42 | target: expect.objectContaining({ 43 | value: name 44 | }) 45 | }) 46 | ) 47 | }) 48 | 49 | it('should render a year input', async () => { 50 | const { year } = movie 51 | wrappedRender( 52 | 58 | ) 59 | 60 | const input = screen.getByTestId('movie-input-comp-number') 61 | expect(input).toBeVisible() 62 | expect(input).toHaveValue(year) 63 | 64 | await user.type(input, '1') 65 | }) 66 | }) 67 | -------------------------------------------------------------------------------- /sample-app/backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sample-app-backend", 3 | "version": "1.0.0", 4 | "description": "Sample backend for testing playwright-utils", 5 | "main": "index.js", 6 | "private": true, 7 | "author": "Murat Ozcan", 8 | "license": "ISC", 9 | "scripts": { 10 | "test": "jest --detectOpenHandles --verbose --silent --config jest.config.ts", 11 | "test:watch": "jest --watch --config jest.config.ts", 12 | "db:migrate": "npx prisma migrate reset --force --skip-seed generate", 13 | "db:sync": "npx prisma db push --force-reset && npx prisma generate", 14 | "db:migrate-prod": "npx prisma migrate deploy", 15 | "reset:db": "tsx ./scripts/global-setup.ts", 16 | "kafka:health-check": "node ./scripts/kafka-health-check.js", 17 | "start": ". ./scripts/env-setup.sh && npm run kafka:health-check && npm run db:sync && npm run reset:db && npm run kafka:reset-logs && nodemon", 18 | "kafka:reset-logs": "rm -rf test-events/movie-events.log", 19 | "kafka:start": "docker compose -f ./src/events/kafka-cluster.yml up -d --no-recreate", 20 | "kafka:stop": "docker compose -f ./src/events/kafka-cluster.yml down", 21 | "generate:openapi": "tsx src/api-docs/openapi-writer.ts", 22 | "generate:api-docs": "rm -rf docs && npx redocly build-docs src/api-docs/openapi.yml -o docs/api-docs.html" 23 | }, 24 | "devDependencies": { 25 | "@redocly/cli": "1.34.5", 26 | "@types/cookie-parser": "1.4.10", 27 | "@types/cors": "2.8.19", 28 | "@types/express": "4.17.21", 29 | "cors": "2.8.5", 30 | "jest-mock-extended": "4.0.0-beta1", 31 | "nodemon": "3.1.10", 32 | "openapi-types": "12.1.3" 33 | }, 34 | "dependencies": { 35 | "@asteasolutions/zod-to-openapi": "8.1.0", 36 | "@prisma/client": "6.19.0", 37 | "cookie-parser": "1.4.7", 38 | "express": "4.21.2", 39 | "kafkajs": "2.2.4", 40 | "prisma": "6.19.0" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /sample-app/backend/src/middleware/validate-movie-id.test.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response, NextFunction } from 'express' 2 | import { validateId } from './validate-movie-id' 3 | 4 | describe('validateId middleware', () => { 5 | let mockRequest: Partial 6 | let mockResponse: Partial 7 | let nextFunction: NextFunction 8 | 9 | beforeEach(() => { 10 | mockRequest = { 11 | params: {} 12 | } 13 | mockResponse = { 14 | status: jest.fn().mockReturnThis(), // mocked to return this (the mockResponse object), allowing method chaining like res.status().json(). 15 | json: jest.fn() 16 | } 17 | nextFunction = jest.fn() 18 | }) 19 | 20 | it('should pass valid movie ID', () => { 21 | mockRequest.params = { id: '123' } 22 | validateId(mockRequest as Request, mockResponse as Response, nextFunction) 23 | 24 | expect(mockRequest.params.id).toBe('123') 25 | expect(nextFunction).toHaveBeenCalled() 26 | expect(mockResponse.status).not.toHaveBeenCalled() 27 | expect(mockResponse.json).not.toHaveBeenCalled() 28 | }) 29 | 30 | it('should return 400 for invalid movie ID', () => { 31 | mockRequest.params = { id: 'abc' } 32 | validateId(mockRequest as Request, mockResponse as Response, nextFunction) 33 | 34 | expect(mockResponse.status).toHaveBeenCalledWith(400) 35 | expect(mockResponse.json).toHaveBeenCalledWith({ 36 | error: 'Invalid movie ID provided' 37 | }) 38 | expect(nextFunction).not.toHaveBeenCalled() 39 | }) 40 | 41 | it('should handle missing ID parameter', () => { 42 | validateId(mockRequest as Request, mockResponse as Response, nextFunction) 43 | 44 | expect(mockResponse.status).toHaveBeenCalledWith(400) 45 | expect(mockResponse.json).toHaveBeenCalledWith({ 46 | error: 'Invalid movie ID provided' 47 | }) 48 | expect(nextFunction).not.toHaveBeenCalled() 49 | }) 50 | }) 51 | -------------------------------------------------------------------------------- /.codeiumignore: -------------------------------------------------------------------------------- 1 | # Codeium ignore file for Windsurf PR reviews 2 | # This file uses the same syntax as .gitignore but allows ignoring files that can't be in .gitignore 3 | # Purpose: Exclude files from PR reviews that don't need human review or cause performance issues 4 | 5 | # ============================================================================= 6 | # SECTION 1: COPIED FROM .gitignore (same patterns needed for PR review exclusion) 7 | # ============================================================================= 8 | 9 | # Dependencies 10 | node_modules/ 11 | /node_modules 12 | 13 | # Build outputs 14 | dist/ 15 | /dist 16 | /build 17 | /coverage 18 | 19 | # Environment and config 20 | .env 21 | .vscode 22 | .DS_Store 23 | 24 | # Logs 25 | *.log 26 | npm-debug.log* 27 | 28 | # Playwright 29 | /test-results/ 30 | /playwright-report/ 31 | /playwright-logs/ 32 | /playwright/playwright-logs 33 | /blob-report/ 34 | /playwright/.cache/ 35 | .auth 36 | **/__screenshots__/ 37 | downloads/ 38 | 39 | # ============================================================================= 40 | # SECTION 2: ADDED (unique to .codeiumignore - can't or shouldn't be in .gitignore) 41 | # ============================================================================= 42 | 43 | # Lock files (needed in git but not useful for PR reviews) 44 | package-lock.json 45 | yarn.lock 46 | pnpm-lock.yaml 47 | *.lock 48 | 49 | # HAR files (large binary-like files, not useful for code review) 50 | har-files/ 51 | *.har 52 | *.har.lock 53 | 54 | # Large documentation (causes PR review performance issues) 55 | docs/bmad-method/ 56 | CLAUDE.md 57 | 58 | # ============================================================================= 59 | # SECTION 3: PROJECT SPECIFIC (customize per repository) 60 | # ============================================================================= 61 | 62 | # Project specific ignores 63 | sample-app/backend/test-events 64 | -------------------------------------------------------------------------------- /sample-app/frontend/src/components/movie-details/movie-details.tsx: -------------------------------------------------------------------------------- 1 | import LoadingMessage from '@components/loading-message' 2 | import { SButton, STitle } from '@styles/styled-components' 3 | import styled from 'styled-components' 4 | import MovieManager from './movie-manager' 5 | import { useMovieDetails } from '@hooks/use-movie-detail' 6 | import { useDeleteMovie } from '@hooks/use-movies' 7 | import { useNavigate } from 'react-router-dom' 8 | import type { Movie } from '@shared/types/movie-types' 9 | import type { ErrorResponse } from '../../consumer' 10 | 11 | export default function MovieDetails() { 12 | const { data, isLoading, hasIdentifier } = useMovieDetails() 13 | const deleteMovieMutation = useDeleteMovie() 14 | const navigate = useNavigate() 15 | 16 | const handleDeleteMovie = (id: number) => 17 | deleteMovieMutation.mutate(id, { 18 | onSuccess: () => navigate('/movies') // Redirect to /movies after delete success 19 | }) 20 | 21 | if (!hasIdentifier) return

No movie selected

22 | if (isLoading) return 23 | 24 | const movieData = (data as unknown as { data: Movie }).data 25 | const movieError = (data as unknown as { error: ErrorResponse }).error?.error 26 | 27 | return ( 28 | 29 | Movie Details 30 | 31 | {movieData && 'name' in movieData ? ( 32 | 33 | ) : ( 34 |

{movieError || 'Unexpected error occurred'}

35 | )} 36 | 37 | navigate(-1)} data-testid="back"> 38 | Back 39 | 40 |
41 | ) 42 | } 43 | 44 | const SMovieDetails = styled.div` 45 | max-width: 600px; 46 | margin: 0 auto; 47 | padding: 20px; 48 | background-color: #f9f9f9; 49 | border-radius: 8px; 50 | box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1); 51 | ` 52 | -------------------------------------------------------------------------------- /sample-app/frontend/src/hooks/use-movie-form.ts: -------------------------------------------------------------------------------- 1 | import { useAddMovie } from '@hooks/use-movies' 2 | import { useState } from 'react' 3 | import { CreateMovieSchema } from '@shared/types/schema' 4 | import type { ZodError } from 'zod' 5 | 6 | export function useMovieForm() { 7 | const [movieName, setMovieName] = useState('') 8 | const [movieYear, setMovieYear] = useState(2023) 9 | const [movieRating, setMovieRating] = useState(0) 10 | const [movieDirector, setMovieDirector] = useState('') 11 | const [validationError, setValidationError] = useState(null) 12 | 13 | const { status, mutate } = useAddMovie() 14 | const movieLoading = status === 'pending' 15 | 16 | // Zod Key feature 3: safeParse 17 | // Zod note: if you have a frontend, you can use the schema + safeParse there 18 | // in order to perform form validation before sending the data to the server 19 | const handleAddMovie = () => { 20 | const payload = { 21 | name: movieName, 22 | year: movieYear, 23 | rating: movieRating, 24 | director: movieDirector 25 | } 26 | const result = CreateMovieSchema.safeParse(payload) 27 | 28 | // Zod key feature 4: you can utilize 29 | // and expose the validation state to be used at a component 30 | if (!result.success) { 31 | setValidationError(result.error) 32 | return 33 | } 34 | 35 | mutate(payload) 36 | // reset form after successful submission 37 | setMovieName('') 38 | setMovieYear(2023) 39 | setMovieRating(0) 40 | setMovieDirector('') 41 | 42 | setValidationError(null) 43 | } 44 | 45 | return { 46 | movieName, 47 | movieYear, 48 | movieRating, 49 | setMovieName, 50 | setMovieYear, 51 | setMovieRating, 52 | handleAddMovie, 53 | movieLoading, 54 | validationError, // for Zod key feature 4: expose the validation state 55 | movieDirector, 56 | setMovieDirector 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /sample-app/frontend/src/hooks/use-movie-edit-form.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { useUpdateMovie } from '@hooks/use-movies' 3 | import type { ZodError } from 'zod' 4 | import type { Movie } from '@shared/types/movie-types' 5 | import { UpdateMovieSchema } from '@shared/types/schema' 6 | 7 | export function useMovieEditForm(initialMovie: Movie) { 8 | const [movieName, setMovieName] = useState(initialMovie.name) 9 | const [movieYear, setMovieYear] = useState(initialMovie.year) 10 | const [movieRating, setMovieRating] = useState(initialMovie.rating) 11 | const [movieDirector, setMovieDirector] = useState(initialMovie.director) 12 | const [validationError, setValidationError] = useState(null) 13 | 14 | const { status, mutate } = useUpdateMovie() 15 | const movieLoading = status === 'pending' 16 | 17 | // Zod Key feature 3: safeParse 18 | // Zod note: if you have a frontend, you can use the schema + safeParse there 19 | // in order to perform form validation before sending the data to the server 20 | 21 | const handleUpdateMovie = () => { 22 | const payload = { 23 | name: movieName, 24 | year: movieYear, 25 | rating: movieRating, 26 | director: movieDirector 27 | } 28 | const result = UpdateMovieSchema.safeParse(payload) 29 | 30 | // Zod key feature 4: you can utilize 31 | // and expose the validation state to be used at a component 32 | if (!result.success) { 33 | setValidationError(result.error) 34 | return 35 | } 36 | 37 | mutate({ 38 | id: initialMovie.id, 39 | movie: payload 40 | }) 41 | 42 | setValidationError(null) 43 | } 44 | 45 | return { 46 | movieName, 47 | movieYear, 48 | movieRating, 49 | setMovieName, 50 | setMovieYear, 51 | setMovieRating, 52 | handleUpdateMovie, 53 | movieLoading, 54 | validationError, 55 | movieDirector, 56 | setMovieDirector 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /sample-app/shared/user-factory.ts: -------------------------------------------------------------------------------- 1 | import { API_URL } from '@playwright/config/local.config' 2 | import { apiRequest } from 'src/api-request' 3 | import { request } from '@playwright/test' 4 | import type { AuthResponse } from 'sample-app/frontend/src/consumer' 5 | 6 | export async function createTestUser({ 7 | username, 8 | password, 9 | userIdentifier 10 | }: { 11 | username: string 12 | password: string 13 | userIdentifier: string 14 | }): Promise<{ 15 | token: string 16 | userId: string 17 | username: string 18 | password: string 19 | }> { 20 | // Make authentication request directly without involving storage 21 | // this would reuse storage: request.newContext({ storageState: storageStatePath }) 22 | const context = await request.newContext() 23 | try { 24 | const { status, body } = await apiRequest({ 25 | request: context, 26 | method: 'POST', 27 | path: '/auth/identity-token', 28 | baseUrl: API_URL, 29 | body: { 30 | username, 31 | password, 32 | userIdentifier 33 | } 34 | }) 35 | 36 | if (status !== 200) { 37 | throw new Error(`Failed to create ephemeral test user ${username}`) 38 | } 39 | 40 | return { 41 | token: body.token, 42 | userId: body.identity.userId, 43 | username: body.identity.username, 44 | password 45 | } 46 | } finally { 47 | await context.dispose() 48 | } 49 | } 50 | 51 | const generateUsername = (userIdentifier: string) => 52 | `${userIdentifier}-${Date.now()}` 53 | const generatePassword = () => 54 | `pwd-${Math.random().toString(36).substring(2, 10)}` 55 | 56 | export const generateUserData = (userIdentifier: string) => ({ 57 | username: generateUsername(userIdentifier), 58 | password: generatePassword(), 59 | userIdentifier 60 | }) 61 | 62 | export type UserData = { 63 | token: string 64 | userId: string 65 | username: string 66 | password: string 67 | } 68 | -------------------------------------------------------------------------------- /sample-app/frontend/src/components/movie-form/movie-form.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import MovieInput from './movie-input' 3 | import ValidationErrorDisplay from '@components/validation-error-display' 4 | import { useMovieForm } from '@hooks/use-movie-form' 5 | import { SButton } from '@styles/styled-components' 6 | 7 | export default function MovieForm() { 8 | const { 9 | movieName, 10 | setMovieName, 11 | movieYear, 12 | setMovieYear, 13 | movieRating, 14 | setMovieRating, 15 | handleAddMovie, 16 | movieLoading, 17 | validationError, 18 | movieDirector, 19 | setMovieDirector 20 | } = useMovieForm() 21 | 22 | return ( 23 |
24 | Add a New Movie 25 | 26 | {/* Zod key feature 4: use the validation state at the component */} 27 | 28 | 29 | setMovieName(e.target.value)} 34 | /> 35 | setMovieYear(Number(e.target.value))} 40 | /> 41 | setMovieRating(Number(e.target.value))} 46 | /> 47 | setMovieDirector(e.target.value)} 52 | /> 53 | 58 | {movieLoading ? 'Adding...' : 'Add Movie'} 59 | 60 |
61 | ) 62 | } 63 | 64 | const SSubtitle = styled.h2` 65 | color: #333; 66 | font-size: 2rem; 67 | margin-bottom: 10px; 68 | ` 69 | -------------------------------------------------------------------------------- /playwright/support/auth/token/is-expired.ts: -------------------------------------------------------------------------------- 1 | import { log } from '@seontechnologies/playwright-utils/log' 2 | 3 | /** 4 | * Extracts the expiration timestamp from a JWT token payload 5 | * 6 | * Handles base64url encoding (replacing '-' with '+' and '_' with '/'). 7 | * Adds padding as needed before decoding. 8 | * Returns the 'exp' claim from the JWT payload which represents the expiration timestamp in seconds since Unix epoch. 9 | * 10 | * @param token - Raw JWT token string in format header.payload.signature 11 | * @returns The expiration timestamp in seconds since epoch, or null if unable to extract 12 | */ 13 | const extractJwtExpiration = (token: string): number | null => { 14 | if (typeof token !== 'string' || token.split('.').length !== 3) { 15 | return null 16 | } 17 | 18 | try { 19 | const [, payload] = token.split('.') 20 | if (!payload) { 21 | log.debugSync('Invalid JWT token format; no payload') 22 | return null 23 | } 24 | const base64 = payload.replace(/-/g, '+').replace(/_/g, '/') 25 | const padded = base64.padEnd( 26 | base64.length + ((4 - (base64.length % 4)) % 4), 27 | '=' 28 | ) 29 | const decoded = JSON.parse(Buffer.from(padded, 'base64').toString('utf-8')) 30 | return decoded.exp || null 31 | } catch (err) { 32 | log.debugSync(`Error parsing JWT token: ${err}`) 33 | return null 34 | } 35 | } 36 | 37 | /** 38 | * Check if a token is expired 39 | * @param rawToken JWT token or stringified storage state 40 | * @returns true if token is expired or invalid, false if valid 41 | */ 42 | export const isTokenExpired = (rawToken: string): boolean => { 43 | const expiration = extractJwtExpiration(rawToken) 44 | if (expiration !== null) { 45 | const currentTime = Math.floor(Date.now() / 1000) 46 | const isExpired = expiration < currentTime 47 | if (isExpired) { 48 | log.infoSync('JWT token is expired based on payload expiration claim') 49 | } 50 | return isExpired 51 | } 52 | 53 | log.infoSync('Could not determine token expiration - assuming expired') 54 | return true 55 | } 56 | -------------------------------------------------------------------------------- /sample-app/backend/src/events/movie-events.test.ts: -------------------------------------------------------------------------------- 1 | import { produceMovieEvent } from './movie-events' 2 | import { Kafka } from 'kafkajs' 3 | import { generateMovieWithId } from '../../../../playwright/support/utils/movie-factories' 4 | import type { Movie } from '@shared/types/movie-types' 5 | 6 | // Mock kafkajs 7 | jest.mock('kafkajs', () => ({ 8 | Kafka: jest.fn().mockImplementation(() => ({ 9 | producer: jest.fn(() => ({ 10 | connect: jest.fn().mockResolvedValue(undefined), 11 | send: jest.fn(), 12 | disconnect: jest.fn() 13 | })) 14 | })) 15 | })) 16 | 17 | // Mock fs 18 | jest.mock('node:fs/promises', () => ({ 19 | appendFile: jest.fn().mockResolvedValue(undefined) 20 | })) 21 | 22 | // Mock console.table and console.error 23 | global.console.table = jest.fn() 24 | global.console.error = jest.fn() 25 | 26 | describe('produceMovieEvent', () => { 27 | const mockMovie: Movie = generateMovieWithId() 28 | const key = mockMovie.id.toString() // the key is always a string in Kafka 29 | 30 | beforeEach(() => { 31 | jest.clearAllMocks() 32 | }) 33 | 34 | it('should produce a movie event successfully', async () => { 35 | const kafkaInstance = new Kafka({ 36 | clientId: 'test-client', 37 | brokers: ['localhost:9092'] 38 | }) 39 | const event = { 40 | topic: 'movie-created', 41 | messages: [{ key, value: JSON.stringify(mockMovie) }] 42 | } 43 | const producer = kafkaInstance.producer() 44 | await producer.connect() 45 | await producer.send(event) 46 | await producer.disconnect() 47 | 48 | const result = await produceMovieEvent(mockMovie, 'created') 49 | 50 | expect(Kafka).toHaveBeenCalledWith(expect.any(Object)) 51 | expect(producer.connect).toHaveBeenCalled() 52 | expect(producer.send).toHaveBeenCalledWith(event) 53 | expect(producer.disconnect).toHaveBeenCalled() 54 | expect(console.table).toHaveBeenCalled() 55 | expect(result).toEqual( 56 | expect.objectContaining({ 57 | topic: 'movie-created', 58 | messages: [{ key, value: mockMovie }] 59 | }) 60 | ) 61 | }) 62 | }) 63 | -------------------------------------------------------------------------------- /src/log/utils/playwright-step-utils.ts: -------------------------------------------------------------------------------- 1 | /** The main purpose is to make it possible to use Playwright's step reporting 2 | * in both test and non-test contexts without causing errors. */ 3 | 4 | // Store reference to the test object if available 5 | let testObj: 6 | | { step: (title: string, body: () => Promise) => Promise } 7 | | undefined 8 | 9 | // Try to load Playwright test, but handle gracefully if unavailable 10 | try { 11 | // This will succeed in test files but might fail in utility files 12 | // eslint-disable-next-line @typescript-eslint/no-require-imports 13 | const { test } = require('@playwright/test') 14 | testObj = test 15 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 16 | } catch (_error) { 17 | // We'll handle this gracefully - testObj will remain undefined 18 | console.info( 19 | 'Note: Running in non-test context, Playwright test API is not available' 20 | ) 21 | } 22 | 23 | /** 24 | * Checks if the Playwright test step API is available in the current context 25 | */ 26 | const isPlaywrightStepAvailable = (): boolean => !!testObj?.step 27 | 28 | /** Executes a Playwright test step with error handling */ 29 | const executePlaywrightStep = async (stepMessage: string): Promise => { 30 | if (!testObj) return 31 | 32 | try { 33 | // We're using an empty function because we just want to mark the step in the report 34 | // The actual work should happen outside this step 35 | await testObj.step(stepMessage, async () => { 36 | // This is intentionally empty - we're just using test.step for reporting, 37 | // not for actual execution control, as we've already processed the step 38 | }) 39 | } catch (error) { 40 | // If test.step fails, don't crash - just skip using it 41 | console.debug('Failed to execute Playwright test.step:', error) 42 | } 43 | } 44 | 45 | /** Attempts to execute a Playwright test step if the test API is available */ 46 | export const tryPlaywrightStep = async (stepMessage: string): Promise => { 47 | if (isPlaywrightStepAvailable()) { 48 | await executePlaywrightStep(stepMessage) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /playwright/tests/auth-session/auth-session-sanity.spec.ts: -------------------------------------------------------------------------------- 1 | import { log } from '../../../src/log' 2 | import { test, expect } from '../../support/merged-fixtures' 3 | 4 | /** 5 | * Create a preview of a token that's safe for logging 6 | * @param token - The full token 7 | * @returns A shortened preview with the middle part replaced by '...' 8 | */ 9 | const createTokenPreview = (token: string): string => 10 | token.substring(0, 10) + '...' + token.substring(token.length - 5) 11 | 12 | // Configure tests to run in serial mode (sequentially, not in parallel) 13 | // This is required for properly testing auth token reuse between tests 14 | test.describe.configure({ mode: 'serial' }) 15 | test.describe('Auth Session Example', () => { 16 | // This test just demonstrates that we get a token 17 | test('should have auth token available', async ({ authToken }) => { 18 | // Token is already obtained via the fixture 19 | expect(authToken).toBeDefined() 20 | expect(typeof authToken).toBe('string') 21 | expect(authToken.length).toBeGreaterThan(0) 22 | 23 | // Log token for debugging (shortened for security) 24 | const tokenPreview = createTokenPreview(authToken) 25 | await log.info(`Token available without explicit fetching: ${tokenPreview}`) 26 | }) 27 | 28 | // This test will reuse the same token without making another request 29 | test('should reuse the same auth token', async ({ 30 | authToken, 31 | apiRequest 32 | }) => { 33 | // The token is already available without making a new request 34 | expect(authToken).toBeDefined() 35 | expect(typeof authToken).toBe('string') 36 | 37 | // We can use the token for API requests 38 | const { status } = await apiRequest({ 39 | method: 'GET', 40 | path: '/movies', 41 | headers: { 42 | Authorization: authToken // Use the token directly as the CRUD helpers do 43 | } 44 | }) 45 | 46 | expect(status).toBe(200) 47 | 48 | // Log token for debugging (shortened for security) 49 | const tokenPreview = createTokenPreview(authToken) 50 | await log.step( 51 | `Second test reuses the token without fetching again: ${tokenPreview}` 52 | ) 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /playwright/config/base.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test' 2 | import { config as dotenvConfig } from 'dotenv' 3 | import path from 'path' 4 | 5 | // the default settings turn on console logging 6 | // this is just to show that we can set things here 7 | // Both configuration styles are supported: 8 | 9 | // Boolean style (simple): 10 | // log.configure({ 11 | // console: false 12 | // }) 13 | 14 | // Object style (with additional options): 15 | // log.configure({ 16 | // console: { 17 | // enabled: false, 18 | // colorize: true, 19 | // timestamps: true 20 | // } 21 | // }) 22 | 23 | dotenvConfig({ 24 | path: path.resolve(__dirname, '../../.env') 25 | }) 26 | 27 | export const baseConfig = defineConfig({ 28 | globalSetup: path.resolve(__dirname, '../support/global-setup.ts'), 29 | 30 | testDir: './playwright/tests', 31 | 32 | testMatch: '**/*.spec.ts', 33 | 34 | fullyParallel: true, 35 | 36 | forbidOnly: !!process.env.CI, 37 | 38 | retries: process.env.CI ? 2 : 1, 39 | 40 | workers: process.env.CI 41 | ? undefined // Let playwright use default (50% of CPU cores) in CI 42 | : '100%', // Use all CPU cores for local runs 43 | 44 | reporter: process.env.CI 45 | ? [ 46 | ['line'], 47 | ['html'], 48 | ['blob'], 49 | ['json', { outputFile: 'test-results.json' }], 50 | ['junit', { outputFile: 'test-results.xml' }] 51 | ] 52 | : [['list'], ['html', { open: 'never' }]], 53 | 54 | timeout: 90000, 55 | 56 | expect: { 57 | timeout: 15000 58 | }, 59 | 60 | /* Shared settings for all the projects below */ 61 | use: { 62 | trace: 'retain-on-first-failure', 63 | testIdAttribute: 'data-testid' 64 | }, 65 | 66 | projects: [ 67 | { 68 | name: 'chromium', 69 | use: { ...devices['Desktop Chrome'] } 70 | }, 71 | 72 | // Only enable Google Chrome when multi-browser is explicitly enabled 73 | ...(process.env.PW_MULTI_BROWSER === 'true' 74 | ? [ 75 | { 76 | name: 'google-chrome', 77 | use: { ...devices['Desktop Chrome'], channel: 'chrome' } 78 | } 79 | ] 80 | : []) 81 | ] 82 | }) 83 | -------------------------------------------------------------------------------- /sample-app/backend/prisma/client.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client' 2 | 3 | const globalForPrisma = global as unknown as { 4 | prisma: PrismaClient | undefined 5 | } 6 | 7 | export const prisma = 8 | globalForPrisma.prisma ?? 9 | new PrismaClient({ 10 | log: ['query'] 11 | }) 12 | 13 | if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma 14 | 15 | // Prisma notes 16 | /* 17 | To connect our applications to a database, we often use an Object-relational 18 | Mapper (ORM). An ORM is a tool that sits between a database and an application. 19 | It’s responsible for mapping database records to objects in an application. 20 | Prisma is the most widely-used ORM for Next.js (or Node.js) applications. 21 | 22 | 1. **Define Models**: To use Prisma, first we have to define our data models. 23 | These are entities that represent our application domain, such as User, 24 | Order, Customer, etc. Each model has one or more fields (or properties). 25 | 26 | `npx prisma init` , and then at `./prisma/schema.prisma` create your models. 27 | 28 | > We want to match these with our Zod types, ex: `./app/api/users/schema.ts`, `./app/api/products/schema.ts` 29 | 30 | 2. **Create migration file**: Once we create a model, we use Prisma CLI to 31 | create a migration file. A migration file contains instructions to generate 32 | or update database tables to match our models. These instructions are in SQL 33 | language, which is the language database engines understand. 34 | 35 | `npx prisma migrate dev` 36 | 37 | 3. **Create a Prisma client**: To connect with a database, we create an instance 38 | of PrismaClient. This client object gets automatically generated whenever we 39 | create a new migration. It exposes properties that represent our models (eg 40 | user). 41 | 42 | At `./prisma/client.ts` copy paste this code 43 | 44 | ```ts 45 | import {PrismaClient} from '@prisma/client' 46 | 47 | const globalForPrisma = global as unknown as { 48 | prisma: PrismaClient | undefined 49 | } 50 | 51 | export const prisma = 52 | globalForPrisma.prisma ?? 53 | new PrismaClient({ 54 | log: ['query'], 55 | }) 56 | 57 | if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma 58 | */ 59 | -------------------------------------------------------------------------------- /sample-app/frontend/src/test-utils/vitest-utils/utils.tsx: -------------------------------------------------------------------------------- 1 | import type { FC, ReactNode } from 'react' 2 | import { Suspense } from 'react' 3 | import type { RenderOptions } from '@testing-library/react' 4 | import { render } from 'vitest-browser-react' 5 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query' 6 | import { ErrorBoundary } from 'react-error-boundary' 7 | import { MemoryRouter, Routes, Route } from 'react-router-dom' 8 | import ErrorComponent from '@components/error-component' 9 | import LoadingMessage from '@components/loading-message' 10 | import { describe, it, expect, beforeEach, beforeAll, afterAll } from 'vitest' 11 | import userEvent from '@testing-library/user-event' 12 | 13 | interface WrapperProps { 14 | children: ReactNode 15 | route?: string 16 | path?: string 17 | } 18 | 19 | const AllTheProviders: FC = ({ 20 | children, 21 | route = '/', 22 | path = '/' 23 | }) => { 24 | return ( 25 | 26 | }> 27 | }> 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | ) 37 | } 38 | 39 | /** 40 | * Custom render function that wraps component with all necessary providers: 41 | * - QueryClientProvider 42 | * - ErrorBoundary 43 | * - Suspense 44 | * - MemoryRouter with Routes 45 | */ 46 | export function wrappedRender( 47 | ui: ReactNode, 48 | { 49 | route = '/', 50 | path = '/', 51 | ...options 52 | }: Omit & { 53 | route?: string 54 | path?: string 55 | } = {} 56 | ) { 57 | return render(ui, { 58 | wrapper: ({ children }) => ( 59 | 60 | {children} 61 | 62 | ), 63 | ...options 64 | }) 65 | } 66 | 67 | // re-export everything 68 | export * from '@testing-library/react' 69 | export * from './msw-setup' 70 | export { describe, it, expect, userEvent, beforeEach, beforeAll, afterAll } 71 | -------------------------------------------------------------------------------- /playwright/tests/sample-app/frontend/movie-crud-e2e-network-record-playback.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/support/merged-fixtures' 2 | import { addMovie } from '@playwright/support/ui-helpers/add-movie' 3 | import { editMovie } from '@playwright/support/ui-helpers/edit-movie' 4 | import { log } from 'src/log' 5 | 6 | process.env.PW_NET_MODE = 'playback' 7 | 8 | test.describe('movie crud e2e - browser only (network recorder)', () => { 9 | test.beforeEach(async ({ page, networkRecorder, context }) => { 10 | // Setup network recorder based on PW_NET_MODE 11 | await networkRecorder.setup(context) 12 | await page.goto('/') 13 | }) 14 | 15 | test('should add, edit and delete a movie using only browser interactions', async ({ 16 | page, 17 | interceptNetworkCall 18 | }) => { 19 | const { name, year, rating, director } = { 20 | name: 'centum solutio suscipit', 21 | year: 2009, 22 | rating: 6.3, 23 | director: 'ancilla crebro crux' 24 | } 25 | 26 | await log.step('add a movie using the UI') 27 | await addMovie(page, name, year, rating, director) 28 | await page.getByTestId('add-movie-button').click() 29 | 30 | await log.step('click on movie to edit') 31 | await page.getByText(name).click() 32 | 33 | await log.step('Edit the movie') 34 | const { editedName, editedYear, editedRating, editedDirector } = { 35 | editedName: 'angustus antepono crapula', 36 | editedYear: 2002, 37 | editedRating: 3.4, 38 | editedDirector: 'cognatus avarus aeger' 39 | } 40 | 41 | const loadUpdateMovie = interceptNetworkCall({ 42 | method: 'PUT', 43 | url: '/movies/*' 44 | }) 45 | await log.step('edit movie using the UI') 46 | await editMovie(page, editedName, editedYear, editedRating, editedDirector) 47 | await loadUpdateMovie 48 | 49 | // Go back and verify edit 50 | await page.getByTestId('back').click() 51 | await expect(page).toHaveURL('/movies') 52 | await page.getByText(editedName).waitFor() 53 | 54 | await log.step('delete movie from list') 55 | await page.getByTestId(`delete-movie-${editedName}`).click() 56 | await expect( 57 | page.getByTestId(`delete-movie-${editedName}`) 58 | ).not.toBeVisible() 59 | }) 60 | }) 61 | -------------------------------------------------------------------------------- /sample-app/frontend/src/components/movie-details/movie-edit-form.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { useMovieEditForm } from '@hooks/use-movie-edit-form' 3 | import { SButton } from '@styles/styled-components' 4 | import type { Movie } from '@shared/types/movie-types' 5 | import ValidationErrorDisplay from '@components/validation-error-display' 6 | import { MovieInput } from '@components/movie-form' 7 | 8 | type MovieEditFormProps = Readonly<{ 9 | movie: Movie 10 | onCancel: () => void 11 | }> 12 | 13 | export default function MovieEditForm({ movie, onCancel }: MovieEditFormProps) { 14 | const { 15 | movieName, 16 | setMovieName, 17 | movieYear, 18 | setMovieYear, 19 | movieRating, 20 | setMovieRating, 21 | handleUpdateMovie, 22 | movieLoading, 23 | validationError, 24 | movieDirector, 25 | setMovieDirector 26 | } = useMovieEditForm(movie) 27 | 28 | return ( 29 |
30 | Edit Movie 31 | 32 | 33 | 34 | setMovieName(e.target.value)} 39 | /> 40 | setMovieYear(Number(e.target.value))} 45 | /> 46 | setMovieRating(Number(e.target.value))} 51 | /> 52 | setMovieDirector(e.target.value)} 57 | /> 58 | 63 | {movieLoading ? 'Updating...' : 'Update Movie'} 64 | 65 | 66 | Cancel 67 | 68 |
69 | ) 70 | } 71 | 72 | const SSubtitle = styled.h2` 73 | color: #333; 74 | font-size: 2rem; 75 | margin-bottom: 10px; 76 | ` 77 | -------------------------------------------------------------------------------- /playwright/tests/network-error-monitor/basic-detection.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/support/merged-fixtures' 2 | 3 | /** 4 | * Tests for Network Error Monitor fixture 5 | * 6 | * Note: merged-fixtures.ts already includes networkErrorMonitorFixture, 7 | * so all tests here automatically have network monitoring enabled. 8 | */ 9 | 10 | test.describe('Network Error Monitor - Basic Functionality', () => { 11 | test('should allow successful requests without failing', async ({ page }) => { 12 | const loadGetMovies = page.waitForResponse( 13 | (response) => 14 | response.url().includes('/movies') && 15 | response.request().method() === 'GET' 16 | ) 17 | 18 | await page.goto('/') 19 | 20 | const response = await loadGetMovies 21 | const responseStatus = response.status() 22 | 23 | expect(responseStatus).toBeGreaterThanOrEqual(200) 24 | expect(responseStatus).toBeLessThan(400) 25 | }) 26 | }) 27 | 28 | test.describe('Network Error Monitor - Opt-out Mechanism', () => { 29 | test( 30 | 'should not fail test when opt-out annotation is used', 31 | { annotation: [{ type: 'skipNetworkMonitoring' }] }, 32 | async ({ page }) => { 33 | // This test verifies that the skipNetworkMonitoring annotation works 34 | // Without the annotation, any 404 would fail the test 35 | // With the annotation, the test should pass even if errors occur 36 | 37 | await page.goto('/') 38 | 39 | // Trigger a 404 by trying to fetch a non-existent resource 40 | // The network monitor should ignore this due to the annotation 41 | await page.evaluate(() => { 42 | const img = document.createElement('img') 43 | img.src = '/definitely-non-existent-image-12345.png' 44 | document.body.appendChild(img) 45 | }) 46 | 47 | // Wait for the 404 response deterministically (expected to fail) 48 | await page 49 | .waitForResponse( 50 | (response) => 51 | response.url().includes('definitely-non-existent-image-12345.png'), 52 | { timeout: 2000 } 53 | ) 54 | .catch(() => { 55 | // Expected to fail - we're testing the annotation prevents failure 56 | }) 57 | 58 | // Test passes - annotation prevents network errors from failing the test 59 | expect(true).toBe(true) 60 | } 61 | ) 62 | }) 63 | -------------------------------------------------------------------------------- /src/auth-session/apply-user-cookies-to-browser-context.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Utility to apply user authentication to a browser context 3 | * 4 | * This utility takes a user's token and applies it to a browser context 5 | * without persisting it to disk, making it ideal for ephemeral test users. 6 | */ 7 | import type { BrowserContext } from '@playwright/test' 8 | import { log } from '../log' 9 | import { getAuthProvider } from './internal/auth-provider' 10 | /** 11 | * Apply a user's authentication token to a browser context 12 | * Uses the auth provider to extract and apply cookies to a browser context 13 | * 14 | * @param context The browser context to apply the auth token to 15 | * @param tokenData The storage state object or user data containing the token 16 | * @returns The context with auth applied 17 | */ 18 | export async function applyUserCookiesToBrowserContext( 19 | context: BrowserContext, 20 | tokenData: Record 21 | ): Promise { 22 | try { 23 | // Get cookies from the auth provider 24 | const authProvider = getAuthProvider() 25 | 26 | try { 27 | const cookies = authProvider.extractCookies(tokenData) 28 | 29 | // Log what we're doing 30 | await log.info(`Applying user auth with ${cookies.length} cookies`) 31 | 32 | if (cookies.length > 0) { 33 | try { 34 | // Apply the cookies to the browser context 35 | await context.addCookies(cookies) 36 | await log.info('Successfully applied auth cookies to browser context') 37 | } catch (cookieError) { 38 | await log.error( 39 | `Failed to apply auth cookies to browser context: ${String(cookieError)}` 40 | ) 41 | throw new Error( 42 | `Failed to apply auth cookies: ${String(cookieError)}` 43 | ) 44 | } 45 | } else { 46 | await log.warning('No auth cookies found to apply') 47 | } 48 | } catch (extractError) { 49 | await log.error( 50 | `Failed to extract auth cookies from token data: ${String(extractError)}` 51 | ) 52 | throw new Error(`Failed to extract auth cookies: ${String(extractError)}`) 53 | } 54 | } catch (error) { 55 | await log.error( 56 | `Error in applyUserCookiesToBrowserContext: ${String(error)}` 57 | ) 58 | throw error 59 | } 60 | 61 | // Always return the context if no errors 62 | return context 63 | } 64 | -------------------------------------------------------------------------------- /playwright/support/auth/token/extract.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Token utility functions for authentication 3 | * These functions handle token extraction and validation for the auth session management 4 | */ 5 | 6 | // Cookie type definition matching Playwright's expectations 7 | type Cookie = { 8 | name: string 9 | value: string 10 | domain?: string 11 | path?: string 12 | expires?: number 13 | httpOnly?: boolean 14 | secure?: boolean 15 | sameSite?: 'Strict' | 'Lax' | 'None' 16 | } 17 | 18 | /** 19 | * Extract JWT token from Playwright storage state format 20 | * @param tokenData Storage state object containing cookies 21 | * @returns JWT token value or null if not found 22 | */ 23 | export const extractToken = ( 24 | tokenData: Record 25 | ): string | null => { 26 | // If it's a storage state with cookies, extract the auth token value 27 | if ( 28 | tokenData?.cookies && 29 | Array.isArray(tokenData.cookies) && 30 | tokenData.cookies.length > 0 31 | ) { 32 | // Find the auth cookie 33 | const authCookie = tokenData.cookies.find( 34 | (cookie) => cookie.name === 'seon-jwt' 35 | ) 36 | 37 | // Return the token value if found 38 | if (authCookie?.value) { 39 | return authCookie.value 40 | } 41 | } 42 | 43 | // Try to extract token from direct API format (in case it's not in cookie format) 44 | if (typeof tokenData.token === 'string') { 45 | return tokenData.token 46 | } 47 | 48 | return null 49 | } 50 | 51 | /** 52 | * Extract cookies from various token formats 53 | * Returns cookies ready to be applied to a browser context 54 | * 55 | * @param tokenData Storage state or user data object 56 | * @returns Array of cookie objects ready for browser context 57 | */ 58 | export const extractCookies = ( 59 | tokenData: Record 60 | ): Cookie[] => { 61 | // If it's already a storage state with cookies, return them directly 62 | if ( 63 | tokenData?.cookies && 64 | Array.isArray(tokenData.cookies) && 65 | tokenData.cookies.length > 0 66 | ) { 67 | return tokenData.cookies 68 | } 69 | 70 | // If it's a string token, convert it to a cookie 71 | const token = extractToken(tokenData) 72 | if (token) { 73 | return [ 74 | { 75 | name: 'seon-jwt', 76 | value: token, 77 | domain: 'localhost', 78 | path: '/' 79 | } 80 | ] 81 | } 82 | 83 | // Return empty array if no cookies found 84 | return [] 85 | } 86 | -------------------------------------------------------------------------------- /src/burn-in/runner.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from 'node:child_process' 2 | import { BurnInAnalyzer } from './core/analyzer' 3 | import { loadConfig } from './core/config' 4 | import type { BurnInOptions, BurnInConfig } from './core/types' 5 | 6 | // Internal type with config override capability 7 | type InternalBurnInOptions = BurnInOptions & { 8 | config?: BurnInConfig 9 | } 10 | 11 | export class BurnInRunner { 12 | private analyzer: BurnInAnalyzer 13 | 14 | constructor(private options: InternalBurnInOptions = {}) { 15 | const config = options.config || loadConfig(options.configPath) 16 | this.analyzer = new BurnInAnalyzer(config, options) 17 | } 18 | 19 | async run(): Promise { 20 | const baseBranch = this.options.baseBranch || 'main' 21 | 22 | console.log('🔍 Smart Burn-in Test Runner') 23 | console.log(`🌳 Base branch: ${baseBranch}`) 24 | 25 | // Get changed files 26 | const changedFiles = this.analyzer.getChangedFiles(baseBranch) 27 | 28 | if (changedFiles.all.length === 0) { 29 | console.log('✅ No changes detected. Nothing to burn-in.') 30 | return 31 | } 32 | 33 | console.log(`📝 Found ${changedFiles.all.length} changed file(s)`) 34 | 35 | // Analyze and create test plan 36 | const plan = await this.analyzer.analyzeTestableDependencies(changedFiles) 37 | const command = this.analyzer.buildCommand(plan) 38 | 39 | console.log('\n🎯 Test execution plan:') 40 | console.log(` ${plan.reason}`) 41 | 42 | if (!command) { 43 | console.log('ℹ️ No tests need to be run based on the changes.') 44 | return 45 | } 46 | 47 | if (plan.tests !== null) { 48 | console.log(` Tests to run: ${plan.tests.length}`) 49 | plan.tests.forEach((test) => console.log(` - ${test}`)) 50 | } 51 | 52 | console.log('\n📦 Command to execute:') 53 | console.log(` ${command.join(' ')}`) 54 | 55 | // Set burn-in environment variable 56 | process.env.PW_BURN_IN = 'true' 57 | 58 | console.log('\n🚀 Starting burn-in tests...\n') 59 | 60 | try { 61 | execSync(command.join(' '), { 62 | stdio: 'inherit', 63 | env: process.env 64 | }) 65 | console.log('\n✅ Burn-in tests completed successfully!') 66 | } catch { 67 | console.error('\n❌ Burn-in tests failed') 68 | process.exit(1) 69 | } 70 | } 71 | } 72 | 73 | export async function runBurnIn(options?: BurnInOptions): Promise { 74 | const runner = new BurnInRunner(options) 75 | await runner.run() 76 | } 77 | -------------------------------------------------------------------------------- /.github/actions/install/action.yml: -------------------------------------------------------------------------------- 1 | name: 'Setup Node and Install Dependencies' 2 | description: 'Sets up Node.js and installs dependencies with npm/pnpm caching' 3 | 4 | inputs: 5 | install-command: 6 | description: 'The install command to run (e.g., "npm ci" or "pnpm install")' 7 | required: true 8 | node-version-file: 9 | description: 'Path to file containing Node.js version' 10 | required: false 11 | default: '.nvmrc' 12 | 13 | runs: 14 | using: 'composite' 15 | steps: 16 | 17 | # Node.js setup 18 | - name: Setup Node.js 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version-file: ${{ inputs.node-version-file }} 22 | 23 | # Detect package manager from install command 24 | - name: Detect package manager 25 | id: detect-pm 26 | shell: bash 27 | run: | 28 | INSTALL_CMD="${{ inputs.install-command }}" 29 | 30 | if [[ "$INSTALL_CMD" == *"pnpm"* ]]; then 31 | echo "detected=pnpm" >> $GITHUB_OUTPUT 32 | else 33 | echo "detected=npm" >> $GITHUB_OUTPUT 34 | fi 35 | echo "Using package manager: $(cat $GITHUB_OUTPUT | grep detected | cut -d= -f2)" 36 | 37 | # Cache dependencies based on package manager 38 | - name: Cache dependencies 39 | id: deps-cache 40 | uses: actions/cache@v4 41 | with: 42 | path: | 43 | ${{ steps.detect-pm.outputs.detected == 'npm' && '~/.npm' || '' }} 44 | ${{ steps.detect-pm.outputs.detected == 'pnpm' && '~/.local/share/pnpm/store' || '' }} 45 | key: | 46 | ${{ steps.detect-pm.outputs.detected }}-${{ runner.os }}- 47 | ${{ steps.detect-pm.outputs.detected == 'npm' && hashFiles('**/package-lock.json') || '' }} 48 | ${{ steps.detect-pm.outputs.detected == 'pnpm' && hashFiles('**/pnpm-lock.yaml') || '' }} 49 | restore-keys: | 50 | ${{ steps.detect-pm.outputs.detected }}-${{ runner.os }}- 51 | 52 | # Install dependencies 53 | # Note: Still need to run install even with cache hit to run scripts, lifecycle hooks, and build binaries 54 | - name: Install dependencies 55 | shell: bash 56 | run: | 57 | # First ensure the package manager is installed if using pnpm 58 | if [ "${{ steps.detect-pm.outputs.detected }}" == "pnpm" ] && ! command -v pnpm &> /dev/null; then 59 | echo "Installing pnpm..." 60 | npm install -g pnpm 61 | fi 62 | 63 | # Then run the provided install command 64 | ${{ inputs.install-command }} -------------------------------------------------------------------------------- /playwright/tests/external/network-mock-original.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '../../support/merged-fixtures' 2 | 3 | // Disable auth session for external tests that don't need authentication 4 | // This prevents navigation errors when applying auth to non-auth URLs 5 | test.use({ 6 | authSessionEnabled: false 7 | }) 8 | 9 | test.describe('describe network interception - original', () => { 10 | test('Spy on the network - original', async ({ page }) => { 11 | // Set up the interception before navigating 12 | await page.route('*/**/api/v1/fruits', (route) => route.continue()) 13 | 14 | await page.goto('https://demo.playwright.dev/api-mocking') 15 | 16 | // Wait for the intercepted response 17 | const fruitsResponse = await page.waitForResponse('*/**/api/v1/fruits') 18 | // verify the network 19 | const fruitsResponseBody = await fruitsResponse.json() 20 | const status = fruitsResponse.status() 21 | expect(fruitsResponseBody.length).toBeGreaterThan(0) 22 | expect(status).toBe(200) 23 | }) 24 | 25 | test('Stub the network - original', async ({ page }) => { 26 | const fruit = { name: 'Guava', id: 12 } 27 | 28 | // Set up the interception before navigating 29 | await page.route('*/**/api/v1/fruits', (route) => 30 | route.fulfill({ 31 | json: [fruit] 32 | }) 33 | ) 34 | 35 | await page.goto('https://demo.playwright.dev/api-mocking') 36 | 37 | // Wait for the intercepted response 38 | const fruitsResponse = await page.waitForResponse('*/**/api/v1/fruits') 39 | // verify the network 40 | const fruitsResponseBody = await fruitsResponse.json() 41 | expect(fruitsResponseBody).toEqual([fruit]) 42 | 43 | await expect(page.getByText(fruit.name)).toBeVisible() 44 | }) 45 | 46 | test('Modify the API response - original', async ({ page }) => { 47 | const fruitResponse = page.route( 48 | '*/**/api/v1/fruits', 49 | async (route, _request) => { 50 | // Get the response and add to it 51 | const response = await route.fetch() 52 | const json = await response.json() 53 | // Modify the JSON by adding a new item 54 | json.push({ name: 'MAGIC FRUIT', id: 42 }) 55 | // Fulfill using the original response, while patching the response body 56 | // with the given JSON object. 57 | await route.fulfill({ response, json }) 58 | } 59 | ) 60 | 61 | await page.goto('https://demo.playwright.dev/api-mocking') 62 | await fruitResponse 63 | await expect(page.getByText('MAGIC FRUIT')).toBeVisible() 64 | }) 65 | }) 66 | -------------------------------------------------------------------------------- /sample-app/frontend/src/components/login/login-form.vitest.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | wrappedRender, 3 | screen, 4 | waitFor, 5 | worker, 6 | http, 7 | describe, 8 | it, 9 | expect, 10 | userEvent 11 | } from '@vitest-utils/utils' 12 | import LoginForm from './login-form' 13 | 14 | // Setup user event 15 | const user = userEvent.setup() 16 | 17 | describe('LoginForm', () => { 18 | it('should validate required fields', async () => { 19 | worker.use( 20 | http.post('/auth/identity-token', () => { 21 | return new Response(JSON.stringify({ message: 'Success' }), { 22 | status: 200 23 | }) 24 | }) 25 | ) 26 | wrappedRender() 27 | expect(screen.getByPlaceholderText('Username')).toBeInTheDocument() 28 | expect(screen.getByPlaceholderText('Password')).toBeInTheDocument() 29 | expect(screen.getByTestId('user-identity-select')).toBeInTheDocument() 30 | expect(screen.getByText('Log In')).toBeInTheDocument() 31 | 32 | // Test 1: Submit with empty fields 33 | await user.click(screen.getByText('Log In')) 34 | expect(await screen.findByText('Username is required')).toBeInTheDocument() 35 | expect(screen.queryByText('Password is required')).toBeInTheDocument() 36 | 37 | // Test 2: Fill username only and submit again 38 | await user.type(screen.getByPlaceholderText('Username'), 'testuser') 39 | await user.click(screen.getByText('Log In')) 40 | expect(await screen.findByText('Password is required')).toBeInTheDocument() 41 | 42 | // Test 3: only password 43 | await user.clear(screen.getByPlaceholderText('Username')) 44 | await user.type(screen.getByPlaceholderText('Password'), 'testpass') 45 | await user.click(screen.getByText('Log In')) 46 | expect(await screen.findByText('Username is required')).toBeInTheDocument() 47 | }) 48 | 49 | it('should submit form with credentials and redirect on success', async () => { 50 | worker.use( 51 | http.post('/auth/identity-token', () => { 52 | return new Response(JSON.stringify({ message: 'Success' }), { 53 | status: 200 54 | }) 55 | }) 56 | ) 57 | 58 | wrappedRender() 59 | 60 | await user.type(screen.getByPlaceholderText('Username'), 'testuser') 61 | await user.type(screen.getByPlaceholderText('Password'), 'password123') 62 | await user.click(screen.getByText('Log In')) 63 | 64 | // verify that the form submission completed 65 | await waitFor(() => { 66 | expect(screen.queryByText('Loading')).not.toBeInTheDocument() 67 | }) 68 | }) 69 | }) 70 | -------------------------------------------------------------------------------- /playwright/support/utils/parse-kafka-event.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs' 2 | import { logFilePath } from '../../../sample-app/backend/src/events/log-file-path' 3 | import type { MovieEvent, MovieAction } from '../../../sample-app/shared/types' 4 | 5 | /** 6 | * Reshapes the Kafka event entry into a simplified format for easier processing. 7 | * 8 | * @param {MovieEvent} entry - The Kafka event entry containing topic and message details. 9 | * @returns {{ topic: string; key: string; movie: Movie }} - Returns a simplified object with the topic, key, and movie details. 10 | */ 11 | const reshape = (entry: MovieEvent) => ({ 12 | topic: entry.topic, 13 | key: entry.messages[0]?.key, 14 | movie: JSON.parse(entry.messages[0]?.value as unknown as string) 15 | }) 16 | 17 | /** 18 | * Filters Kafka event entries by topic and movieId. 19 | * 20 | * @param {number} movieId - The ID of the movie to filter by. 21 | * @param {string} topic - The Kafka topic to filter by. 22 | * @param {Array>} entries - The list of reshaped Kafka event entries. 23 | * @returns {Array} - Filtered entries based on the topic and movieId. 24 | */ 25 | const filterByTopicAndId = ( 26 | movieId: number, 27 | topic: string, 28 | entries: ReturnType[] 29 | ) => 30 | entries.filter( 31 | (entry) => entry.topic === topic && entry.movie?.id === movieId 32 | ) 33 | 34 | /** 35 | * Parses the Kafka event log file and filters events based on the topic and movieId. 36 | * 37 | * @param {number} movieId - The ID of the movie to filter for. 38 | * @param {`movie-${MovieAction}`} topic - The Kafka topic to filter by. 39 | * @param {string} [filePath=logFilePath] - Optional file path for the Kafka event log file. 40 | * @returns {Promise} - A promise that resolves to the matching events. 41 | */ 42 | export const parseKafkaEvent = async ( 43 | movieId: number, 44 | topic: `movie-${MovieAction}`, 45 | filePath = logFilePath 46 | ) => { 47 | try { 48 | // Read and process the Kafka log file 49 | const fileContent = await fs.readFile(filePath, 'utf-8') 50 | const entries = fileContent 51 | .trim() 52 | .split('\n') 53 | .map((line) => JSON.parse(line)) 54 | .map(reshape) 55 | 56 | // Filter the entries by topic and movie ID 57 | return filterByTopicAndId(movieId, topic, entries) 58 | } catch (error) { 59 | if (error instanceof Error) { 60 | console.error(`Error parsing Kafka event log: ${error.message}`) 61 | } else { 62 | console.error('An unknown error occurred') 63 | } 64 | throw error 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /playwright/config/local.config.ts: -------------------------------------------------------------------------------- 1 | import merge from 'lodash/merge' 2 | import { defineConfig } from '@playwright/test' 3 | import { baseConfig } from './base.config' 4 | import { log } from '../../src' 5 | 6 | // IMPORTANT:the setup for logging to files needs to be uniform between test files 7 | // best place to put it is in a config file 8 | 9 | // SINGLE LOG FILE 10 | // log.configure({ 11 | // fileLogging: { 12 | // enabled: true, 13 | // // Force all tests to use this specific folder regardless of test context 14 | // defaultTestFolder: 'all-tests-in-one', 15 | // forceConsolidated: true, 16 | // outputDir: 'playwright-logs/' 17 | // } 18 | // }) 19 | // Check environment variables for log configuration 20 | const SILENT = process.env.SILENT === 'true' 21 | const DISABLE_FILE_LOGS = process.env.DISABLE_FILE_LOGS === 'true' || SILENT 22 | const DISABLE_CONSOLE_LOGS = 23 | process.env.DISABLE_CONSOLE_LOGS === 'true' || SILENT 24 | 25 | // ORGANIZED LOGS 26 | log.configure({ 27 | // Set console directly to false rather than using object format 28 | console: { 29 | enabled: !DISABLE_CONSOLE_LOGS 30 | }, // This should disable console logs completely 31 | format: { 32 | maxLineLength: 4000 // Set your desired maximum line length 33 | }, 34 | // debug < info < step < success < warning < error 35 | // level: 'step', // show all logs, or limit them 36 | fileLogging: { 37 | enabled: !DISABLE_FILE_LOGS, 38 | defaultTestFolder: 'before-hooks', // all hooks go to the default folder 39 | forceConsolidated: false, // Explicitly disable consolidation 40 | outputDir: 'playwright-logs/' 41 | } 42 | }) 43 | 44 | export const BASE_URL = process.env.BASE_URL || 'http://localhost:3000' 45 | export const API_URL = process.env.VITE_API_URL || 'http://localhost:3001' 46 | 47 | export default defineConfig( 48 | merge({}, baseConfig, { 49 | use: { 50 | baseURL: BASE_URL // case sensitive 51 | }, 52 | webServer: [ 53 | // Backend 54 | { 55 | command: 'npm run start:backend', 56 | url: API_URL, 57 | reuseExistingServer: !process.env.CI, 58 | stdout: 'pipe', 59 | stderr: 'pipe', 60 | timeout: 180000 61 | }, 62 | // Frontend server 63 | { 64 | command: 'npm run start:frontend', 65 | url: BASE_URL, 66 | reuseExistingServer: !process.env.CI, 67 | stdout: 'pipe', 68 | stderr: 'pipe', 69 | timeout: 180000 70 | } 71 | ], 72 | // Add the special project to your config 73 | projects: [...(baseConfig.projects || [])] 74 | }) 75 | ) 76 | -------------------------------------------------------------------------------- /playwright/tests/sample-app/frontend/movie-routes-helper-version.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/support/merged-fixtures' 2 | import { generateMovieWithoutId } from '@playwright/support/utils/movie-factories' 3 | import type { Movie } from '@shared/types/movie-types' 4 | import { type InterceptNetworkCall } from '../../../../src/intercept-network-call' 5 | import { log } from 'src/log' 6 | 7 | test.describe('App routes (playwright-utils helpers)', () => { 8 | const movies = [ 9 | { id: 1, ...generateMovieWithoutId() }, 10 | { id: 2, ...generateMovieWithoutId() }, 11 | { id: 3, ...generateMovieWithoutId() } 12 | ] 13 | const movie = movies[0] 14 | let loadGetMovies: InterceptNetworkCall 15 | 16 | test.beforeEach(({ interceptNetworkCall }) => { 17 | loadGetMovies = interceptNetworkCall({ 18 | method: 'GET', 19 | url: '/movies', 20 | fulfillResponse: { 21 | status: 200, 22 | body: { data: movies } 23 | } 24 | }) 25 | }) 26 | 27 | test('should redirect to /movies (playwright-utils helpers)', async ({ 28 | page 29 | }) => { 30 | await page.goto('/') 31 | 32 | await expect(page).toHaveURL('/movies') 33 | const { 34 | responseJson: { data: moviesResponse } 35 | } = (await loadGetMovies) as { responseJson: { data: typeof movies } } 36 | expect(moviesResponse).toEqual(movies) 37 | 38 | await expect(page.getByTestId('movie-list-comp')).toBeVisible() 39 | await expect(page.getByTestId('movie-form-comp')).toBeVisible() 40 | await expect(page.getByTestId('movie-item-comp')).toHaveCount(movies.length) 41 | 42 | await log.info( 43 | 'with PW you have to use for await of, since you have to await the expect' 44 | ) 45 | const movieItemComps = page.getByTestId('movie-item-comp').all() 46 | const items = await movieItemComps 47 | for (const item of items) { 48 | await expect(item).toBeVisible() 49 | } 50 | }) 51 | 52 | test('should direct nav to by query param (playwright-utils helpers)', async ({ 53 | page, 54 | interceptNetworkCall 55 | }) => { 56 | const movieName = encodeURIComponent(movie?.name as Movie['name']) 57 | 58 | const loadGetMovies2 = interceptNetworkCall({ 59 | method: 'GET', 60 | url: '/movies?*', 61 | fulfillResponse: { 62 | status: 200, 63 | body: movie 64 | } 65 | }) 66 | 67 | await page.goto(`/movies?name=${movieName}`) 68 | 69 | const { responseJson: resBody } = await loadGetMovies2 70 | expect(resBody).toEqual(movie) 71 | 72 | await expect(page).toHaveURL(`/movies?name=${movieName}`) 73 | }) 74 | }) 75 | -------------------------------------------------------------------------------- /sample-app/frontend/src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/intercept-network-call/core/observe-network-call.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import type { Page } from '@playwright/test' 3 | import type { NetworkCallResult } from './types' 4 | import { NetworkInterceptError, NetworkTimeoutError } from './types' 5 | import { matchesRequest } from './utils/matches-request' 6 | 7 | export async function observeNetworkCall< 8 | TRequest = unknown, 9 | TResponse = unknown 10 | >( 11 | page: Page, 12 | method?: string, 13 | url?: string, 14 | timeout?: number 15 | ): Promise> { 16 | try { 17 | const request = await page.waitForRequest( 18 | (req) => matchesRequest(req, method, url), 19 | { timeout } 20 | ) 21 | 22 | const response = await request.response() 23 | if (!response) { 24 | throw new NetworkInterceptError( 25 | 'No response received for the request', 26 | 'observe', 27 | url, 28 | method 29 | ) 30 | } 31 | 32 | let data: TResponse | null = null 33 | 34 | try { 35 | data = await response.json() 36 | } catch (_error) { 37 | const contentType = 38 | response.headers()['content-type'] || 39 | response.headers()['Content-Type'] || 40 | '' 41 | 42 | if (!contentType.includes('application/json')) { 43 | data = null 44 | } else { 45 | console.warn( 46 | 'Failed to parse JSON response despite Content-Type indicating JSON' 47 | ) 48 | } 49 | } 50 | 51 | let requestJson: TRequest | null = null 52 | try { 53 | requestJson = await request.postDataJSON() 54 | } catch (_error) { 55 | // Request has no post data or is not JSON 56 | } 57 | 58 | return { 59 | request, 60 | response, 61 | responseJson: data, 62 | status: response.status(), 63 | requestJson: requestJson 64 | } 65 | } catch (error) { 66 | // Handle timeout errors specifically 67 | if (error instanceof Error && error.message.includes('Timeout')) { 68 | throw new NetworkTimeoutError( 69 | 'Request timeout while observing network call', 70 | 'observe', 71 | timeout || 30000, 72 | url, 73 | method 74 | ) 75 | } 76 | 77 | // Re-throw our custom errors 78 | if (error instanceof NetworkInterceptError) { 79 | throw error 80 | } 81 | 82 | // Wrap other errors 83 | throw new NetworkInterceptError( 84 | `Failed to observe network call: ${error instanceof Error ? error.message : 'Unknown error'}`, 85 | 'observe', 86 | url, 87 | method 88 | ) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /playwright/support/auth/token/check-validity.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Token validation utilities 3 | * Provides functions to check if tokens are valid or need renewal 4 | */ 5 | import { log } from '../../../../src/log' 6 | import { loadStorageState } from '../../../../src/auth-session' 7 | import { extractToken } from './extract' 8 | import { isTokenExpired } from './is-expired' 9 | import { needsTokenRenewal, renewToken } from './renew' 10 | 11 | /** 12 | * Check if a token exists and is valid, renewing it if needed 13 | * @param tokenPath Path to the token storage file 14 | * @returns The storage state object if a valid token exists, null otherwise 15 | */ 16 | export async function checkTokenValidity( 17 | tokenPath: string 18 | ): Promise | null> { 19 | // Check if we already have a valid token 20 | const existingStorageState = loadStorageState(tokenPath) 21 | if (!existingStorageState) { 22 | return null 23 | } 24 | 25 | // Check if the token is expired using explicit validation 26 | const token = extractToken(existingStorageState) 27 | if (!token || isTokenExpired(token)) { 28 | return null 29 | } 30 | 31 | // Check if token needs renewal (JWT expired but refresh token valid) 32 | if (needsTokenRenewal(existingStorageState)) { 33 | log.infoSync( 34 | 'ℹ️ JWT token expired, attempting to renew token using refresh token' 35 | ) 36 | try { 37 | // Call our renewal helper function with the storage path 38 | await renewToken({ storageState: tokenPath }) 39 | 40 | // Load the updated storage state after successful renewal 41 | const renewedStorageState = loadStorageState(tokenPath) 42 | if (!renewedStorageState) { 43 | throw new Error('Failed to load renewed storage state') 44 | } 45 | 46 | // Verify the renewed token is valid 47 | const token = extractToken(renewedStorageState) 48 | if (!token || isTokenExpired(token)) { 49 | throw new Error('Renewed token is invalid or already expired') 50 | } 51 | 52 | log.infoSync(`✓ Successfully renewed token from ${tokenPath}`) 53 | return renewedStorageState 54 | } catch (error) { 55 | // If renewal fails, we'll proceed to get a new token via full login 56 | const errorMessage = 57 | error instanceof Error ? error.message : 'Unknown error' 58 | log.infoSync(`⚠️ Failed to renew token: ${errorMessage}`) 59 | log.infoSync('⚠️ Will attempt to acquire a new token') 60 | return null 61 | } 62 | } else { 63 | // Token is still valid, use it 64 | log.infoSync(`✓ Using existing token from ${tokenPath}`) 65 | return existingStorageState 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /playwright/support/global-setup.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Global setup script for Playwright testing 3 | * 4 | * This script handles initial setup tasks before tests run: 5 | * Explicitly configures authentication through a two-step process: 6 | * - Step 1: Configure auth storage settings with configureAuthSession 7 | * - Step 2: Register an auth provider implementation with setAuthProvider 8 | * 9 | * To use this, add to your playwright config: 10 | * ``` 11 | * globalSetup: '../support/global-setup.ts' 12 | * ``` 13 | */ 14 | 15 | import { 16 | authStorageInit, 17 | setAuthProvider, 18 | configureAuthSession, 19 | authGlobalInit 20 | } from '../../src/auth-session' 21 | 22 | type UserOptions = { 23 | admin: string 24 | fraudAnalystUser: string 25 | settingsAdminUser: string 26 | freeUser: string 27 | shopifyUser: string 28 | } 29 | 30 | export const VALID_TEST_USERS: UserOptions = { 31 | admin: 'admin', 32 | freeUser: 'freeUser', 33 | settingsAdminUser: 'settingsAdminUser', 34 | fraudAnalystUser: 'fraudAnalystUser', 35 | shopifyUser: 'shopifyUser' 36 | } 37 | 38 | // Uncomment to use the custom auth provider 39 | import myCustomProvider from './auth/custom-auth-provider' 40 | 41 | /** 42 | * Global setup function that runs before tests 43 | */ 44 | async function globalSetup() { 45 | console.log('Running global setup') 46 | 47 | // ======================================================================== 48 | // STEP 1: Configure minimal auth storage settings 49 | // ======================================================================== 50 | // Ensure storage directories exist (required for both auth approaches) 51 | 52 | // if single user 53 | // authStorageInit() 54 | 55 | // if multiple users 56 | for (const user of Object.values(VALID_TEST_USERS)) { 57 | authStorageInit({ 58 | userIdentifier: user 59 | }) 60 | } 61 | // This just sets up where tokens will be stored and debug options 62 | configureAuthSession({ 63 | debug: true 64 | }) 65 | 66 | // ======================================================================== 67 | // STEP 2: Set up custom auth provider 68 | // ======================================================================== 69 | // This defines HOW authentication tokens are acquired and used 70 | 71 | setAuthProvider(myCustomProvider) 72 | 73 | // Optional: pre-fetch all tokens in the beginning 74 | await authGlobalInit() 75 | // if the authUrl is different from the appUrl, you can pass it as an option 76 | // by default it takes AUTH_BASE_URL (if it exists) or baseURL/BASE_URL 77 | // await authGlobalInit({ 78 | // authBaseURL: 'https://auth.example.com' 79 | // }) 80 | } 81 | 82 | export default globalSetup 83 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # Repository Guidelines 2 | 3 | ## Project Structure & Module Organization 4 | 5 | Core TypeScript utilities live in `src/`, grouped by feature folders such as `api-request`, `intercept-network-call`, and `log`. Build outputs land in `dist/`; regenerate them via `npm run build` instead of editing. Docs stay in `docs/`, and Playwright specs, fixtures, and config live in `playwright/`. The `sample-app/{backend,frontend}` workspace powers demo flows, `scripts/` holds automation helpers, and transient outputs (`playwright-report/`, `test-results/`, `har-files/`) should remain untracked. 6 | 7 | ## Build, Test, and Development Commands 8 | 9 | - `npm run build`: clean + emit CJS, ESM, and type outputs into `dist/`. 10 | - `npm run validate`: parallel type-check, lint, unit tests, and formatting to catch regressions fast. 11 | - `npm run test`: executes `sample-app` backend Jest suite and frontend Vitest suite. 12 | - `npm run test:pw` / `npm run test:pw-ui`: run Playwright specs headless or in UI mode against `TEST_ENV=local`. 13 | - `npm run test:pw:burn-in`: repeats flaky suites (`PW_BURN_IN=true`) before promoting changes. 14 | - `npm run start:sample-app`: boots backend + frontend so local playwright runs hit real services. 15 | 16 | ## Coding Style & Naming Conventions 17 | 18 | Source targets Node 18+, so use 2-space indentation, trailing commas, and ES module syntax. Prefer named exports and keep file names kebab-case to satisfy `eslint-plugin-filenames`. Run `npm run fix:format` (Prettier + ESLint) before pushing, and store fixtures beside their modules (for example `src/api-request/fixtures.ts`). 19 | 20 | ## Testing Guidelines 21 | 22 | Author unit or component tests beside the code they verify, mirroring the `*.spec.ts` naming pattern in `sample-app`. Integration flows belong in `playwright/tests`; tag cases that hit external services and capture HTML reporter output when failures occur. Run `npm run validate` plus the needed Playwright command before every PR, and use `npm run test:pw:burn-in` for flaky or high-traffic fixtures. 23 | 24 | ## Commit & Pull Request Guidelines 25 | 26 | Use Conventional Commit prefixes (`feat:`, `fix:`, `docs:`, `chore:`) with subjects ≤72 characters and keep each commit focused. Include updated HAR/log assets whenever behavior shifts. PRs must link an issue, summarize the change, list validation steps, and attach screenshots or console output for UI/test updates; rerun `npm run validate` after rebases and note any skipped checks. 27 | 28 | ## Security & Configuration Tips 29 | 30 | Never commit `.env` values or Playwright storage states; rely on `TEST_ENV` and document defaults in `docs/`. Run `setup-playwright-browsers` after fresh installs for consistent binaries. Dry-run releases with `npm run publish:local` before tagging to confirm artifacts and registry scopes. 31 | -------------------------------------------------------------------------------- /src/api-request/schema-validation/core.ts: -------------------------------------------------------------------------------- 1 | /** Core schema validation engine */ 2 | 3 | // Zod types - will be loaded dynamically 4 | import type { SupportedSchema, ValidationResult, ShapeAssertion } from './types' 5 | import { validateShape } from './internal/shape-validator' 6 | import { processSchema } from './internal/schema-processors' 7 | import { 8 | buildValidationResult, 9 | buildErrorResult 10 | } from './internal/result-builder' 11 | 12 | /** Detect schema format based on input */ 13 | export function detectSchemaFormat( 14 | schema: SupportedSchema 15 | ): ValidationResult['schemaFormat'] { 16 | if (typeof schema === 'string') { 17 | if (schema.endsWith('.yaml') || schema.endsWith('.yml')) { 18 | return 'YAML OpenAPI' 19 | } 20 | return 'JSON OpenAPI' 21 | } 22 | 23 | if (schema && typeof schema === 'object') { 24 | // Check if it's a Zod schema 25 | if ( 26 | '_def' in schema && 27 | typeof (schema as unknown as Record).parse === 'function' 28 | ) { 29 | return 'Zod Schema' 30 | } 31 | 32 | // Check if it's an OpenAPI spec (has 'openapi' or 'swagger' field) 33 | if ( 34 | (schema as Record).openapi || 35 | (schema as Record).swagger 36 | ) { 37 | return 'JSON OpenAPI' 38 | } 39 | 40 | // Default to JSON Schema 41 | return 'JSON Schema' 42 | } 43 | 44 | return 'JSON Schema' 45 | } 46 | 47 | /** Main validation function using strategy pattern */ 48 | export async function validateSchema( 49 | data: unknown, 50 | schema: SupportedSchema, 51 | options: { 52 | shape?: ShapeAssertion 53 | path?: string 54 | endpoint?: string 55 | method?: string 56 | status?: number 57 | } = {} 58 | ): Promise { 59 | const startTime = Date.now() 60 | 61 | try { 62 | const schemaFormat = detectSchemaFormat(schema) 63 | 64 | // Process schema using appropriate strategy 65 | const processingResult = await processSchema( 66 | data, 67 | schema, 68 | schemaFormat, 69 | options 70 | ) 71 | 72 | let { validationErrors } = processingResult 73 | const { schemaForResult } = processingResult 74 | 75 | // Add shape validation if provided 76 | if (options.shape) { 77 | const shapeErrors = await validateShape(data, options.shape) 78 | validationErrors = [...validationErrors, ...shapeErrors] 79 | } 80 | 81 | const validationTime = Date.now() - startTime 82 | 83 | return buildValidationResult( 84 | validationErrors, 85 | schemaFormat, 86 | validationTime, 87 | schemaForResult 88 | ) 89 | } catch (error) { 90 | const validationTime = Date.now() - startTime 91 | return buildErrorResult(error, detectSchemaFormat(schema), validationTime) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /playwright/tests/network-record-playback/auto-fallback.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/support/merged-fixtures' 2 | import { addMovie } from '@playwright/support/ui-helpers/add-movie' 3 | import { promises as fs } from 'fs' 4 | import { log } from 'src/log' 5 | 6 | test.describe('network recorder - auto-record fallback', () => { 7 | test('should automatically switch to record mode when HAR file is missing', async ({ 8 | page, 9 | networkRecorder, 10 | context 11 | }) => { 12 | process.env.PW_NET_MODE = 'playback' 13 | 14 | const recorderContext = networkRecorder.getContext() 15 | const harFilePath = recorderContext.harFilePath 16 | 17 | await log.step( 18 | 'Delete HAR file if it exists to simulate missing HAR scenario' 19 | ) 20 | try { 21 | await fs.unlink(harFilePath) 22 | await log.debug(`Deleted existing HAR file: ${harFilePath}`) 23 | } catch { 24 | await log.debug(`HAR file does not exist (expected): ${harFilePath}`) 25 | } 26 | 27 | await log.step('Setup network recorder - should auto-switch to record mode') 28 | await networkRecorder.setup(context) 29 | 30 | await log.step('Verify the recorder switched to record mode') 31 | const contextAfterSetup = networkRecorder.getContext() 32 | expect(contextAfterSetup.mode).toBe('record') 33 | await log.info('✅ Verified mode switched from playback to record') 34 | 35 | await log.step('Navigate and perform actions to generate network traffic') 36 | await page.goto('/') 37 | 38 | const { name, year, rating, director } = { 39 | name: 'auto-fallback-test-movie', 40 | year: 2024, 41 | rating: 8.5, 42 | director: 'Test Director' 43 | } 44 | 45 | await log.step('add a movie to generate network traffic for recording') 46 | await addMovie(page, name, year, rating, director) 47 | await page.getByTestId('add-movie-button').click() 48 | 49 | await log.step('Wait for the movie to appear') 50 | await page.getByText(name).waitFor() 51 | 52 | await log.step('Cleanup network recorder to ensure HAR file is written') 53 | await networkRecorder.cleanup() 54 | 55 | await log.step('Verify HAR file was created and contains recorded data') 56 | const harExists = await fs 57 | .access(harFilePath) 58 | .then(() => true) 59 | .catch(() => false) 60 | expect(harExists).toBe(true) 61 | await log.info(`✅ Verified HAR file was created: ${harFilePath}`) 62 | 63 | await log.step('Verify HAR file has content') 64 | const harContent = await fs.readFile(harFilePath, 'utf-8') 65 | const harData = JSON.parse(harContent) 66 | expect(harData.log.entries.length).toBeGreaterThan(0) 67 | await log.info( 68 | `✅ Verified HAR file contains ${harData.log.entries.length} network requests` 69 | ) 70 | }) 71 | }) 72 | -------------------------------------------------------------------------------- /src/burn-in/core/config.ts: -------------------------------------------------------------------------------- 1 | import type { BurnInConfig } from './types' 2 | import * as fs from 'node:fs' 3 | import * as path from 'node:path' 4 | 5 | export const DEFAULT_CONFIG: BurnInConfig = { 6 | skipBurnInPatterns: [ 7 | '**/config/**', 8 | '**/configuration/**', 9 | '**/playwright.config.ts', 10 | '**/*featureFlags*', 11 | '**/*constants*', 12 | '**/*config*', 13 | '**/*types*', 14 | '**/*interfaces*', 15 | '**/package.json', 16 | '**/tsconfig.json', 17 | '**/*.md' 18 | ], 19 | testPatterns: ['**/*.spec.ts', '**/*.test.ts'], 20 | burnIn: { 21 | repeatEach: 3, 22 | retries: 0 23 | }, 24 | burnInTestPercentage: 1 25 | } 26 | 27 | export function loadConfig(configPath?: string): BurnInConfig { 28 | const configs: BurnInConfig[] = [DEFAULT_CONFIG] 29 | 30 | // Try to load from possible TypeScript config file locations 31 | const configPaths = configPath 32 | ? [configPath] 33 | : [ 34 | 'config/.burn-in.config.ts', // Recommended: organized in config folder 35 | '.burn-in.config.ts', // Alternative: project root (hidden) 36 | 'burn-in.config.ts', // Alternative: project root 37 | 'playwright/.burn-in.config.ts' // Alternative: playwright folder 38 | ] 39 | 40 | for (const configFile of configPaths) { 41 | if (fs.existsSync(configFile)) { 42 | try { 43 | // Only load TypeScript config files 44 | if (path.extname(configFile) !== '.ts') { 45 | console.warn( 46 | `⚠️ Skipping non-TypeScript config: ${configFile}. Only .ts config files are supported.` 47 | ) 48 | continue 49 | } 50 | 51 | // Safely load TypeScript config 52 | delete require.cache[path.resolve(configFile)] // Clear cache for fresh load 53 | const configModule = require(path.resolve(configFile)) 54 | const loadedConfig: BurnInConfig = configModule.default || configModule 55 | 56 | // Validate that it's actually a config object 57 | if (typeof loadedConfig !== 'object' || loadedConfig === null) { 58 | throw new Error('Config must export a BurnInConfig object') 59 | } 60 | 61 | configs.push(loadedConfig) 62 | console.log(`📋 Loaded burn-in config from: ${configFile}`) 63 | break 64 | } catch (error) { 65 | console.warn( 66 | `⚠️ Failed to load config from ${configFile}:`, 67 | error instanceof Error ? error.message : String(error) 68 | ) 69 | } 70 | } 71 | } 72 | 73 | // Merge all configs (later configs override earlier ones) 74 | return configs.reduce( 75 | (merged, config) => ({ 76 | ...merged, 77 | ...config, 78 | burnIn: { 79 | ...(merged.burnIn || {}), 80 | ...(config.burnIn || {}) 81 | } 82 | }), 83 | {} as BurnInConfig 84 | ) 85 | } 86 | -------------------------------------------------------------------------------- /.github/actions/setup-kafka/action.yml: -------------------------------------------------------------------------------- 1 | name: 'Setup and Manage Kafka' 2 | description: 'Sets up Kafka for testing, handles startup, health checks, and cleanup' 3 | 4 | inputs: 5 | kafka-compose-file: 6 | description: 'Path to the Kafka docker-compose file' 7 | required: false 8 | default: './sample-app/backend/src/events/kafka-cluster.yml' 9 | health-check-script: 10 | description: 'Path to the Kafka health check script' 11 | required: false 12 | default: './sample-app/backend/scripts/kafka-health-check.js' 13 | continue-on-error: 14 | description: 'Whether to continue if Kafka fails to start' 15 | required: false 16 | default: 'true' 17 | 18 | outputs: 19 | kafka-ready: 20 | description: 'Whether Kafka is successfully running' 21 | value: ${{ steps.health-check.outputs.ready }} 22 | 23 | runs: 24 | using: 'composite' 25 | steps: 26 | # 1. Start Kafka containers 27 | - name: Start Kafka 28 | id: start-kafka 29 | continue-on-error: ${{ inputs.continue-on-error == 'true' }} 30 | shell: bash 31 | run: | 32 | docker compose -f ${{ inputs.kafka-compose-file }} up -d --no-recreate 33 | 34 | # 2. Wait for Kafka to be ready with health checks 35 | - name: Wait for Kafka to be ready 36 | id: health-check 37 | continue-on-error: ${{ inputs.continue-on-error == 'true' }} 38 | shell: bash 39 | run: | 40 | echo "ready=false" >> $GITHUB_OUTPUT 41 | echo "Waiting for Kafka containers to initialize..." 42 | # First wait for containers to fully start up (30 seconds max) 43 | sleep 5 44 | for i in {1..5}; do 45 | echo "Attempt $i: Checking if Kafka containers are running..." 46 | if docker ps | grep -q "events-kafka"; then 47 | echo "Kafka container is running" 48 | break 49 | fi 50 | sleep 5 51 | done 52 | 53 | # Now wait for Kafka to be responsive (allow up to 3 failures) 54 | for i in {1..3}; do 55 | echo "Attempt $i: Checking Kafka connectivity..." 56 | if node ${{ inputs.health-check-script }}; then 57 | echo "✅ Kafka is ready!" 58 | echo "ready=true" >> $GITHUB_OUTPUT 59 | break 60 | else 61 | echo "⚠️ Kafka health check failed on attempt $i, retrying..." 62 | # Give Kafka a bit more time to initialize 63 | sleep 10 64 | fi 65 | done 66 | 67 | echo "Proceeding with tests (Kafka may or may not be fully ready)" 68 | 69 | # 3. Register cleanup hook to run after tests 70 | - name: Register Kafka cleanup 71 | id: register-cleanup 72 | shell: bash 73 | run: | 74 | echo "::save-state name=kafka_compose_file::${{ inputs.kafka-compose-file }}" 75 | echo "Kafka cleanup will be performed at the end of the workflow" 76 | -------------------------------------------------------------------------------- /src/file-utils/core/file-downloader.ts: -------------------------------------------------------------------------------- 1 | import type { Page } from '@playwright/test' 2 | import { existsSync } from 'node:fs' 3 | import path from 'node:path' 4 | import { recurse } from '../../recurse' 5 | 6 | export type DownloadOptions = { 7 | page: Page 8 | downloadDir: string 9 | /** A function that triggers the download action. */ 10 | trigger: () => Promise 11 | /** Optional: A specific filename to save the file as. If not provided, a unique name is generated. */ 12 | fileName?: string 13 | /** Optional: Timeout for waiting for the download event. Defaults to 30 seconds. */ 14 | timeout?: number 15 | } 16 | 17 | /** 18 | * Handles a file download in Playwright by wrapping the common boilerplate. 19 | * It waits for the download event, triggers the download, and saves the file. 20 | * 21 | * @returns The full path to the downloaded file. 22 | */ 23 | export async function handleDownload( 24 | options: DownloadOptions 25 | ): Promise { 26 | const { page, downloadDir, trigger, fileName, timeout = 30000 } = options 27 | 28 | const downloadPromise = page.waitForEvent('download', { timeout }) 29 | await trigger() 30 | const download = await downloadPromise 31 | const finalFileName = fileName || download.suggestedFilename() 32 | 33 | if (!finalFileName) { 34 | throw new Error( 35 | 'Download failed: No filename was suggested by the server, and no explicit `fileName` was provided.' 36 | ) 37 | } 38 | 39 | const extension = path.extname(finalFileName) 40 | const basename = path.basename(finalFileName, extension) 41 | const uniqueFilename = `${basename}-${Date.now()}${extension}` 42 | const downloadPath = path.join(downloadDir, uniqueFilename) 43 | 44 | await download.saveAs(downloadPath) 45 | 46 | // Wait for the file to be fully written to the disk 47 | await waitForFile({ filePath: downloadPath, timeout }) 48 | 49 | return downloadPath 50 | } 51 | 52 | type WaitFileOptions = { 53 | timeout?: number 54 | interval?: number 55 | /** 56 | * Controls logging behavior. 57 | * - If `true`, logs a default message to the console. 58 | * - If a `string`, logs that custom message. 59 | * - If `false` or `undefined`, logging is disabled. 60 | */ 61 | log?: boolean | string 62 | } 63 | 64 | async function waitForFile( 65 | options: { filePath: string } & WaitFileOptions 66 | ): Promise { 67 | const { filePath, ...waitOptions } = options 68 | const mergedOptions = { 69 | timeout: 30000, 70 | interval: 250, 71 | log: 'Waiting for file to be available', 72 | ...waitOptions 73 | } 74 | 75 | await recurse( 76 | async () => existsSync(filePath), 77 | (exists) => exists, 78 | { 79 | timeout: mergedOptions.timeout, 80 | interval: mergedOptions.interval, 81 | log: mergedOptions.log, 82 | error: `Timed out waiting for file to exist at: ${filePath}` 83 | } 84 | ) 85 | } 86 | -------------------------------------------------------------------------------- /test.http: -------------------------------------------------------------------------------- 1 | @baseUrl = http://localhost:3001 2 | 3 | ### 4 | # @name generateToken 5 | # Identity-based authentication with user information 6 | POST {{baseUrl}}/auth/identity-token 7 | Content-Type: application/json 8 | 9 | { 10 | "username": "test-user", 11 | "password": "password123", 12 | "userIdentifier": "admin" 13 | } 14 | 15 | ### 16 | # @name validateAuthWithToken 17 | # Test the authentication validation endpoint with token 18 | GET {{baseUrl}}/auth/validate 19 | Cookie: seon-jwt={{token}} 20 | 21 | ### 22 | # @name renewToken 23 | # This requires a valid refresh cookie to be present in your browser 24 | POST {{baseUrl}}/auth/renew 25 | Content-Type: application/json 26 | 27 | ### 28 | # Token for authentication 29 | @token = {{generateToken.response.body.token}} 30 | 31 | ### 32 | # @name heartbeat 33 | GET {{baseUrl}} 34 | 35 | ### 36 | # @name addMovie 37 | POST {{baseUrl}}/movies 38 | Content-Type: application/json 39 | Cookie: seon-jwt={{token}} 40 | 41 | { 42 | "name": "Inception", 43 | "year": 2010, 44 | "rating": 7.5, 45 | "director": "Christopher Nolan" 46 | } 47 | 48 | ### 49 | @movieId = {{addMovie.response.body.data.id}} 50 | @movieName = {{addMovie.response.body.data.name}} 51 | 52 | ### 53 | # @name getAllMovies 54 | GET {{baseUrl}}/movies 55 | Cookie: seon-jwt={{token}} 56 | 57 | ### 58 | # @name getMovieById 59 | GET {{baseUrl}}/movies/{{movieId}} 60 | Cookie: seon-jwt={{token}} 61 | 62 | ### 63 | # @name getMovieByName 64 | GET {{baseUrl}}/movies?name={{movieName}} 65 | Cookie: seon-jwt={{token}} 66 | 67 | ### 68 | # @name addDuplicateMovie 69 | POST {{baseUrl}}/movies/ 70 | Content-Type: application/json 71 | Cookie: seon-jwt={{token}} 72 | 73 | { 74 | "name": "Inception", 75 | "year": 2010, 76 | "rating": 7.5, 77 | "director": "Christopher Nolan" 78 | } 79 | 80 | ### 81 | # @name addMovieInvalidYear 82 | POST {{baseUrl}}/movies 83 | Content-Type: application/json 84 | Cookie: seon-jwt={{token}} 85 | 86 | { 87 | "name": "Invalid Year Movie", 88 | "year": 1800, 89 | "rating": 7.5, 90 | "director": "Christopher Nolan" 91 | } 92 | 93 | ### 94 | # @name updateMovie 95 | PUT {{baseUrl}}/movies/{{movieId}} 96 | Content-Type: application/json 97 | Cookie: seon-jwt={{token}} 98 | 99 | { 100 | "name": "Inception Updated", 101 | "year": 2015, 102 | "rating": 8.0, 103 | "director": "Steven Spielberg" 104 | } 105 | 106 | ### 107 | # @name deleteMovie 108 | DELETE {{baseUrl}}/movies/{{movieId}} 109 | Cookie: seon-jwt={{token}} 110 | 111 | ### 112 | # @name getNonExistentMovie 113 | GET {{baseUrl}}/movies/999 114 | Cookie: seon-jwt={{token}} 115 | Authorization: {{token}} 116 | 117 | 118 | ### 119 | # @name deleteNonExistentMovie 120 | DELETE {{baseUrl}}/movies/999 121 | Cookie: seon-jwt={{token}} 122 | Authorization: {{token}} 123 | 124 | 125 | -------------------------------------------------------------------------------- /playwright/tests/sample-app/frontend/movie-routes.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/support/merged-fixtures' 2 | import { generateMovieWithoutId } from '@playwright/support/utils/movie-factories' 3 | import type { Movie } from '@shared/types/movie-types' 4 | import type { Response } from '@playwright/test' 5 | 6 | test.describe('App routes (vanilla playwright)', () => { 7 | const movies = [ 8 | { id: 1, ...generateMovieWithoutId() }, 9 | { id: 2, ...generateMovieWithoutId() }, 10 | { id: 3, ...generateMovieWithoutId() } 11 | ] 12 | const movie = movies[0] 13 | let loadGetMovies: Promise 14 | 15 | test.beforeEach(async ({ page }) => { 16 | await page.route('**/movies', (route) => 17 | route.fulfill({ 18 | status: 200, 19 | body: JSON.stringify({ data: movies }), 20 | headers: { 'Content-Type': 'application/json' } 21 | }) 22 | ) 23 | loadGetMovies = page.waitForResponse( 24 | (response) => 25 | response.url().includes('/movies') && response.status() === 200 26 | ) 27 | }) 28 | 29 | test('should redirect to /movies @smoke (vanilla playwright)', async ({ 30 | page 31 | }) => { 32 | await page.goto('/') 33 | 34 | await expect(page).toHaveURL('/movies') 35 | const getMovies = await loadGetMovies 36 | const { data } = await getMovies.json() 37 | expect(data).toEqual(movies) 38 | 39 | await expect(page.getByTestId('movie-list-comp')).toBeVisible() 40 | await expect(page.getByTestId('movie-form-comp')).toBeVisible() 41 | await expect(page.getByTestId('movie-item-comp')).toHaveCount(movies.length) 42 | // with PW you have to use for await of, since you have to await the expect 43 | const movieItemComps = page.getByTestId('movie-item-comp').all() 44 | const items = await movieItemComps 45 | for (const item of items) { 46 | await expect(item).toBeVisible() 47 | } 48 | }) 49 | 50 | test('should direct nav to by query param (vanilla playwright)', async ({ 51 | page 52 | }) => { 53 | const movieName = encodeURIComponent(movie?.name as Movie['name']) 54 | 55 | await page.route('**/movies?*', (route) => 56 | route.fulfill({ 57 | status: 200, 58 | body: JSON.stringify(movie), 59 | headers: { 'Content-Type': 'application/json' } 60 | }) 61 | ) 62 | const loadGetMovies2 = page.waitForResponse( 63 | (response) => 64 | response.url().includes('/movies?') && response.status() === 200 65 | ) 66 | 67 | await page.goto(`/movies?name=${movieName}`) 68 | 69 | const getMovie = await loadGetMovies2 70 | const resBody = await getMovie.json() 71 | expect(resBody).toEqual(movie) 72 | 73 | await expect(page).toHaveURL(`/movies?name=${movieName}`) 74 | 75 | const movieItemComps = page.getByTestId('movie-item-comp').all() 76 | const items = await movieItemComps 77 | for (const item of items) { 78 | await expect(item).toBeVisible() 79 | } 80 | }) 81 | }) 82 | -------------------------------------------------------------------------------- /src/api-request/api-request-fixture.ts: -------------------------------------------------------------------------------- 1 | import { test as base } from '@playwright/test' 2 | import { apiRequest as apiRequestFunction } from './api-request' 3 | import type { ApiRequestParams } from './api-request' 4 | import type { EnhancedApiPromise } from './schema-validation/internal/promise-extension' 5 | 6 | /** 7 | * Type for the apiRequest fixture parameters - exactly like ApiRequestParams but without the 'request' property 8 | * which is handled internally by the fixture. 9 | */ 10 | export type ApiRequestFixtureParams = Omit 11 | 12 | export const test = base.extend<{ 13 | /** 14 | * Simplified helper for making API requests and returning the status and JSON body. 15 | * This helper automatically performs the request based on the provided method, path, body, and headers. 16 | * It handles URL construction with proper slash handling and response parsing based on content type. 17 | * 18 | * IMPORTANT: When using the fixture version, you do NOT need to provide the 'request' parameter, 19 | * as it's automatically injected by the fixture. 20 | * 21 | * @example 22 | * // GET request to an endpoint 23 | * test('fetch user data', async ({ apiRequest }) => { 24 | * const { status, body } = await apiRequest({ 25 | * method: 'GET', 26 | * path: '/api/users/123', // Note: use 'path' not 'url' 27 | * headers: { 'Authorization': 'Bearer token' } 28 | * }); 29 | * 30 | * expect(status).toBe(200); 31 | * expect(body.name).toBe('John Doe'); 32 | * }); 33 | * 34 | * @example 35 | * // POST request with a body 36 | * test('create new item', async ({ apiRequest }) => { 37 | * const { status, body } = await apiRequest({ 38 | * method: 'POST', 39 | * path: '/api/items', // Note: use 'path' not 'url' 40 | * baseUrl: 'https://api.example.com', // override default baseURL 41 | * body: { name: 'New Item', price: 19.99 }, 42 | * headers: { 'Content-Type': 'application/json' } 43 | * }); 44 | * 45 | * expect(status).toBe(201); 46 | * expect(body.id).toBeDefined(); 47 | * }); 48 | */ 49 | apiRequest: ( 50 | params: ApiRequestFixtureParams 51 | ) => EnhancedApiPromise 52 | }>({ 53 | apiRequest: async ({ request, baseURL, page }, use) => { 54 | const apiRequest = ({ 55 | method, 56 | path, 57 | baseUrl, 58 | configBaseUrl = baseURL, 59 | body = null, 60 | headers, 61 | params, 62 | uiMode = false, 63 | testStep 64 | }: ApiRequestFixtureParams): EnhancedApiPromise => { 65 | return apiRequestFunction({ 66 | request, 67 | method, 68 | path, 69 | baseUrl, 70 | configBaseUrl, 71 | body, 72 | headers, 73 | params, 74 | uiMode, 75 | testStep, 76 | page // Pass page context for UI mode 77 | }) 78 | } 79 | 80 | await use(apiRequest) 81 | } 82 | }) 83 | --------------------------------------------------------------------------------