# Copyright 2021 Collate
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# This workflow executes end-to-end (e2e) tests using Playwright with PostgreSQL as the database.
# For more information see: https://github.com/actions/setup-java/blob/main/docs/advanced-usage.md#apache-maven-with-a-settings-path
name: Postgresql PR Playwright E2E Tests
on:
merge_group:
workflow_dispatch:
# Note: paths-ignore removed โ the workflow always triggers so that the
# required status check (playwright-summary) always completes. Path filtering
# is handled inside the workflow via dorny/paths-filter so PRs without
# relevant changes skip the expensive jobs while still reporting a passing status.
pull_request_target:
types:
- labeled
- opened
- synchronize
- reopened
- ready_for_review
permissions:
contents: read
concurrency:
group: playwright-ci-pr-postgresql-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name != 'pull_request_target' || github.event.action != 'labeled' || github.event.label.name == 'safe to test' }}
jobs:
check-changes:
runs-on: ubuntu-latest
outputs:
e2e: ${{ steps.filter.outputs.e2e }}
docker-compose: ${{ steps.filter.outputs.docker-compose }}
steps:
- uses: dorny/paths-filter@v4
id: filter
with:
filters: |
e2e:
- 'openmetadata-service/**'
- 'openmetadata-ui/**'
- 'openmetadata-spec/**'
- 'openmetadata-integration-tests/**'
- 'ingestion/**'
- 'bootstrap/**'
- 'conf/**'
- 'docker/development/**'
- 'openmetadata-clients/**'
- 'openmetadata-airflow-apis/**'
- 'openmetadata-mcp/**'
- 'openmetadata-sdk/**'
- 'openmetadata-shaded-deps/**'
- 'openmetadata-ui-core-components/**'
- 'openmetadata-k8s-operator/**'
- 'common/**'
- 'openspec/**'
- 'pom.xml'
- 'Makefile'
docker-compose:
- 'docker/development/docker-compose.yml'
- 'docker/development/docker-compose-postgres.yml'
build:
needs: check-changes
runs-on: ubuntu-latest
if: |
!github.event.pull_request.draft &&
(github.event_name == 'merge_group' || github.event_name == 'workflow_dispatch' ||
needs.check-changes.outputs.e2e == 'true' || needs.check-changes.outputs.docker-compose == 'true') &&
(github.event_name != 'pull_request_target' || github.event.action != 'labeled' || github.event.label.name == 'safe to test')
steps:
- name: Wait for the labeler
uses: lewagon/wait-on-check-action@v1.7.0
if: ${{ github.event_name == 'pull_request_target' }}
with:
ref: ${{ github.event.pull_request.head.sha }}
check-name: Team Label
repo-token: ${{ secrets.GITHUB_TOKEN }}
wait-interval: 90
- name: Verify PR labels
uses: jesusvasquez333/verify-pr-label-action@v1.4.0
if: ${{ github.event_name == 'pull_request_target' }}
with:
github-token: "${{ secrets.GITHUB_TOKEN }}"
valid-labels: "safe to test"
pull-request-number: "${{ github.event.pull_request.number }}"
disable-reviews: true
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'merge_group' && github.sha || github.event.pull_request.head.sha }}
- name: Setup JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- name: Cache Maven Dependencies
uses: actions/cache@v4
with:
path: ~/.m2
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
restore-keys: |
${{ runner.os }}-maven-
- name: Install antlr cli
run: sudo make install_antlr_cli
- name: Build with Maven
run: mvn -DskipTests clean package
- name: Upload Maven build artifact
uses: actions/upload-artifact@v4
with:
name: openmetadata-build
path: openmetadata-dist/target/openmetadata-*.tar.gz
retention-days: 1
detect-changes:
needs: check-changes
runs-on: ubuntu-latest
if: |
!github.event.pull_request.draft &&
(github.event_name == 'merge_group' || github.event_name == 'workflow_dispatch' ||
needs.check-changes.outputs.e2e == 'true' || needs.check-changes.outputs.docker-compose == 'true') &&
(github.event_name != 'pull_request_target' || github.event.action != 'labeled' || github.event.label.name == 'safe to test')
outputs:
spec_only_mode: ${{ steps.compute.outputs.spec_only_mode }}
changed_specs: ${{ steps.compute.outputs.changed_specs }}
matrix: ${{ steps.compute.outputs.matrix }}
steps:
- name: Checkout
if: ${{ github.event_name == 'pull_request_target' }}
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
filter: blob:none
- name: Get all changed files
id: all-changes
if: ${{ github.event_name == 'pull_request_target' }}
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
- name: Get changed runnable spec files
id: changed-specs
if: ${{ github.event_name == 'pull_request_target' }}
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
with:
path: openmetadata-ui/src/main/resources/ui
files: |
playwright/e2e/**/*.spec.ts
files_ignore: |
playwright/e2e/**/SystemCertificationTags.spec.ts
playwright/e2e/**/IntakeForm.spec.ts
- name: Compute spec-only mode and matrix
id: compute
env:
ALL_FILES: ${{ steps.all-changes.outputs.all_changed_files }}
SPEC_FILES: ${{ steps.changed-specs.outputs.all_changed_files }}
run: |
FULL_MATRIX='{"shardIndex":[1,2,3,4,5,6,7],"shardTotal":[7]}'
SPEC_MATRIX='{"shardIndex":[1],"shardTotal":[1]}'
ALL_COUNT=$(echo "$ALL_FILES" | wc -w)
SPEC_COUNT=$(echo "$SPEC_FILES" | wc -w)
if [ "$SPEC_COUNT" -gt 0 ] && [ "$ALL_COUNT" -eq "$SPEC_COUNT" ]; then
echo "spec_only_mode=true" >> "$GITHUB_OUTPUT"
echo "changed_specs=$SPEC_FILES" >> "$GITHUB_OUTPUT"
echo "matrix=$SPEC_MATRIX" >> "$GITHUB_OUTPUT"
echo "Spec-only mode: ${SPEC_COUNT} spec(s) changed"
else
echo "spec_only_mode=false" >> "$GITHUB_OUTPUT"
echo "changed_specs=" >> "$GITHUB_OUTPUT"
echo "matrix=$FULL_MATRIX" >> "$GITHUB_OUTPUT"
echo "Full suite mode (total=${ALL_COUNT}, runnable-specs=${SPEC_COUNT})"
fi
playwright-ci-postgresql:
needs: [build, detect-changes]
runs-on: ubuntu-latest
if: ${{ !cancelled() && needs.build.result == 'success' && needs.detect-changes.result == 'success' }}
environment: test
permissions:
contents: read
id-token: write # Required for GitHub OIDC (Flakiness.io reporter)
env:
# Playwright logs the admin user in many times (performAdminLogin per test, parallel
# workers, retries). The production default of 5 active sessions per user would evict the
# long-lived storageState session the page fixtures rely on and 401 every request. Raise
# the cap for E2E only; docker compose reads this for ${AUTHENTICATION_MAX_ACTIVE_SESSIONS_PER_USER:-5}.
AUTHENTICATION_MAX_ACTIVE_SESSIONS_PER_USER: "10000"
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.detect-changes.outputs.matrix) }}
steps:
- name: Free Disk Space (Ubuntu)
uses: jlumbroso/free-disk-space@main
with:
tool-cache: false
android: true
dotnet: true
haskell: true
large-packages: false
swap-storage: true
docker-images: false
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'merge_group' && github.sha || github.event.pull_request.head.sha }}
- name: Download Maven build artifact
uses: actions/download-artifact@v4
with:
name: openmetadata-build
path: openmetadata-dist/target
- name: Setup Openmetadata Test Environment
uses: ./.github/actions/setup-openmetadata-test-environment
with:
python-version: "3.10"
args: "-d postgresql -s true"
ingestion_dependency: "all"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: "openmetadata-ui/src/main/resources/ui/.nvmrc"
- name: Install dependencies
working-directory: openmetadata-ui/src/main/resources/ui/
run: yarn --ignore-scripts --frozen-lockfile
- name: Install Playwright Browsers
run: npx playwright@1.57.0 install chromium --with-deps
- name: Run Playwright tests
id: run-tests
working-directory: openmetadata-ui/src/main/resources/ui/
run: |
if [ "$SPEC_ONLY" = "true" ]; then
echo "๐น Spec-only mode: running changed specs โ ${CHANGED_SPECS}"
PROJECT_ARGS=""
# DataAssetRulesEnabled runs standalone; its deps (setup) are resolved automatically
if echo "$CHANGED_SPECS" | tr ' ' '\n' | grep -q "DataAssetRulesEnabled\.spec\.ts"; then
PROJECT_ARGS="$PROJECT_ARGS --project=DataAssetRulesEnabled"
fi
# DataAssetRulesDisabled depends on DataAssetRulesEnabled โ Playwright resolves this automatically
if echo "$CHANGED_SPECS" | tr ' ' '\n' | grep -q "DataAssetRulesDisabled\.spec\.ts"; then
PROJECT_ARGS="$PROJECT_ARGS --project=DataAssetRulesDisabled"
fi
# SearchRBAC depends on the full DataAssetRules chain โ Playwright resolves this automatically
if echo "$CHANGED_SPECS" | tr ' ' '\n' | grep -q "SearchRBAC\.spec\.ts"; then
PROJECT_ARGS="$PROJECT_ARGS --project=SearchRBAC"
fi
# DomainIsolation uses serial workers and its own testMatch
if echo "$CHANGED_SPECS" | tr ' ' '\n' | grep -q "DomainIsolation/"; then
PROJECT_ARGS="$PROJECT_ARGS --project=DomainIsolation"
fi
# Search relevance specs use the dedicated search-nightly project.
if echo "$CHANGED_SPECS" | tr ' ' '\n' | grep -q "playwright/e2e/Search/"; then
PROJECT_ARGS="$PROJECT_ARGS --project=search-nightly"
fi
# Regular chromium specs: anything not in a special-project category.
# Also add Basic because chromium has grepInvert:[@basic], so @basic-tagged tests
# in the changed files would be silently skipped without it.
if echo "$CHANGED_SPECS" | tr ' ' '\n' | grep -qvE "(DataAssetRulesEnabled\.spec\.ts|DataAssetRulesDisabled\.spec\.ts|SearchRBAC\.spec\.ts|DomainIsolation/|playwright/e2e/Search/)"; then
PROJECT_ARGS="$PROJECT_ARGS --project=chromium --project=Basic"
fi
[ -z "$PROJECT_ARGS" ] && PROJECT_ARGS="--project=chromium --project=Basic"
npx playwright test $PROJECT_ARGS $CHANGED_SPECS
elif [ "${{ matrix.shardIndex }}" -eq "1" ]; then
echo "๐น Running DataAssetRules-only tests on shard 1"
# The testMatch pattern ensures only DataAssetRules*.spec.ts files run
npx playwright test \
--project=setup \
--project=DataAssetRulesEnabled \
--project=DataAssetRulesDisabled \
--project=Basic \
--project=SearchRBAC \
--project=DomainIsolation \
elif [ "${{ matrix.shardIndex }}" -eq "2" ]; then
echo "๐น Running search relevance tests on shard 2"
npx playwright test \
--project=search-nightly \
playwright/e2e/Search/SearchRelevance.spec.ts \
--workers=1
else
# Shards 3-7 run common chromium tests equally distributed (5-way sharding)
CHROMIUM_SHARD=$(( ${{ matrix.shardIndex }} - 2 ))
echo "๐น Running common chromium tests on shard ${CHROMIUM_SHARD}/5"
npx playwright test \
--project=chromium \
--grep-invert @dataAssetRules \
--shard=${CHROMIUM_SHARD}/5
fi
env:
SPEC_ONLY: ${{ needs.detect-changes.outputs.spec_only_mode }}
CHANGED_SPECS: ${{ needs.detect-changes.outputs.changed_specs }}
PLAYWRIGHT_IS_OSS: true
PLAYWRIGHT_SNOWFLAKE_USERNAME: ${{ secrets.TEST_SNOWFLAKE_USERNAME }}
PLAYWRIGHT_SNOWFLAKE_PASSWORD: ${{ secrets.TEST_SNOWFLAKE_PASSWORD }}
PLAYWRIGHT_SNOWFLAKE_ACCOUNT: ${{ secrets.TEST_SNOWFLAKE_ACCOUNT }}
PLAYWRIGHT_SNOWFLAKE_DATABASE: ${{ secrets.TEST_SNOWFLAKE_DATABASE }}
PLAYWRIGHT_SNOWFLAKE_WAREHOUSE: ${{ secrets.TEST_SNOWFLAKE_WAREHOUSE }}
PLAYWRIGHT_SNOWFLAKE_PASSPHRASE: ${{ secrets.TEST_SNOWFLAKE_PASSPHRASE }}
PLAYWRIGHT_PROJECT_ID: ${{ steps.cypress-project-id.outputs.CYPRESS_PROJECT_ID }}
PLAYWRIGHT_BQ_PRIVATE_KEY: ${{ secrets.TEST_BQ_PRIVATE_KEY }}
PLAYWRIGHT_BQ_PROJECT_ID: ${{ secrets.PLAYWRIGHT_BQ_PROJECT_ID }}
PLAYWRIGHT_BQ_PRIVATE_KEY_ID: ${{ secrets.TEST_BQ_PRIVATE_KEY_ID }}
PLAYWRIGHT_BQ_PROJECT_ID_TAXONOMY: ${{ secrets.TEST_BQ_PROJECT_ID_TAXONOMY }}
PLAYWRIGHT_BQ_CLIENT_EMAIL: ${{ secrets.TEST_BQ_CLIENT_EMAIL }}
PLAYWRIGHT_BQ_CLIENT_ID: ${{ secrets.TEST_BQ_CLIENT_ID }}
PLAYWRIGHT_REDSHIFT_HOST: ${{ secrets.E2E_REDSHIFT_HOST_PORT }}
PLAYWRIGHT_REDSHIFT_USERNAME: ${{ secrets.E2E_REDSHIFT_USERNAME }}
PLAYWRIGHT_REDSHIFT_PASSWORD: ${{ secrets.E2E_REDSHIFT_PASSWORD }}
PLAYWRIGHT_REDSHIFT_DATABASE: ${{ secrets.TEST_REDSHIFT_DATABASE }}
PLAYWRIGHT_METABASE_USERNAME: ${{ secrets.TEST_METABASE_USERNAME }}
PLAYWRIGHT_METABASE_PASSWORD: ${{ secrets.TEST_METABASE_PASSWORD }}
PLAYWRIGHT_METABASE_DB_SERVICE_NAME: ${{ secrets.TEST_METABASE_DB_SERVICE_NAME }}
PLAYWRIGHT_METABASE_HOST_PORT: ${{ secrets.TEST_METABASE_HOST_PORT }}
PLAYWRIGHT_SUPERSET_USERNAME: ${{ secrets.TEST_SUPERSET_USERNAME }}
PLAYWRIGHT_SUPERSET_PASSWORD: ${{ secrets.TEST_SUPERSET_PASSWORD }}
PLAYWRIGHT_SUPERSET_HOST_PORT: ${{ secrets.TEST_SUPERSET_HOST_PORT }}
PLAYWRIGHT_KAFKA_BOOTSTRAP_SERVERS: ${{ secrets.TEST_KAFKA_BOOTSTRAP_SERVERS }}
PLAYWRIGHT_KAFKA_SCHEMA_REGISTRY_URL: ${{ secrets.TEST_KAFKA_SCHEMA_REGISTRY_URL }}
PLAYWRIGHT_GLUE_ACCESS_KEY: ${{ secrets.TEST_GLUE_ACCESS_KEY }}
PLAYWRIGHT_GLUE_SECRET_KEY: ${{ secrets.TEST_GLUE_SECRET_KEY }}
PLAYWRIGHT_GLUE_AWS_REGION: ${{ secrets.TEST_GLUE_AWS_REGION }}
PLAYWRIGHT_GLUE_ENDPOINT: ${{ secrets.TEST_GLUE_ENDPOINT }}
PLAYWRIGHT_GLUE_STORAGE_SERVICE: ${{ secrets.TEST_GLUE_STORAGE_SERVICE }}
PLAYWRIGHT_MYSQL_USERNAME: ${{ secrets.TEST_MYSQL_USERNAME }}
PLAYWRIGHT_MYSQL_PASSWORD: ${{ secrets.TEST_MYSQL_PASSWORD }}
PLAYWRIGHT_MYSQL_HOST_PORT: ${{ secrets.TEST_MYSQL_HOST_PORT }}
PLAYWRIGHT_MYSQL_DATABASE_SCHEMA: ${{ secrets.TEST_MYSQL_DATABASE_SCHEMA }}
PLAYWRIGHT_POSTGRES_USERNAME: ${{ secrets.TEST_POSTGRES_USERNAME }}
PLAYWRIGHT_POSTGRES_PASSWORD: ${{ secrets.TEST_POSTGRES_PASSWORD }}
PLAYWRIGHT_POSTGRES_HOST_PORT: ${{ secrets.TEST_POSTGRES_HOST_PORT }}
PLAYWRIGHT_POSTGRES_DATABASE: ${{ secrets.TEST_POSTGRES_DATABASE }}
PLAYWRIGHT_AIRFLOW_HOST_PORT: ${{ secrets.TEST_AIRFLOW_HOST_PORT }}
PLAYWRIGHT_ML_MODEL_TRACKING_URI: ${{ secrets.TEST_ML_MODEL_TRACKING_URI }}
PLAYWRIGHT_ML_MODEL_REGISTRY_URI: ${{ secrets.TEST_ML_MODEL_REGISTRY_URI }}
PLAYWRIGHT_S3_STORAGE_ACCESS_KEY_ID: ${{ secrets.TEST_S3_STORAGE_ACCESS_KEY_ID }}
PLAYWRIGHT_S3_STORAGE_SECRET_ACCESS_KEY: ${{ secrets.TEST_S3_STORAGE_SECRET_ACCESS_KEY }}
PLAYWRIGHT_S3_STORAGE_END_POINT_URL: ${{ secrets.TEST_S3_STORAGE_END_POINT_URL }}
# Recommended: pass the GitHub token lets this action correctly
# determine the unique run id necessary to re-run the checks
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report-${{ matrix.shardIndex }}
path: openmetadata-ui/src/main/resources/ui/playwright/output/playwright-report
retention-days: 5
- name: Upload test results (screenshots, traces)
uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-test-results-${{ matrix.shardIndex }}
path: openmetadata-ui/src/main/resources/ui/playwright/output/test-results
retention-days: 5
- name: Upload results JSON for summary
uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-results-json-${{ matrix.shardIndex }}
path: openmetadata-ui/src/main/resources/ui/playwright/output/results.json
retention-days: 1
if-no-files-found: ignore
- name: Clean Up
run: |
cd ./docker/development
docker compose down --remove-orphans
sudo rm -rf ${PWD}/docker-volume
playwright-summary:
if: always()
needs: playwright-ci-postgresql
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write # Posts the consolidated PR comment
steps:
- name: Download all results JSON
if: needs.playwright-ci-postgresql.result != 'skipped'
uses: actions/download-artifact@v4
with:
pattern: playwright-results-json-*
path: results
- name: Post consolidated PR comment and gate on results
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const upstreamResult = '${{ needs.playwright-ci-postgresql.result }}';
if (upstreamResult === 'skipped') {
// Distinguish the two reasons build/playwright can be skipped:
// 1. A non-"safe to test" label was added โ build skips intentionally for fork security.
// Tests will run once "safe to test" is applied or code is pushed.
// 2. No relevant paths changed โ nothing to test.
const eventAction = context.payload.action;
const labelName = context.payload.label?.name ?? '';
if (eventAction === 'labeled' && labelName !== 'safe to test') {
console.log(`Playwright E2E tests pending โ label "${labelName}" added but only "safe to test" triggers E2E. Tests will run when "safe to test" is applied or code is pushed.`);
} else {
console.log('Playwright E2E tests not required for this PR (no relevant paths changed).');
}
return;
}
if (upstreamResult === 'cancelled') {
core.setFailed('Playwright E2E tests were cancelled.');
return;
}
const fs = require('fs');
const path = require('path');
const runId = '${{ github.run_id }}';
const repo = context.repo;
const prNumber = context.payload.pull_request?.number;
const artifactUrl = `https://github.com/${repo.owner}/${repo.repo}/actions/runs/${runId}`;
const commentMarker = '';
// Collect results from all shards
const shardResults = [];
const resultsDir = 'results';
if (fs.existsSync(resultsDir)) {
for (const dir of fs.readdirSync(resultsDir).sort()) {
const jsonPath = path.join(resultsDir, dir, 'results.json');
if (!fs.existsSync(jsonPath)) continue;
const shardNum = dir.replace('playwright-results-json-', '');
const report = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
const allTests = [];
function collectTests(suite, filePath) {
const file = suite.file || filePath || '';
for (const spec of (suite.specs || [])) {
for (const test of (spec.tests || [])) {
const results = test.results || [];
const lastResult = results[results.length - 1] || {};
const firstResult = results[0] || {};
allTests.push({
title: spec.title,
file: file,
status: test.status,
retries: results.length - 1,
error: lastResult.error?.message || firstResult.error?.message || '',
});
}
}
for (const child of (suite.suites || [])) {
collectTests(child, file);
}
}
for (const suite of (report.suites || [])) {
collectTests(suite, '');
}
shardResults.push({
shard: shardNum,
genuine: allTests.filter(t => t.status === 'unexpected'),
flaky: allTests.filter(t => t.status === 'flaky'),
passed: allTests.filter(t => t.status === 'expected'),
skipped: allTests.filter(t => t.status === 'skipped'),
});
}
}
if (shardResults.length === 0) {
core.setFailed('No Playwright results found โ shards may have been cancelled or failed to upload artifacts.');
return;
}
// Aggregate totals
const totalPassed = shardResults.reduce((s, r) => s + r.passed.length, 0);
const totalFailed = shardResults.reduce((s, r) => s + r.genuine.length, 0);
const totalFlaky = shardResults.reduce((s, r) => s + r.flaky.length, 0);
const totalSkipped = shardResults.reduce((s, r) => s + r.skipped.length, 0);
const lines = [commentMarker];
if (totalFailed > 0) {
lines.push(`## ๐ด Playwright Results โ ${totalFailed} failure(s)${totalFlaky > 0 ? `, ${totalFlaky} flaky` : ''}`);
} else if (totalFlaky > 0) {
lines.push(`## ๐ก Playwright Results โ all passed (${totalFlaky} flaky)`);
} else {
lines.push(`## โ
Playwright Results โ all ${totalPassed} tests passed`);
}
lines.push('');
lines.push(`โ
${totalPassed} passed ยท โ ${totalFailed} failed ยท ๐ก ${totalFlaky} flaky ยท โญ๏ธ ${totalSkipped} skipped`);
lines.push('');
// Per-shard summary table
lines.push('| Shard | Passed | Failed | Flaky | Skipped |');
lines.push('|-------|--------|--------|-------|---------|');
for (const r of shardResults) {
const status = r.genuine.length > 0 ? '๐ด' : r.flaky.length > 0 ? '๐ก' : 'โ
';
lines.push(`| ${status} Shard ${r.shard} | ${r.passed.length} | ${r.genuine.length} | ${r.flaky.length} | ${r.skipped.length} |`);
}
lines.push('');
// Genuine failures detail
const allGenuine = shardResults.flatMap(r => r.genuine.map(t => ({ ...t, shard: r.shard })));
if (allGenuine.length > 0) {
lines.push('### Genuine Failures (failed on all attempts)');
lines.push('');
for (const t of allGenuine.slice(0, 30)) {
const shortFile = t.file.replace(/.*playwright\/e2e\//, '');
lines.push(`โ ${shortFile} โบ ${t.title} (shard ${t.shard})
`);
lines.push('');
lines.push('```');
lines.push(t.error.substring(0, 1000));
lines.push('```');
lines.push(' ');
lines.push('');
}
if (allGenuine.length > 30) {
lines.push(`... and ${allGenuine.length - 30} more failures`);
lines.push('');
}
}
// Flaky tests
const allFlaky = shardResults.flatMap(r => r.flaky.map(t => ({ ...t, shard: r.shard })));
if (allFlaky.length > 0) {
lines.push(`๐ก ${allFlaky.length} flaky test(s) (passed on retry)
`);
lines.push('');
for (const t of allFlaky.slice(0, 30)) {
const shortFile = t.file.replace(/.*playwright\/e2e\//, '');
lines.push(`- \`${shortFile}\` โบ ${t.title} (shard ${t.shard}, ${t.retries} ${t.retries === 1 ? 'retry' : 'retries'})`);
}
if (allFlaky.length > 30) {
lines.push(`- ... and ${allFlaky.length - 30} more`);
}
lines.push('');
lines.push(' ');
lines.push('');
}
lines.push(`๐ฆ [Download artifacts](${artifactUrl})`);
lines.push('');
lines.push('How to debug locally
');
lines.push('');
lines.push('```bash');
lines.push('# Download playwright-test-results- artifact and unzip');
lines.push('npx playwright show-trace path/to/trace.zip # view trace');
lines.push('```');
lines.push(' ');
const body = lines.join('\n');
// Post PR comment only for pull_request_target events
if (prNumber) {
let existingComment = null;
for await (const response of github.paginate.iterator(
github.rest.issues.listComments, { ...repo, issue_number: prNumber, per_page: 100 }
)) {
const found = response.data.find(c =>
c.user?.login === 'github-actions[bot]' && c.body?.includes(commentMarker)
);
if (found) { existingComment = found; break; }
}
// Also clean up any old per-shard comments from previous runs
for await (const response of github.paginate.iterator(
github.rest.issues.listComments, { ...repo, issue_number: prNumber, per_page: 100 }
)) {
for (const c of response.data) {
if (c.user?.login === 'github-actions[bot]' && /Playwright Shard \d/.test(c.body || '') && !c.body?.includes(commentMarker)) {
await github.rest.issues.deleteComment({ ...repo, comment_id: c.id });
console.log(`Deleted old per-shard comment ${c.id}`);
}
}
}
if (existingComment) {
await github.rest.issues.updateComment({ ...repo, comment_id: existingComment.id, body });
} else {
await github.rest.issues.createComment({ ...repo, issue_number: prNumber, body });
}
}
// Gate: fail the step (and therefore the job) when genuine test failures exist.
// This is the single source of truth โ the same parsed results that drive the
// PR comment also determine whether playwright-summary passes or fails.
if (totalFailed > 0) {
core.setFailed(`${totalFailed} Playwright test(s) failed.`);
}