# 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.`); }