name: Playwright UI Tests on: # No `push: main` trigger: the merge_group (merge-queue) run already builds and # tests the exact commit that lands on main, so a post-merge push run would be a # full-suite duplicate. The merge_group run is now the trusted main-ward build and # also warms the shared Rust cache (see save-if on the rust-cache step below). pull_request: branches: - "**" # labeled/unlabeled so adding or removing the 'e2e' label re-fires the run and # re-evaluates the gate in playwright_summary. types: [opened, reopened, synchronize, labeled, unlabeled] merge_group: concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.merge_group.head_ref || github.ref }} cancel-in-progress: true env: COLUMNS: 150 ZO_ROOT_USER_EMAIL: root@example.com ZO_ROOT_USER_PASSWORD: Complexpass#123 ZO_BASE_URL: http://localhost:5080 WS_ZO_BASE_URL: ws://localhost:5080 ZO_BASE_URL_SC: http://localhost:5080 ZO_BASE_URL_SC_UI: http://localhost:5080 INGESTION_URL: http://localhost:5080 ORGNAME: default ZO_WEB_URL: "http://localhost:5080" ZO_QUICK_MODE_NUM_FIELDS: 100 ZO_QUICK_MODE_STRATEGY: first ZO_ALLOW_USER_DEFINED_SCHEMAS: true ZO_INGEST_ALLOWED_UPTO: 5 ZO_FEATURE_QUERY_EXCLUDE_ALL: false ZO_USAGE_BATCH_SIZE: 200 ZO_USAGE_PUBLISH_INTERVAL: 2 ZO_USAGE_REPORTING_ENABLED: true ZO_MIN_AUTO_REFRESH_INTERVAL: 5 ZO_STREAMING_ENABLED: true ZO_COLS_PER_RECORD_LIMIT: "80000" ZO_SMTP_ENABLED: true ZO_FORMAT_STREAM_NAME_TO_LOWERCASE: false ZO_CREATE_ORG_THROUGH_INGESTION: true ZO_UTF8_VIEW_ENABLED: false ZO_ENABLE_CROSS_LINKING: true # Timechart (visualize) tab is hidden by default; enable it so visualize E2E tests can reach it. ZO_TIMECHART_ENABLED: true # Allow alert destinations to target localhost so the test validation stream # (http://localhost:5080/api/.../..._json) is not blocked by the SSRF guard. ZO_SSRF_ALLOW_LOOPBACK: true jobs: check_changes: timeout-minutes: 5 name: check_changes runs-on: ubuntu-latest permissions: contents: read outputs: has_changes: ${{ steps.check.outputs.has_changes }} steps: - uses: actions/checkout@v5 with: fetch-depth: 0 - id: check run: | # Skip when a PR touches only docs, images, or unrelated workflow files (e.g. a # dependabot bump to another workflow) — none can affect the app. Source, web/, and # this workflow (playwright.yml) still run the suite. IGNORE_RE='(\.md$|^docs/|\.(png|jpe?g|gif|svg|webp)$|^\.github/workflows/)' CHANGED=$(git diff --name-only origin/${{ github.base_ref || 'main' }} ${{ github.sha }}) # Files not matching IGNORE_RE. Captured (not `grep -qv`) so the check doesn't depend # on grep -qv exit semantics, which differ across grep builds; the `|| true` keeps # `set -e` from aborting when grep matches nothing (exit 1). The `grep -qE` below is a # loop-safe `if`-condition term (`set -e` doesn't fire there), so it needs no `|| true`. RELEVANT=$(printf '%s' "$CHANGED" | grep -vE "$IGNORE_RE" || true) if [ -z "$CHANGED" ] || [ -n "$RELEVANT" ] || printf '%s' "$CHANGED" | grep -qE '^\.github/workflows/playwright\.yml$'; then echo "has_changes=true" >> $GITHUB_OUTPUT else echo "has_changes=false" >> $GITHUB_OUTPUT fi build_binary: timeout-minutes: 45 name: build_binary needs: [check_changes] # Run when there are e2e-relevant changes AND either this is not a pull_request # (push to main / merge_group always run) or the PR carries the 'e2e' label. if: >- (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'e2e')) || (github.event_name != 'pull_request' && needs.check_changes.outputs.has_changes == 'true') runs-on: labels: ubicloud-standard-16 permissions: contents: read steps: - name: Remove unused tools run: | sudo rm -rf /usr/share/dotnet sudo rm -rf /opt/ghc sudo rm -rf "/usr/local/share/boost" sudo rm -rf "$AGENT_TOOLSDIRECTORY" - name: Clone the current repo uses: actions/checkout@v5 with: fetch-depth: 0 - name: Setup Rust Toolchain uses: dtolnay/rust-toolchain@master with: toolchain: nightly-2026-05-20 targets: x86_64-unknown-linux-gnu - uses: Swatinem/rust-cache@v2 with: cache-on-failure: true shared-key: playwright-release-ci # Save the shared cache on merge_group runs (the main-ward build now that # push:main is gone); keep the main-ref check for any non-PR fallback. save-if: ${{ github.event_name == 'merge_group' || github.ref == 'refs/heads/main' }} - name: Install Protoc run: | bash ./.github/protoc.sh - uses: actions/setup-node@v7 with: node-version: 24 - name: Lint frontend code run: cd web && npm ci && npm run lint:ci - name: Check for undefined CSS custom properties run: cd web && npm run lint:tokens - name: Lint styles (stylelint — bans legacy --o2-* tokens) run: cd web && npm run lint:styles - name: Check token-file purity (token files contain tokens only) run: cd web && npm run lint:token-purity - name: Check design-consistency ratchet (strict — hardcoded/arbitrary/raw-token, no slack) run: cd web && npm run lint:design:strict - name: Build binary and frontend env: NODE_OPTIONS: "--max-old-space-size=8192" GIT_HASH_OVERRIDE: ${{ github.sha }} run: | mkdir -p /tmp/dist bash -c "cd web && npm run build && cp -r ./dist/. /tmp/dist/" & NPM_PID=$! sed -i 's|folder = "../../web/dist/"|folder = "/tmp/dist/"|' src/web/src/lib.rs && grep -q 'folder = "/tmp/dist/"' src/web/src/lib.rs cargo build --profile release-ci --target x86_64-unknown-linux-gnu wait $NPM_PID test -f /tmp/dist/index.html - name: Upload binary artifact uses: actions/upload-artifact@v4 with: name: release-ci-binary path: target/x86_64-unknown-linux-gnu/release-ci/openobserve retention-days: 1 - name: Upload frontend artifact uses: actions/upload-artifact@v4 with: name: frontend-dist path: /tmp/dist retention-days: 1 if-no-files-found: error # Build the shard matrix from tests/ui-testing/ci-matrix/ci_matrix.json (single source of truth, # shared with ENT via build-ci-matrix.js). Runs under the same gate as build_binary so # ui_integration_tests always has a matrix whenever it is allowed to run. generate_matrix: timeout-minutes: 5 name: generate_matrix needs: [check_changes] if: >- (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'e2e')) || (github.event_name != 'pull_request' && needs.check_changes.outputs.has_changes == 'true') runs-on: ubuntu-latest permissions: contents: read outputs: matrix: ${{ steps.gen.outputs.matrix }} steps: - uses: actions/checkout@v5 - id: gen run: | MATRIX=$(node .github/scripts/build-ci-matrix.js tests/ui-testing/ci-matrix/ci_matrix.json) if [ -z "$MATRIX" ]; then echo "::error::matrix generation failed (build-ci-matrix produced no output)"; exit 1; fi echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT" echo "$MATRIX" | node -e "const m=JSON.parse(require('fs').readFileSync(0,'utf8')); console.log('generated',m.include.length,'shards:', m.include.map(s=>s.testfolder).join(', '))" ui_integration_tests: timeout-minutes: 45 name: e2e / ${{ matrix.testfolder }} needs: [build_binary, generate_matrix] if: ${{ !cancelled() && needs.build_binary.result == 'success' }} runs-on: labels: eks-openobserve-standard-8 permissions: contents: read actions: read # container: # image: mcr.microsoft.com/playwright:v1.50.0-jammy # options: --user root strategy: fail-fast: false # Matrix is generated from tests/ui-testing/ci-matrix/ci_matrix.json by the # generate_matrix job (the single source of truth shared with ENT). To add # or move a spec, edit that JSON file — do NOT hand-edit a matrix here. matrix: ${{ fromJSON(needs.generate_matrix.outputs.matrix) }} steps: - name: Kill background apt processes run: | sudo systemctl stop apt-daily.service apt-daily-upgrade.service unattended-upgrades.service 2>/dev/null || true sudo systemctl stop apt-daily.timer apt-daily-upgrade.timer 2>/dev/null || true sudo systemctl disable apt-daily.service apt-daily-upgrade.service unattended-upgrades.service 2>/dev/null || true sudo flock --wait 30 /var/lib/dpkg/lock-frontend true 2>/dev/null || true - name: Clone the current repo uses: actions/checkout@v5 with: fetch-depth: 0 - name: Download binary artifact uses: actions/download-artifact@v5 with: name: release-ci-binary path: release-ci-binary - name: Download frontend artifact uses: actions/download-artifact@v5 with: name: frontend-dist path: /tmp/dist - name: Start OpenObserve # Per-shard override of the workflow-level default. ZO_QUICK_MODE_ENABLED is # read once at server start and is global to the process, so a shard is the # smallest unit that can change it. Set via "quick_mode_enabled" in # tests/ui-testing/ci-matrix/ci_matrix.json; shards that omit it get "false". env: ZO_QUICK_MODE_ENABLED: ${{ matrix.quick_mode_enabled || 'false' }} run: chmod +x ./release-ci-binary/openobserve && ./release-ci-binary/openobserve > o2.log 2>&1 & - name: Wait for start run: | for i in $(seq 1 120); do if curl -sf http://localhost:5080/web/login > /dev/null 2>&1; then echo "Server ready after ${i}s" exit 0 fi sleep 1 done echo "Server failed to start in 120s" exit 1 - name: Ensure we are getting a reply from the server run: curl http://localhost:5080/web/login - uses: actions/setup-node@v7 with: node-version: 24 - name: Cache Playwright browser id: playwright-cache uses: actions/cache@v6 with: path: ~/.cache/ms-playwright key: playwright-chromium-${{ runner.os }}-${{ hashFiles('tests/ui-testing/package-lock.json') }} - name: Install dependencies and run ui-tests run: | touch .env echo "ZO_ROOT_USER_EMAIL=${ZO_ROOT_USER_EMAIL}" >> .env echo "ZO_ROOT_USER_PASSWORD=${ZO_ROOT_USER_PASSWORD}" >> .env echo "ZO_BASE_URL=${ZO_BASE_URL}" >> .env echo "WS_ZO_BASE_URL=${WS_ZO_BASE_URL}" >> .env echo "ZO_BASE_URL_SC=${ZO_BASE_URL_SC}" >> .env echo "ZO_BASE_URL_SC_UI=${ZO_BASE_URL_SC_UI}" >> .env echo "INGESTION_URL=${INGESTION_URL}" >> .env echo "ORGNAME=${ORGNAME}" >> .env echo "ZO_SMTP_ENABLED=${ZO_SMTP_ENABLED}" >> .env mv .env tests/ui-testing cd tests/ui-testing && npm ci # Install apt system dependencies (always needed) npx playwright install-deps chromium # Get revision from playwright's own metadata CHROMIUM_REV=$(node -pe " require('./node_modules/playwright-core/browsers.json') .browsers.find(b => b.name === 'chromium').revision ") install_browser() { local NAME="$1" local BINARY="$2" local ZIP_PATH="$3" local DIR="$HOME/.cache/ms-playwright/${NAME}-${CHROMIUM_REV}" if [ -f "${DIR}/INSTALLATION_COMPLETE" ]; then echo "${NAME} already installed (cache hit)"; return 0 fi rm -rf "${DIR}" echo "Cache miss — downloading ${NAME} r${CHROMIUM_REV} via curl + system unzip" curl -fL --progress-bar \ "https://cdn.playwright.dev/dbazure/download/playwright/builds/${ZIP_PATH}" \ -o "/tmp/${NAME}.zip" mkdir -p "${DIR}" unzip -q "/tmp/${NAME}.zip" -d "${DIR}" rm -f "/tmp/${NAME}.zip" find "${DIR}" -name "${BINARY}" -type f -exec chmod 755 {} \; find "${DIR}" -name 'chrome_sandbox' -type f -exec chmod 4755 {} \; 2>/dev/null || true touch "${DIR}/INSTALLATION_COMPLETE" echo "${NAME} installed: $(ls ${DIR}/)" } install_browser "chromium" "chrome" \ "chromium/${CHROMIUM_REV}/chromium-linux.zip" install_browser "chromium_headless_shell" "headless_shell" \ "chromium/${CHROMIUM_REV}/chromium-headless-shell-linux.zip" # ffmpeg is required for video recording (video: retain-on-failure in playwright.config.js) # The custom install_browser uses CHROMIUM_REV which doesn't match ffmpeg's own revision, # so use the local playwright binary (pinned via package-lock.json) to install ffmpeg only. ./node_modules/.bin/playwright install ffmpeg # Get list of files to run using join function FILE_LIST="${{ join(matrix.run_files, ' ') }}" echo "DEBUG: FILE_LIST = $FILE_LIST" echo "DEBUG: matrix.testfolder = ${{ matrix.testfolder }}" if [ -n "$FILE_LIST" ]; then # actual_folder (the real directory under playwright-tests/) comes straight # from the matrix now — the logical->directory mapping lives in ci-matrix/ci_matrix.json. ACTUAL_FOLDER="${{ matrix.actual_folder }}" echo "DEBUG: ACTUAL_FOLDER = $ACTUAL_FOLDER" # Build file paths FILE_PATHS="" for file in $FILE_LIST; do FILE_PATH="./playwright-tests/$ACTUAL_FOLDER/$file" echo "DEBUG: Will run file: $FILE_PATH" FILE_PATHS="$FILE_PATHS $FILE_PATH" done echo "DEBUG: Final command: npx playwright test $FILE_PATHS" npx playwright test $FILE_PATHS else echo "No files specified to run for ${{ matrix.testfolder }} folder" fi - name: Upload blob report to GitHub Actions Artifacts if: ${{ !cancelled() }} uses: actions/upload-artifact@v4 with: name: blob-report-${{ matrix.testfolder }}-attempt-${{ github.run_attempt }} path: tests/ui-testing/blob-report retention-days: 1 - name: Check OpenObserve logs if: always() run: cat o2.log merge_reports: timeout-minutes: 20 name: Merge Reports & Report to Kinora needs: [ui_integration_tests] if: ${{ !cancelled() && needs.ui_integration_tests.result != 'skipped' }} # eks runner: `playwright merge-reports` silently stops mid-merge (exits 0, ~3 of N blobs, no # output) on the ubicloud and github-hosted runners — but works on the eks runners (same as ENT). # This job also uploads the merged run to the self-hosted Kinora dashboard (see the last two # steps) — folded in here to reuse the merge instead of re-downloading/re-merging in a 2nd job. runs-on: labels: eks-openobserve-standard-4 permissions: contents: read actions: read # the no-blobs investigation reads run/job state from the Actions API pull-requests: write # upsert the single sticky Kinora comment on PRs outputs: # Compact Playwright stats (expected/unexpected/flaky/skipped) from the merged report.json, # consumed by report_to_openobserve to record per-test counts in ci_test_runs. stats: ${{ steps.report_stats.outputs.stats }} # Flaky + failed tests for the ci_test_results stream, consumed by # report_to_openobserve. JSON array: [{module,title,file,status}] where # status is "flaky"|"failed" (see .github/scripts/extract-flaky.js). "[]" if none. flaky: ${{ steps.report_flaky.outputs.flaky }} steps: - name: Clone the current repo uses: actions/checkout@v5 - name: Setup Node.js uses: actions/setup-node@v7 with: node-version: 24 - name: Install project dependencies run: cd tests/ui-testing && npm ci - name: Download blob reports from GitHub Actions Artifacts uses: actions/download-artifact@v4 continue-on-error: true id: download_blobs with: path: tests/ui-testing/all-blob-reports pattern: blob-report-*-attempt-${{ github.run_attempt }} merge-multiple: true - name: Check if any artifacts were downloaded run: | if [ -d "tests/ui-testing/all-blob-reports" ] && [ "$(ls -A tests/ui-testing/all-blob-reports)" ]; then echo "✅ Artifacts downloaded successfully" ls -la tests/ui-testing/all-blob-reports else echo "⚠️ No artifacts found or download failed completely" # Don't fail here - let the Merge Reports step handle this fi - name: Merge Reports env: GH_TOKEN: ${{ github.token }} run: | cd tests/ui-testing # Check if directory exists and contains reports if [ ! -d "all-blob-reports" ] || [ -z "$(ls -A all-blob-reports 2>/dev/null)" ]; then echo "::warning::No blob reports found to merge." # Investigate: Check if all test jobs succeeded (meaning they skipped intentionally) # vs test jobs failed (meaning blob reporter might have failed) echo "::group::Investigating why no blob reports exist" JOBS_RESPONSE=$(curl -s -H "Authorization: token $GH_TOKEN" \ -H "Accept: application/vnd.github.v3+json" \ "https://api.github.com/repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }}/jobs") # Count test matrix jobs by conclusion SUCCEEDED=$(echo "$JOBS_RESPONSE" | jq -r '[.jobs[] | select(.name | startswith("e2e /")) | select(.conclusion == "success")] | length') FAILED=$(echo "$JOBS_RESPONSE" | jq -r '[.jobs[] | select(.name | startswith("e2e /")) | select(.conclusion == "failure")] | length') TOTAL=$(echo "$JOBS_RESPONSE" | jq -r '[.jobs[] | select(.name | startswith("e2e /"))] | length') echo "::notice::Test job results: $SUCCEEDED succeeded, $FAILED failed, $TOTAL total" if [ "$SUCCEEDED" -gt 0 ] && [ "$FAILED" -eq 0 ]; then echo "::notice::✅ All test jobs succeeded - they likely skipped because tests already passed" echo "::notice::This is VALID behavior for optimized reruns" echo "::notice::Creating empty report structure for downstream jobs" mkdir -p playwright-results/html-report # Add stats object so downstream report consumers get a well-formed report. # startTime is an ISO 8601 string (not a Unix timestamp number). START_TIME=$(date -u +%Y-%m-%dT%H:%M:%S.000Z) echo '{"config":{},"suites":[],"errors":[],"stats":{"startTime":"'"$START_TIME"'","duration":0,"expected":0,"unexpected":0,"flaky":0,"skipped":0}}' > playwright-results/report.json echo "

