├── .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 |
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 |
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 |
--------------------------------------------------------------------------------