Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/playwright-workflow-config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"pull_request": {
"runnerLabel": "ubuntu-latest",
"workers": 4
},
"production": {
"runnerLabel": "ubuntu-latest",
"maxParallel": 3,
"workers": 1
}
}
139 changes: 139 additions & 0 deletions .github/scripts/readPlaywrightWorkflowConfig.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;

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<string, string> =
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`,
);
82 changes: 75 additions & 7 deletions .github/workflows/playwright.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 }}")
Expand Down Expand Up @@ -89,30 +148,39 @@ 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 == 'failure' || needs.playwright.result == 'cancelled'
uses: actions/checkout@v5

- name: Use Node.js
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 == 'failure' || needs.playwright.result == 'cancelled'
run: npm ci

- name: Download Playwright blob reports
if: needs.playwright.result == 'failure' || needs.playwright.result == 'cancelled'
uses: actions/download-artifact@v5
with:
merge-multiple: true
path: all-blob-reports
pattern: playwright-blob-report-*

- name: Merge Playwright HTML report
if: needs.playwright.result == 'failure' || needs.playwright.result == 'cancelled'
run: npx playwright merge-reports --reporter html ./all-blob-reports
Comment on lines +151 to 180
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The merge-report steps run whenever needs.playwright.result != 'success', which includes 'skipped' (e.g., if resolve_config/setup fails and the matrix never runs). In that case there won’t be any blob-report artifacts and the download/merge steps will fail and add noise. Consider tightening these conditions to only run when the matrix actually ran (e.g., needs.playwright.result == 'failure' || needs.playwright.result == 'cancelled') and/or configuring the download step to ignore missing artifacts.

Copilot uses AI. Check for mistakes.

- name: Upload merged Playwright HTML report
if: always()
if: needs.playwright.result == 'failure' || needs.playwright.result == 'cancelled'
uses: actions/upload-artifact@v6
with:
name: playwright-report
Expand Down
Loading
Loading