No tests executed - all shards skipped

All test shards exited early because tests had already passed in a previous attempt.

" > playwright-results/html-report/index.html echo "::endgroup::" exit 0 else echo "::error::❌ Some/all test jobs failed but no blob reports were found" echo "::error::This indicates a problem with test execution or blob reporter" echo "::error::Failed jobs: $FAILED, Succeeded jobs: $SUCCEEDED" echo "::endgroup::" exit 1 fi fi # Force the html + json reporters explicitly with their output paths instead of # relying on `unset CI` to flip playwright.config.js's `process.env.CI ? blob : html/json` # ternary. The runner exports CI=true and `unset CI` does not reliably reach the # merge subprocess, so the config would intermittently re-select the `blob` reporter — # which writes a merged blob-report/ and NOTHING to playwright-results/, making the # downstream `ls playwright-results/html-report/` fail with a cryptic exit 2. mkdir -p playwright-results # These env vars override playwright.config.js's reporter settings: # PLAYWRIGHT_HTML_OUTPUT_DIR - html reporter output directory # PLAYWRIGHT_HTML_OPEN=never - never auto-open the report (required for CI) # PLAYWRIGHT_JSON_OUTPUT_NAME - json reporter output file path PLAYWRIGHT_HTML_OUTPUT_DIR=playwright-results/html-report \ PLAYWRIGHT_HTML_OPEN=never \ PLAYWRIGHT_JSON_OUTPUT_NAME=playwright-results/report.json \ npx playwright merge-reports --reporter html,json ./all-blob-reports echo "Contents of playwright-results:" ls -la playwright-results/ || true if [ ! -f playwright-results/html-report/index.html ] || [ ! -f playwright-results/report.json ]; then echo "::error::merge-reports did not produce the expected html-report/index.html and report.json" echo "::error::all-blob-reports contents:" ls -la all-blob-reports/ || true exit 1 fi # Diagnostics only — never fail the step on these (the guard above already # confirmed the outputs exist). echo "Contents of html-report:" ls -la playwright-results/html-report/ || true echo "Checking report.json:" ls -lh playwright-results/report.json || true - name: Extract report stats for OpenObserve id: report_stats if: always() run: | cd tests/ui-testing if [ -f playwright-results/report.json ]; then STATS=$(jq -c '.stats // {}' playwright-results/report.json 2>/dev/null || echo '{}') else STATS='{}' fi echo "stats=$STATS" >> "$GITHUB_OUTPUT" echo "report stats: $STATS" - name: Extract flaky tests for OpenObserve id: report_flaky if: always() run: | # Flaky + failed tests from the merged report, as one JSON array, for the # ci_test_results stream. Logic + schema live in extract-flaky.js (it # handles a missing/malformed report by printing [] non-fatally, and is # commented to explain status="flaky" vs "failed" and the sp.title choice). FLAKY=$(node .github/scripts/extract-flaky.js tests/ui-testing/playwright-results/report.json) echo "flaky=$FLAKY" >> "$GITHUB_OUTPUT" # Log a count only — avoid dumping test file paths/titles into public CI logs. echo "flaky/failed test count: $(printf '%s' "$FLAKY" | jq 'length' 2>/dev/null || echo '?')" - name: Upload merged report if: always() uses: actions/upload-artifact@v4 with: name: merged-report-${{ github.run_attempt }} path: tests/ui-testing/playwright-results retention-days: 1 if-no-files-found: warn # Report to Kinora — reuses the report.json + trace.zips this job just merged (all on this # runner), so there is NO second artifact download or merge. Fully non-fatal. - name: Report to Kinora (upload merged run + traces) id: kinora if: ${{ vars.UPLOAD_TO_KINORA != 'false' }} continue-on-error: true env: KINORA_TOKEN: ${{ secrets.KINORA_TOKEN }} KINORA_URL: ${{ secrets.KINORA_URL }} KINORA_PROJECT: openobserve-e2e run: | cd tests/ui-testing if [ ! -f playwright-results/report.json ]; then echo "::notice::No merged report.json — skipping Kinora upload." exit 0 fi echo "::group::Uploading to Kinora ($KINORA_PROJECT)" # Cap a hung upload at 5 min (SIGTERM, then SIGKILL after 30s) so a stalled # Kinora endpoint fails fast instead of burning the whole 20-min job budget. if OUT=$(timeout -k 30 300 npx --yes @kinora/cli upload playwright-results/report.json \ --project "$KINORA_PROJECT" \ --git-repo-url "https://github.com/${{ github.repository }}" 2>&1); then echo "$OUT" else echo "::warning::Kinora CLI upload failed (exit $?) — continuing (non-fatal)." echo "$OUT"; echo "::endgroup::"; exit 0 fi echo "::endgroup::" # Match the CLI's "(run )" form specifically so stray log text can't match. RUN_ID=$(printf '%s\n' "$OUT" | grep -oiE '\(run [0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\)' | head -1 | grep -oiE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}') if [ -n "$RUN_ID" ]; then RUN_URL="${KINORA_URL%/}/projects/$KINORA_PROJECT/runs/$RUN_ID" echo "run_url=$RUN_URL" >> "$GITHUB_OUTPUT" echo "::notice::Kinora run: $RUN_URL" else echo "::warning::Kinora upload did not return a run id (see log above)." fi - name: Upsert Kinora report comment on PR if: ${{ github.event_name == 'pull_request' && steps.kinora.outputs.run_url != '' }} continue-on-error: true env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} RUN_URL: ${{ steps.kinora.outputs.run_url }} run: | MARKER="" # printf only interprets % in the format string; $RUN_URL is a %s arg, so it is safe. BODY=$(printf '%s\n### 📊 Kinora report\nPlaywright run uploaded to Kinora — **[View run in Kinora](%s)**\n\nUpdated for `%s` · run attempt %s. This comment updates in place on every push (no new comments).' \ "$MARKER" "$RUN_URL" "${HEAD_SHA:0:9}" "${{ github.run_attempt }}") CID=$(gh api "repos/$REPO/issues/$PR_NUMBER/comments" --paginate \ -q ".[] | select(.body | contains(\"$MARKER\")) | .id" | head -1) if [ -n "$CID" ]; then gh api -X PATCH "repos/$REPO/issues/comments/$CID" -f body="$BODY" >/dev/null && echo "Updated existing Kinora comment ($CID)." else gh api -X POST "repos/$REPO/issues/$PR_NUMBER/comments" -f body="$BODY" >/dev/null && echo "Posted new Kinora comment." fi # OpenObserve metrics — its own job so playwright_summary never depends on it. Emits one # retry-aware run-summary doc (ci_test_runs, suite=ui) + per-test flaky/failed rows # (ci_test_results). The step is continue-on-error and the poster scripts never fail. report_openobserve: timeout-minutes: 10 name: Report to OpenObserve needs: [ui_integration_tests, merge_reports] if: ${{ !cancelled() && needs.ui_integration_tests.result != 'skipped' }} runs-on: labels: eks-openobserve-standard-4 permissions: contents: read actions: read # reads run/job timings from the Actions API steps: - name: Clone the current repo uses: actions/checkout@v5 - name: Report run metrics to OpenObserve continue-on-error: true env: GH_TOKEN: ${{ github.token }} O2_REPORTING_INGEST_BASE: ${{ secrets.O2_REPORTING_INGEST_BASE }} O2_REPORTING_AUTH: ${{ secrets.O2_REPORTING_AUTH }} O2_REPORTING_INSECURE: ${{ vars.O2_REPORTING_INSECURE }} TESTS: ${{ needs.ui_integration_tests.result }} MERGE_STATS: ${{ needs.merge_reports.outputs.stats }} MERGE_FLAKY: ${{ needs.merge_reports.outputs.flaky }} # Branch + PR for per-test rows so the dashboard needs no cross-stream JOIN. # head_ref is set on pull_request (the source branch); ref_name covers push + # merge_group (gh-readonly-queue/main/pr--). PR empty on merge_group/push. REPORT_BRANCH: ${{ github.head_ref || github.ref_name }} REPORT_PR: ${{ github.event.pull_request.number }} run: | # This job runs only when tests actually ran (merge `if:` gates on !cancelled && # ui != skipped), so TESTS is success|failure here. if [ "$TESTS" = "failure" ]; then CONCLUSION=failure; else CONCLUSION=success; fi # Per-test counts from the merged report.json (tests_total includes skipped). if [ -n "${MERGE_STATS:-}" ] && [ "$MERGE_STATS" != "null" ] && [ "$MERGE_STATS" != "{}" ]; then EXTRA_JSON=$(printf '%s' "$MERGE_STATS" | jq -c '{tests_total:((.expected//0)+(.unexpected//0)+(.flaky//0)+(.skipped//0)), tests_passed:(.expected//0), tests_failed:(.unexpected//0), tests_flaky:(.flaky//0), tests_skipped:(.skipped//0)}' 2>/dev/null || echo "") [ -n "$EXTRA_JSON" ] && export EXTRA_JSON fi export CONCLUSION STREAM=ci_test_runs SUITE=ui bash .github/scripts/o2-run-report.sh # One row per flaky-or-failed test -> ci_test_results (status flaky|failed; suite/ # workflow hardcoded UI-only). o2-report.sh takes the stream as arg 1. Non-fatal. FLAKY="${MERGE_FLAKY:-[]}" if printf '%s' "$FLAKY" | jq -e 'type=="array" and length>0' >/dev/null 2>&1; then FLAKY_FILE=$(mktemp "${TMPDIR:-/tmp}/flaky.XXXXXX") trap 'rm -f "$FLAKY_FILE"' EXIT printf '%s' "$FLAKY" | jq -c --arg run_id "$GITHUB_RUN_ID" --arg repo "$GITHUB_REPOSITORY" \ --arg branch "${REPORT_BRANCH:-}" --arg pr "${REPORT_PR:-}" \ '[.[] | {run_id:$run_id, repo:$repo, suite:"ui", workflow:"test", ingest_source:"live", status:.status, module:.module, test_title:.title, test_file:.file, branch:(if $branch=="" then null else $branch end), pr_number:(if $pr=="" then null else ($pr|tonumber) end)}]' \ > "$FLAKY_FILE" 2>/dev/null || echo '[]' > "$FLAKY_FILE" if [ "$(jq 'length' "$FLAKY_FILE" 2>/dev/null || echo 0)" -gt 0 ]; then bash .github/scripts/o2-report.sh ci_test_results "$FLAKY_FILE" fi fi playwright_summary: timeout-minutes: 5 runs-on: labels: eks-openobserve-standard-4 permissions: {} needs: [ check_changes, build_binary, ui_integration_tests, ] if: always() steps: - name: Check test results run: | # 1. check_changes must have succeeded — if it failed, downstream jobs skipped # and we must not report a false success. if [ "${{ needs.check_changes.result }}" != "success" ]; then echo "check_changes job did not succeed: ${{ needs.check_changes.result }}" exit 1 fi # 2. On a pull_request WITHOUT the 'e2e' label the suite did not run: block (fail) # when the change is Playwright-relevant so the author opts in, else bypass (pass). # Adding the 'e2e' label always runs the suite (see build_binary's if). if [ "${{ github.event_name }}" == "pull_request" ] && \ [ "${{ contains(github.event.pull_request.labels.*.name, 'e2e') }}" != "true" ]; then if [ "${{ needs.check_changes.outputs.has_changes }}" == "true" ]; then echo "::error::This PR changes Playwright-relevant files but has no 'e2e' label, so the suite did not run." echo "::error::Add the 'e2e' label to the PR to run the full Playwright suite before merging." exit 1 fi echo "✅ No Playwright-relevant changes and no 'e2e' label — suite not required for this PR." exit 0 fi # 3. Non-PR event (push / merge queue) with nothing relevant → nothing ran → allow. if [ "${{ github.event_name }}" != "pull_request" ] && \ [ "${{ needs.check_changes.outputs.has_changes }}" != "true" ]; then echo "✅ No Playwright-relevant changes — suite not required." exit 0 fi # 4. Suite was expected to run — pass only if build + tests actually succeeded. if [ "${{ needs.build_binary.result }}" == "success" ] && \ [ "${{ needs.ui_integration_tests.result }}" == "success" ]; then echo "All Playwright tests completed successfully" exit 0 else echo "Playwright tests failed:" echo " build_binary: ${{ needs.build_binary.result }}" echo " ui_integration_tests: ${{ needs.ui_integration_tests.result }}" exit 1 fi