From ed625856eb993b3fadc44ebc74d6da493da4f4e6 Mon Sep 17 00:00:00 2001 From: Tenemo Date: Sun, 29 Mar 2026 23:59:07 +0200 Subject: [PATCH 1/4] more robust Playwright config --- .claude/settings.local.json | 27 ++++ .github/playwright-workflow-config.json | 11 ++ .../scripts/readPlaywrightWorkflowConfig.ts | 139 ++++++++++++++++++ .github/workflows/playwright.yml | 82 ++++++++++- .github/workflows/production-e2e.yml | 70 +++++++-- playwright.config.ts | 37 ++++- tsconfig.json | 1 + 7 files changed, 343 insertions(+), 24 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 .github/playwright-workflow-config.json create mode 100644 .github/scripts/readPlaywrightWorkflowConfig.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..482bcc1 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "permissions": { + "allow": [ + "Bash(rg *)", + "Bash(grep *)", + "Bash(ls:*)", + "Bash(cat *)", + "Bash(find *)", + "Bash(head *)", + "Bash(tail *)", + "Bash(wc *)", + "Bash(sed -n *)", + "Bash(npm run:*)" + ], + "deny": [ + "Bash(rm *)", + "Bash(mv *)", + "Bash(cp *)", + "Bash(chmod *)", + "Bash(chown *)", + "Bash(sudo *)", + "Bash(curl *)", + "Bash(wget *)" + ] + } +} diff --git a/.github/playwright-workflow-config.json b/.github/playwright-workflow-config.json new file mode 100644 index 0000000..e2843c4 --- /dev/null +++ b/.github/playwright-workflow-config.json @@ -0,0 +1,11 @@ +{ + "pull_request": { + "runnerLabel": "ubuntu-24.04-16core", + "workers": 8 + }, + "production": { + "runnerLabel": "ubuntu-latest", + "maxParallel": 3, + "workers": 1 + } +} diff --git a/.github/scripts/readPlaywrightWorkflowConfig.ts b/.github/scripts/readPlaywrightWorkflowConfig.ts new file mode 100644 index 0000000..5f63a3c --- /dev/null +++ b/.github/scripts/readPlaywrightWorkflowConfig.ts @@ -0,0 +1,139 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const CONFIG_PATH = path.resolve( + process.cwd(), + '.github', + 'playwright-workflow-config.json', +); + +type WorkflowTarget = 'pull_request' | 'production'; + +type BaseWorkflowConfig = { + runnerLabel: string; + workers: number; +}; + +type ProductionWorkflowConfig = BaseWorkflowConfig & { + maxParallel: number; +}; + +type WorkflowConfig = { + pull_request: BaseWorkflowConfig; + production: ProductionWorkflowConfig; +}; + +function assertPositiveInteger(value: number, fieldName: string): void { + if (!Number.isInteger(value) || value < 1) { + throw new Error( + `Expected "${fieldName}" to be a positive integer, received ${String(value)}.`, + ); + } +} + +function parseWorkflowTarget(rawTarget: string | undefined): WorkflowTarget { + if (rawTarget === 'pull_request' || rawTarget === 'production') { + return rawTarget; + } + + throw new Error( + 'Expected a workflow target of "pull_request" or "production".', + ); +} + +function validateBaseConfig( + rawConfig: unknown, + fieldPrefix: WorkflowTarget, +): BaseWorkflowConfig { + if ( + typeof rawConfig !== 'object' || + rawConfig === null || + Array.isArray(rawConfig) + ) { + throw new Error(`Expected "${fieldPrefix}" to be an object.`); + } + + const config = rawConfig as Record; + const { runnerLabel, workers } = config; + + if (typeof runnerLabel !== 'string' || runnerLabel === '') { + throw new Error( + `Expected "${fieldPrefix}.runnerLabel" to be a non-empty string.`, + ); + } + + if (typeof workers !== 'number') { + throw new Error(`Expected "${fieldPrefix}.workers" to be a number.`); + } + + assertPositiveInteger(workers, `${fieldPrefix}.workers`); + + return { + runnerLabel, + workers, + }; +} + +function validateProductionConfig( + rawConfig: unknown, +): ProductionWorkflowConfig { + if ( + typeof rawConfig !== 'object' || + rawConfig === null || + Array.isArray(rawConfig) + ) { + throw new Error('Expected "production" to be an object.'); + } + + const config = rawConfig as Record; + const validatedConfig = validateBaseConfig(rawConfig, 'production'); + const { maxParallel } = config; + + if (typeof maxParallel !== 'number') { + throw new Error('Expected "production.maxParallel" to be a number.'); + } + + assertPositiveInteger(maxParallel, 'production.maxParallel'); + + return { + ...validatedConfig, + maxParallel, + }; +} + +function parseWorkflowConfig(rawConfig: unknown): WorkflowConfig { + if (typeof rawConfig !== 'object' || rawConfig === null) { + throw new Error('Expected workflow config to be an object.'); + } + + const maybeConfig = rawConfig as Record; + + return { + pull_request: validateBaseConfig( + maybeConfig.pull_request, + 'pull_request', + ), + production: validateProductionConfig(maybeConfig.production), + }; +} + +const target = parseWorkflowTarget(process.argv[2]); +const rawConfig = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) as unknown; +const parsedConfig = parseWorkflowConfig(rawConfig); +const outputs: Record = + target === 'pull_request' + ? { + runner_label: parsedConfig.pull_request.runnerLabel, + workers: String(parsedConfig.pull_request.workers), + } + : { + runner_label: parsedConfig.production.runnerLabel, + workers: String(parsedConfig.production.workers), + max_parallel: String(parsedConfig.production.maxParallel), + }; + +process.stdout.write( + `${Object.entries(outputs) + .map(([key, value]) => `${key}=${value}`) + .join('\n')}\n`, +); diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index b6a38bc..378f71b 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -8,9 +8,59 @@ permissions: contents: read jobs: + resolve_config: + name: Resolve Playwright config + runs-on: ubuntu-latest + outputs: + runner_label: ${{ steps.config.outputs.runner_label }} + workers: ${{ steps.config.outputs.workers }} + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Use Node.js + uses: actions/setup-node@v5 + with: + node-version: '22.x' + + - name: Read Playwright workflow config + id: config + run: node --experimental-strip-types .github/scripts/readPlaywrightWorkflowConfig.ts pull_request >> "$GITHUB_OUTPUT" + + setup: + name: Prepare Playwright E2E + needs: resolve_config + runs-on: ${{ needs.resolve_config.outputs.runner_label }} + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Use Node.js + uses: actions/setup-node@v5 + with: + node-version: '22.x' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build production output + env: + GH_TOKEN: ${{ github.token }} + run: npm run build:skip + + - name: Upload built site artifact + uses: actions/upload-artifact@v6 + with: + name: playwright-dist-client + path: dist/client/ + if-no-files-found: error + playwright: name: Playwright E2E (${{ matrix.engine.name }}) - runs-on: ubuntu-latest + needs: [resolve_config, setup] + runs-on: ${{ needs.resolve_config.outputs.runner_label }} timeout-minutes: 30 strategy: fail-fast: false @@ -36,6 +86,7 @@ jobs: mobile_project: Mobile Safari (iPhone 15) env: PLAYWRIGHT_BLOB_REPORT: 'true' + PLAYWRIGHT_CI_WORKERS: ${{ needs.resolve_config.outputs.workers }} steps: - name: Checkout uses: actions/checkout@v5 @@ -49,14 +100,22 @@ jobs: - name: Install dependencies run: npm ci + - name: Restore built site artifact + uses: actions/download-artifact@v5 + with: + name: playwright-dist-client + path: dist/client + + - name: Cache Playwright browsers + id: playwright-browser-cache + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ matrix.engine.browser }}-${{ hashFiles('package-lock.json') }} + - name: Install Playwright browser run: npx playwright install --with-deps ${{ matrix.engine.browser }} - - name: Build production output - env: - GH_TOKEN: ${{ github.token }} - run: npm run build:skip - - name: Run Playwright tests run: | command=(npx playwright test --project "${{ matrix.engine.desktop_project }}" --project "${{ matrix.engine.mobile_project }}") @@ -89,19 +148,27 @@ jobs: name: Merge Playwright report runs-on: ubuntu-latest steps: + - name: Skip merged report on green matrix + if: needs.playwright.result == 'success' + run: echo "Skipping merged Playwright HTML report because all PR matrix jobs passed." + - name: Checkout + if: needs.playwright.result != 'success' uses: actions/checkout@v5 - name: Use Node.js + if: needs.playwright.result != 'success' uses: actions/setup-node@v5 with: node-version: '22.x' cache: 'npm' - name: Install dependencies + if: needs.playwright.result != 'success' run: npm ci - name: Download Playwright blob reports + if: needs.playwright.result != 'success' uses: actions/download-artifact@v5 with: merge-multiple: true @@ -109,10 +176,11 @@ jobs: pattern: playwright-blob-report-* - name: Merge Playwright HTML report + if: needs.playwright.result != 'success' run: npx playwright merge-reports --reporter html ./all-blob-reports - name: Upload merged Playwright HTML report - if: always() + if: needs.playwright.result != 'success' uses: actions/upload-artifact@v6 with: name: playwright-report diff --git a/.github/workflows/production-e2e.yml b/.github/workflows/production-e2e.yml index 0523b3b..50a5668 100644 --- a/.github/workflows/production-e2e.yml +++ b/.github/workflows/production-e2e.yml @@ -19,13 +19,34 @@ concurrency: cancel-in-progress: false jobs: + resolve_config: + name: Resolve Production Playwright config + runs-on: ubuntu-latest + outputs: + runner_label: ${{ steps.config.outputs.runner_label }} + workers: ${{ steps.config.outputs.workers }} + max_parallel: ${{ steps.config.outputs.max_parallel }} + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Use Node.js + uses: actions/setup-node@v5 + with: + node-version: '22.x' + + - name: Read Playwright workflow config + id: config + run: node --experimental-strip-types .github/scripts/readPlaywrightWorkflowConfig.ts production >> "$GITHUB_OUTPUT" + playwright: name: Production Playwright E2E (${{ matrix.project.name }}) - runs-on: ubuntu-latest + needs: resolve_config + runs-on: ${{ needs.resolve_config.outputs.runner_label }} timeout-minutes: 30 strategy: fail-fast: false - max-parallel: 3 + max-parallel: ${{ fromJSON(needs.resolve_config.outputs.max_parallel) }} matrix: project: - name: Desktop Chrome @@ -51,6 +72,7 @@ jobs: browser: firefox env: PLAYWRIGHT_BLOB_REPORT: 'true' + PLAYWRIGHT_REMOTE_CI_WORKERS: ${{ needs.resolve_config.outputs.workers }} steps: - name: Checkout uses: actions/checkout@v5 @@ -100,6 +122,13 @@ jobs: echo "PRODUCTION_E2E_PUBLISHED_URL=$published_url" } >> "$GITHUB_ENV" + - name: Cache Playwright browsers + id: playwright-browser-cache + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ matrix.project.browser }}-${{ hashFiles('package-lock.json') }} + - name: Install Playwright browser run: npx playwright install --with-deps ${{ matrix.project.browser }} @@ -124,22 +153,10 @@ jobs: merge-production-playwright-report: if: always() - needs: playwright + needs: [resolve_config, playwright] name: Merge Production Playwright report runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v5 - - - name: Use Node.js - uses: actions/setup-node@v5 - with: - node-version: '22.x' - cache: 'npm' - - - name: Install dependencies - run: npm ci - - name: Resolve production E2E context env: EVENT_NAME: ${{ github.event_name }} @@ -189,7 +206,27 @@ jobs: echo "- Netlify published URL: $PRODUCTION_E2E_PUBLISHED_URL" } >> "$GITHUB_STEP_SUMMARY" + - name: Skip merged report on green matrix + if: needs.playwright.result == 'success' + run: echo "Skipping merged Playwright HTML report because all production matrix jobs passed." + + - name: Checkout + if: needs.playwright.result != 'success' + uses: actions/checkout@v5 + + - name: Use Node.js + if: needs.playwright.result != 'success' + uses: actions/setup-node@v5 + with: + node-version: '22.x' + cache: 'npm' + + - name: Install dependencies + if: needs.playwright.result != 'success' + run: npm ci + - name: Download Playwright blob reports + if: needs.playwright.result != 'success' uses: actions/download-artifact@v5 with: merge-multiple: true @@ -197,10 +234,11 @@ jobs: pattern: production-playwright-blob-report-* - name: Merge Playwright HTML report + if: needs.playwright.result != 'success' run: npx playwright merge-reports --reporter html ./production-blob-reports - name: Upload merged Playwright HTML report - if: always() + if: needs.playwright.result != 'success' uses: actions/upload-artifact@v6 with: name: production-playwright-report diff --git a/playwright.config.ts b/playwright.config.ts index 0b01fc0..6e9d235 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -35,13 +35,48 @@ const galaxyS24 = devices['Galaxy S24']; const { defaultBrowserType: _defaultBrowserType, ...galaxyS24Device } = galaxyS24; +function parseWorkerCount( + rawValue: string | undefined, + fallback: number | undefined, +): number | undefined { + if (rawValue === undefined || rawValue === '') { + return fallback; + } + + const parsedValue = Number(rawValue); + + if ( + !Number.isFinite(parsedValue) || + !Number.isInteger(parsedValue) || + parsedValue < 1 + ) { + throw new Error( + `Invalid worker count "${rawValue}". Expected a positive integer.`, + ); + } + + return parsedValue; +} + +const ciWorkerCount = parseWorkerCount(process.env.PLAYWRIGHT_CI_WORKERS, 4); +const remoteCiWorkerCount = parseWorkerCount( + process.env.PLAYWRIGHT_REMOTE_CI_WORKERS, + 1, +); + export default defineConfig({ testDir: './e2e', outputDir: 'test-results', fullyParallel: Boolean(process.env.CI) && !SHOULD_USE_REMOTE_E2E, forbidOnly: Boolean(process.env.CI), retries: process.env.CI ? 1 : 0, - workers: SHOULD_USE_REMOTE_E2E ? 1 : process.env.CI ? 4 : undefined, + workers: SHOULD_USE_REMOTE_E2E + ? process.env.CI + ? remoteCiWorkerCount + : 1 + : process.env.CI + ? ciWorkerCount + : undefined, reporter: reporters, use: { baseURL: E2E_BASE_URL, diff --git a/tsconfig.json b/tsconfig.json index ff78178..9cac537 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -60,6 +60,7 @@ "e2e/**/*", "netlify/**/*", "scripts/**/*", + ".github/scripts/**/*", "temp/**/*", "eslint.config.js", "playwright.config.ts", From d90117b91d6f40d606a8068b447715b6f1e82db2 Mon Sep 17 00:00:00 2001 From: Tenemo Date: Mon, 30 Mar 2026 00:43:22 +0200 Subject: [PATCH 2/4] .claude/ gitignored --- .claude/settings.local.json | 27 --------------------------- .gitignore | 1 + 2 files changed, 1 insertion(+), 27 deletions(-) delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 482bcc1..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/claude-code-settings.json", - "permissions": { - "allow": [ - "Bash(rg *)", - "Bash(grep *)", - "Bash(ls:*)", - "Bash(cat *)", - "Bash(find *)", - "Bash(head *)", - "Bash(tail *)", - "Bash(wc *)", - "Bash(sed -n *)", - "Bash(npm run:*)" - ], - "deny": [ - "Bash(rm *)", - "Bash(mv *)", - "Bash(cp *)", - "Bash(chmod *)", - "Bash(chown *)", - "Bash(sudo *)", - "Bash(curl *)", - "Bash(wget *)" - ] - } -} diff --git a/.gitignore b/.gitignore index 5e39ba9..1a1af95 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ test-results/ .DS_Store *concatenated.txt .react-router/ +.claude/ # env .env From 56ec0711ed11a55201614fb21355485d14b1589a Mon Sep 17 00:00:00 2001 From: Tenemo Date: Mon, 30 Mar 2026 00:48:17 +0200 Subject: [PATCH 3/4] GH workflow better error handling --- .github/workflows/playwright.yml | 12 ++++++------ .github/workflows/production-e2e.yml | 12 ++++++------ playwright.config.ts | 10 ++++++++-- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 378f71b..4ed9feb 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -153,22 +153,22 @@ jobs: run: echo "Skipping merged Playwright HTML report because all PR matrix jobs passed." - name: Checkout - if: needs.playwright.result != 'success' + if: needs.playwright.result == 'failure' || needs.playwright.result == 'cancelled' uses: actions/checkout@v5 - name: Use Node.js - if: needs.playwright.result != 'success' + if: needs.playwright.result == 'failure' || needs.playwright.result == 'cancelled' uses: actions/setup-node@v5 with: node-version: '22.x' cache: 'npm' - name: Install dependencies - if: needs.playwright.result != 'success' + if: needs.playwright.result == 'failure' || needs.playwright.result == 'cancelled' run: npm ci - name: Download Playwright blob reports - if: needs.playwright.result != 'success' + if: needs.playwright.result == 'failure' || needs.playwright.result == 'cancelled' uses: actions/download-artifact@v5 with: merge-multiple: true @@ -176,11 +176,11 @@ jobs: pattern: playwright-blob-report-* - name: Merge Playwright HTML report - if: needs.playwright.result != 'success' + if: needs.playwright.result == 'failure' || needs.playwright.result == 'cancelled' run: npx playwright merge-reports --reporter html ./all-blob-reports - name: Upload merged Playwright HTML report - if: needs.playwright.result != 'success' + if: needs.playwright.result == 'failure' || needs.playwright.result == 'cancelled' uses: actions/upload-artifact@v6 with: name: playwright-report diff --git a/.github/workflows/production-e2e.yml b/.github/workflows/production-e2e.yml index 50a5668..1012cc6 100644 --- a/.github/workflows/production-e2e.yml +++ b/.github/workflows/production-e2e.yml @@ -211,22 +211,22 @@ jobs: run: echo "Skipping merged Playwright HTML report because all production matrix jobs passed." - name: Checkout - if: needs.playwright.result != 'success' + if: needs.playwright.result == 'failure' || needs.playwright.result == 'cancelled' uses: actions/checkout@v5 - name: Use Node.js - if: needs.playwright.result != 'success' + if: needs.playwright.result == 'failure' || needs.playwright.result == 'cancelled' uses: actions/setup-node@v5 with: node-version: '22.x' cache: 'npm' - name: Install dependencies - if: needs.playwright.result != 'success' + if: needs.playwright.result == 'failure' || needs.playwright.result == 'cancelled' run: npm ci - name: Download Playwright blob reports - if: needs.playwright.result != 'success' + if: needs.playwright.result == 'failure' || needs.playwright.result == 'cancelled' uses: actions/download-artifact@v5 with: merge-multiple: true @@ -234,11 +234,11 @@ jobs: pattern: production-playwright-blob-report-* - name: Merge Playwright HTML report - if: needs.playwright.result != 'success' + if: needs.playwright.result == 'failure' || needs.playwright.result == 'cancelled' run: npx playwright merge-reports --reporter html ./production-blob-reports - name: Upload merged Playwright HTML report - if: needs.playwright.result != 'success' + if: needs.playwright.result == 'failure' || needs.playwright.result == 'cancelled' uses: actions/upload-artifact@v6 with: name: production-playwright-report diff --git a/playwright.config.ts b/playwright.config.ts index 6e9d235..f47a3d5 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -36,6 +36,7 @@ const { defaultBrowserType: _defaultBrowserType, ...galaxyS24Device } = galaxyS24; function parseWorkerCount( + envVarName: string, rawValue: string | undefined, fallback: number | undefined, ): number | undefined { @@ -51,15 +52,20 @@ function parseWorkerCount( parsedValue < 1 ) { throw new Error( - `Invalid worker count "${rawValue}". Expected a positive integer.`, + `Invalid ${envVarName} value "${rawValue}". Expected a positive integer.`, ); } return parsedValue; } -const ciWorkerCount = parseWorkerCount(process.env.PLAYWRIGHT_CI_WORKERS, 4); +const ciWorkerCount = parseWorkerCount( + 'PLAYWRIGHT_CI_WORKERS', + process.env.PLAYWRIGHT_CI_WORKERS, + 4, +); const remoteCiWorkerCount = parseWorkerCount( + 'PLAYWRIGHT_REMOTE_CI_WORKERS', process.env.PLAYWRIGHT_REMOTE_CI_WORKERS, 1, ); From 1a1631af77209a156eb3013744add53a97677caa Mon Sep 17 00:00:00 2001 From: Tenemo Date: Mon, 30 Mar 2026 00:53:41 +0200 Subject: [PATCH 4/4] stuck action fix --- .github/playwright-workflow-config.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/playwright-workflow-config.json b/.github/playwright-workflow-config.json index e2843c4..a5d68c6 100644 --- a/.github/playwright-workflow-config.json +++ b/.github/playwright-workflow-config.json @@ -1,7 +1,7 @@ { "pull_request": { - "runnerLabel": "ubuntu-24.04-16core", - "workers": 8 + "runnerLabel": "ubuntu-latest", + "workers": 4 }, "production": { "runnerLabel": "ubuntu-latest",