name: Core E2E Tests on: pull_request: branches: - main - pulse/v6-release - 'release/v*' paths: - 'frontend-modern/**' - 'internal/**' - 'tests/integration/**' - 'Dockerfile' - '.github/workflows/test-e2e.yml' - 'scripts/playwright-apt.conf' - 'scripts/tests/test_playwright_acquisition.py' - 'scripts/e2e_test_images.py' - 'scripts/tests/test_e2e_test_images.py' - 'scripts/collect_e2e_container_logs.py' - 'scripts/tests/test_e2e_container_logs.py' - 'scripts/ci_browser_proof_only.py' - 'scripts/tests/test_ci_browser_proof_only.py' push: branches: - main - master - pulse/v6-release - 'release/v*' paths: - 'frontend-modern/**' - 'internal/**' - 'tests/integration/**' - 'Dockerfile' - '.github/workflows/test-e2e.yml' - 'scripts/playwright-apt.conf' - 'scripts/tests/test_playwright_acquisition.py' - 'scripts/e2e_test_images.py' - 'scripts/tests/test_e2e_test_images.py' - 'scripts/collect_e2e_container_logs.py' - 'scripts/tests/test_e2e_container_logs.py' - 'scripts/ci_browser_proof_only.py' - 'scripts/tests/test_ci_browser_proof_only.py' workflow_dispatch: # Let branch runs finish so busy main and release branches retain verdicts. # A newer PR commit supersedes its previous validation, so do not queue the # replacement behind obsolete checks. Keep this aligned with Build and Test. concurrency: group: e2e-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: contents: read jobs: scope: name: Detect browser receipt-only changes runs-on: ubuntu-24.04 timeout-minutes: 5 outputs: proof_only: ${{ steps.diff.outputs.proof_only }} steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false fetch-depth: 0 - name: Check shipped docs before dependency work run: python3 scripts/check_docs_mirror.py - name: Validate browser receipt-only CI admission run: python3 scripts/tests/test_ci_browser_proof_only.py - name: Read the complete tree diff id: diff env: DIFF_BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} DIFF_HEAD: ${{ github.sha }} run: | set -euo pipefail proof_only=$(python3 scripts/ci_browser_proof_only.py --base "$DIFF_BASE" --head "$DIFF_HEAD") echo "proof_only=$proof_only" >> "$GITHUB_OUTPUT" tier-selection: name: Validate E2E tier selection needs: scope if: needs.scope.outputs.proof_only != 'true' runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false # Fail before installs, Docker builds or the eight-shard fan-out. - name: Check shipped docs before dependency work run: python3 scripts/check_docs_mirror.py - name: Validate shared E2E image admission run: python3 scripts/tests/test_e2e_test_images.py - name: Validate bounded container diagnostic retention run: python3 scripts/tests/test_e2e_container_logs.py - name: Validate browser dependency acquisition policy run: python3 scripts/tests/test_playwright_acquisition.py - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24' cache: 'npm' cache-dependency-path: tests/integration/package-lock.json - name: Validate read-only auth renderer diagnostics working-directory: tests/integration run: node --test scripts/capture-auth-renderer.test.mjs - name: Validate telemetry schema version parity working-directory: tests/integration run: | npm run check:telemetry-schema-version node --test scripts/check-telemetry-schema-version.test.mjs - name: Install integration dependencies working-directory: tests/integration run: npm ci - name: Validate stable/probation project accounting working-directory: tests/integration run: npm run check:e2e-tiers - name: Validate stable failure reporting working-directory: tests/integration run: node --test scripts/report-stable-e2e-failures.test.mjs - name: Validate fixture authentication and credential lifetime working-directory: tests/integration run: node scripts/fixture-auth.test.mjs offline-org-provisioning: name: Offline Organization provisioning needs: scope if: needs.scope.outputs.proof_only != 'true' # Fixture acceptance only, not quarantine promotion or private RBAC proof. runs-on: ubuntu-24.04 timeout-minutes: 25 steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false # Fail before installs, Docker builds or the eight-shard fan-out. - name: Check shipped docs before dependency work run: python3 scripts/check_docs_mirror.py - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24' cache: 'npm' cache-dependency-path: | tests/integration/package-lock.json frontend-modern/package-lock.json internal/cloudcp/portal/frontend/package-lock.json - name: Set up Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod # Apply only to this disposable hosted runner, never the maintainer host. # Keep the provider's repositories and apt authentication unchanged. - name: Bound browser OS dependency acquisition run: sudo install -m 0644 scripts/playwright-apt.conf /etc/apt/apt.conf.d/99pulse-playwright-acquire - name: Install locked dependencies run: | npm ci --prefix tests/integration npm ci --prefix frontend-modern npm ci --prefix internal/cloudcp/portal/frontend - name: Install Playwright browser and OS dependencies timeout-minutes: 10 working-directory: tests/integration run: npx playwright install --with-deps chromium - name: Validate offline issuer boundaries working-directory: tests/integration run: >- node --test scripts/offline-license-issuer.test.mjs scripts/with-offline-entitlements.test.mjs scripts/entitlement-bootstrap.test.mjs - name: Prove authenticated default and created-org activation working-directory: tests/integration env: PULSE_E2E_USE_LOCAL_BACKEND: 'true' PULSE_E2E_SKIP_PLAYWRIGHT_INSTALL: 'true' run: >- node scripts/with-offline-entitlements.mjs node scripts/run-playwright.mjs --config=playwright.multi-tenant-diagnostic.config.ts --grep 'Scenario 1:|create, update, member manage' --workers=1 --retries=0 - name: Upload offline provisioning failure report if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: offline-org-provisioning-report path: tests/integration/playwright-report/multi-tenant-diagnostic/ retention-days: 3 test-images: name: Build source-bound E2E images once needs: tier-selection runs-on: ubuntu-24.04 timeout-minutes: 30 outputs: artifact-id: ${{ steps.images.outputs.artifact-id }} manifest-sha256: ${{ steps.bundle.outputs.manifest-sha256 }} steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false # Same provider, pins and e2e_runtime contract as the former nine builds. # A provider refusal remains terminal: no mirror, login or pull retry. - name: Build and bind the test images id: bundle run: python3 scripts/e2e_test_images.py build "$RUNNER_TEMP/pulse-e2e-images" - name: Upload this run's exact test images id: images uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: e2e-test-images-${{ github.sha }}-${{ github.run_attempt }} path: | ${{ runner.temp }}/pulse-e2e-images/manifest.json ${{ runner.temp }}/pulse-e2e-images/images.tar.gz retention-days: 1 compression-level: 0 if-no-files-found: error e2e: name: Playwright Core E2E (shard ${{ matrix.shard }}/8) needs: [tier-selection, test-images] runs-on: ubuntu-24.04 timeout-minutes: 60 strategy: fail-fast: false matrix: shard: [1, 2, 3, 4, 5, 6, 7, 8] env: PULSE_E2E_SEED_IMAGE: pulse-e2e-seed:test steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24' cache: 'npm' cache-dependency-path: tests/integration/package-lock.json # Apply only to this disposable hosted runner, never the maintainer host. # Keep the provider's repositories and apt authentication unchanged. - name: Bound browser OS dependency acquisition run: sudo install -m 0644 scripts/playwright-apt.conf /etc/apt/apt.conf.d/99pulse-playwright-acquire - name: Install Playwright dependencies # Chromium + WebKit requested 116 MB of authenticated OS packages in # the retained setup failure. Active downloads near 90 kB/s need over # 20 minutes before installation/browser downloads, not ten. Keep the # 60-minute job and every test deadline unchanged; apt's transport # timeouts and incomplete-index refusal still bound stalled requests. timeout-minutes: 30 working-directory: tests/integration run: | npm ci npx playwright install --with-deps chromium webkit - name: Install Pulse Account frontend dependencies working-directory: internal/cloudcp/portal/frontend run: npm ci - name: Download this run's exact test images uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: artifact-ids: ${{ needs.test-images.outputs.artifact-id }} path: ${{ runner.temp }}/pulse-e2e-images merge-multiple: true - name: Admit the source-bound test images env: PULSE_E2E_MANIFEST_SHA256: ${{ needs.test-images.outputs.manifest-sha256 }} run: python3 scripts/e2e_test_images.py load "$RUNNER_TEMP/pulse-e2e-images" - name: Start test containers working-directory: tests/integration env: PULSE_E2E_BOOTSTRAP_TOKEN: 0123456789abcdef0123456789abcdef0123456789abcdef PULSE_E2E_SKIP_PLAYWRIGHT_INSTALL: "true" PULSE_MULTI_TENANT_ENABLED: "true" run: node scripts/pretest.mjs # One shared fixture fault previously consumed 47 minutes of repeated # setup/login failures before probation hit the job limit. Admit the # same authenticated inventory and history once, before either tier. - name: Admit the default mock runtime id: fixture timeout-minutes: 8 working-directory: tests/integration env: PULSE_E2E_BOOTSTRAP_TOKEN: 0123456789abcdef0123456789abcdef0123456789abcdef PULSE_E2E_REQUIRE_DEFAULT_MOCK_READY: "true" PULSE_MULTI_TENANT_ENABLED: "true" run: npx playwright test --config=fixture-readiness.config.ts - name: Upload failed fixture admission if: ${{ !cancelled() && steps.fixture.outcome == 'failure' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: fixture-readiness-shard-${{ matrix.shard }} path: | tests/integration/playwright-report-readiness/ tests/integration/test-results-readiness/ retention-days: 7 # Two-tier run (see PROBATION_SPECS in tests/integration/e2e-tiering.mjs # for the tier mechanism and the promotion/demotion rule). The stable tier # is the gate; the probation tier reuses the same containers but reports # through continue-on-error so a probation flake cannot paint main red. - name: Run stable-tier E2E suite id: stable working-directory: tests/integration env: PULSE_E2E_BOOTSTRAP_TOKEN: 0123456789abcdef0123456789abcdef0123456789abcdef PULSE_E2E_SKIP_DOCKER: "true" PULSE_E2E_SKIP_PLAYWRIGHT_INSTALL: "true" PULSE_E2E_PERF: "1" PULSE_E2E_REQUIRE_DEFAULT_MOCK_READY: "true" PULSE_E2E_TIER: "stable" PULSE_MULTI_TENANT_ENABLED: "true" run: npm test -- --shard=${{ matrix.shard }}/8 # Playwright's report artifacts preserve the full forensic record, but # artifact bodies and job logs require an authenticated GitHub session. # Project the bounded failed testcase identity into ordinary check # annotations so a red stable gate is diagnosable from the API without # rerunning, demoting, or skipping the failure. - name: Report stable-tier failures if: ${{ !cancelled() && steps.stable.outcome == 'failure' }} working-directory: tests/integration run: node scripts/report-stable-e2e-failures.mjs test-results/junit.xml # Preserve the completed stable failure before observational work can # reach the job deadline. Probation must not take its receipts with it. - name: Upload Playwright report if: ${{ !cancelled() && steps.stable.outcome == 'failure' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: playwright-report-shard-${{ matrix.shard }} path: tests/integration/playwright-report/ retention-days: 3 # The HTML report already contains screenshots, videos and traces. # Keep only the separate machine-readable result here. - name: Upload JUnit results if: ${{ !cancelled() && steps.stable.outcome == 'failure' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: junit-results-shard-${{ matrix.shard }} path: tests/integration/test-results/junit.xml retention-days: 3 # Runs even when the stable tier failed (its data still counts toward # promotion), but not when the stable tier was skipped — that means the # environment never came up and every probation spec would fail on it. # Pull requests skip it. It cannot gate a PR, and the promotion rule in # e2e-tiering.mjs counts only main runs, so on a PR it only held each # of the eight shard runners for about eight more minutes. - name: Run probation-tier E2E suite (non-gating) id: probation if: ${{ !cancelled() && github.event_name != 'pull_request' && steps.stable.conclusion != 'skipped' }} continue-on-error: true # A broken probation surface can otherwise consume the entire 60m job # budget through retries and cancel the job before continue-on-error # can make the observational result non-gating. Playwright's own # global timeout preserves its reports; the step timeout is a backstop # for runner/process failures outside Playwright. timeout-minutes: 12 working-directory: tests/integration env: PULSE_E2E_BOOTSTRAP_TOKEN: 0123456789abcdef0123456789abcdef0123456789abcdef PULSE_E2E_SKIP_DOCKER: "true" PULSE_E2E_SKIP_PLAYWRIGHT_INSTALL: "true" PULSE_E2E_PERF: "1" PULSE_E2E_REQUIRE_DEFAULT_MOCK_READY: "true" PULSE_E2E_TIER: "probation" PULSE_E2E_REPORT_DIR: playwright-report-probation PULSE_E2E_RESULTS_DIR: test-results-probation PULSE_MULTI_TENANT_ENABLED: "true" run: >- npm test -- --shard=${{ matrix.shard }}/8 --pass-with-no-tests --max-failures=5 --global-timeout=600000 - name: Report probation-tier outcome if: ${{ !cancelled() && steps.probation.outcome != 'skipped' }} run: | if [ "${{ steps.probation.outcome }}" = "failure" ]; then echo "⚠️ Probation tier FAILED on shard ${{ matrix.shard }} (non-gating; see the playwright-report-probation artifact). A probation spec's 10-consecutive-green promotion count restarts." >> "$GITHUB_STEP_SUMMARY" else echo "Probation tier passed on shard ${{ matrix.shard }} (non-gating)." >> "$GITHUB_STEP_SUMMARY" fi - name: Collect container logs if: always() timeout-minutes: 3 run: python3 scripts/collect_e2e_container_logs.py "$RUNNER_TEMP/pulse-e2e-container-logs" # Preserve all diagnostic bytes, including partial/failed reads, while # the combined Actions log stays small enough to inspect its verdicts. - name: Upload complete container diagnostics if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: e2e-container-logs-shard-${{ matrix.shard }}-${{ github.sha }}-${{ github.run_attempt }} path: ${{ runner.temp }}/pulse-e2e-container-logs/ retention-days: 3 if-no-files-found: error # continue-on-error keeps the job green on a probation failure, so # failure() never fires for these; key off the step outcome instead. - name: Upload probation Playwright report if: ${{ !cancelled() && steps.probation.outcome == 'failure' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: playwright-report-probation-shard-${{ matrix.shard }} path: tests/integration/playwright-report-probation/ retention-days: 3 - name: Upload probation JUnit results if: ${{ !cancelled() && steps.probation.outcome == 'failure' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: junit-results-probation-shard-${{ matrix.shard }} path: tests/integration/test-results-probation/junit.xml retention-days: 3 agent-registration: name: Agent registration lifecycle needs: [tier-selection, test-images] runs-on: ubuntu-24.04 timeout-minutes: 30 env: PULSE_E2E_SEED_IMAGE: pulse-e2e-seed:test steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false # Fail before installs, Docker builds or the eight-shard fan-out. - name: Check shipped docs before dependency work run: python3 scripts/check_docs_mirror.py - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24' package-manager-cache: false # Apply only to this disposable hosted runner, never the maintainer host. # Keep the provider's repositories and apt authentication unchanged. - name: Bound browser OS dependency acquisition run: sudo install -m 0644 scripts/playwright-apt.conf /etc/apt/apt.conf.d/99pulse-playwright-acquire - name: Install Playwright dependencies timeout-minutes: 10 working-directory: tests/integration run: | npm ci npx playwright install --with-deps chromium - name: Install Pulse Account frontend dependencies working-directory: internal/cloudcp/portal/frontend run: npm ci - name: Download this run's exact test images uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: artifact-ids: ${{ needs.test-images.outputs.artifact-id }} path: ${{ runner.temp }}/pulse-e2e-images merge-multiple: true - name: Admit the source-bound test images env: PULSE_E2E_MANIFEST_SHA256: ${{ needs.test-images.outputs.manifest-sha256 }} run: python3 scripts/e2e_test_images.py load "$RUNNER_TEMP/pulse-e2e-images" - name: Start non-mock test container working-directory: tests/integration env: PULSE_BASE_URL: http://localhost:7655 PULSE_E2E_BOOTSTRAP_TOKEN: 0123456789abcdef0123456789abcdef0123456789abcdef PULSE_E2E_SKIP_PLAYWRIGHT_INSTALL: "true" PULSE_MOCK_MODE: "false" PULSE_MULTI_TENANT_ENABLED: "true" run: node scripts/pretest.mjs - name: Run agent registration lifecycle working-directory: tests/integration env: PULSE_BASE_URL: http://localhost:7655 PULSE_E2E_AGENT_JOURNEY: "true" PULSE_E2E_BOOTSTRAP_TOKEN: 0123456789abcdef0123456789abcdef0123456789abcdef PULSE_E2E_SKIP_DOCKER: "true" PULSE_E2E_SKIP_PLAYWRIGHT_INSTALL: "true" PULSE_MOCK_MODE: "false" PULSE_MULTI_TENANT_ENABLED: "true" run: npx playwright test tests/journeys/04-agent-install-registration.spec.ts --project=chromium - name: Collect container logs if: always() timeout-minutes: 3 run: python3 scripts/collect_e2e_container_logs.py "$RUNNER_TEMP/pulse-e2e-container-logs" --registration - name: Upload complete container diagnostics if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: e2e-container-logs-agent-registration-${{ github.sha }}-${{ github.run_attempt }} path: ${{ runner.temp }}/pulse-e2e-container-logs/ retention-days: 3 if-no-files-found: error - name: Upload Playwright report if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: playwright-report-agent-registration path: tests/integration/playwright-report/ retention-days: 3 - name: Upload test videos and screenshots if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: test-failures-agent-registration path: tests/integration/test-results/ retention-days: 3 e2e-verdict: name: E2E verdict runs-on: ubuntu-24.04 timeout-minutes: 5 needs: - scope - tier-selection - test-images - e2e - agent-registration if: always() steps: # The e2e shards fail only on stable-tier failures: the probation tier # runs behind continue-on-error inside each shard, so its failures are # reported (shard summaries + probation artifacts) without reaching # this verdict. Mechanism and promotion rule: PROBATION_SPECS in # tests/integration/e2e-tiering.mjs. - name: Check E2E results env: PROOF_ONLY: ${{ needs.scope.outputs.proof_only }} run: | if [ "${{ needs.scope.result }}" != "success" ]; then echo "E2E change-scope admission did not pass." exit 1 fi if [ "$PROOF_ONLY" = "true" ]; then # A skip is not a test pass. Require the expected all-skipped # topology; never use a receipt to clear a failed runtime job. for result in "${{ needs.tier-selection.result }}" "${{ needs.test-images.result }}" "${{ needs.e2e.result }}" "${{ needs.agent-registration.result }}"; do if [ "$result" != "skipped" ]; then echo "Unexpected runtime result for a browser receipt-only diff: $result" exit 1 fi done echo "Browser receipt-only tree diff; runtime E2E is not applicable. Canonical Governance still validates evidence." exit 0 fi if [ "$PROOF_ONLY" != "false" ]; then echo "E2E change scope is unknown; no runtime success is established." exit 1 fi if [ "${{ needs.tier-selection.result }}" != "success" ]; then echo "E2E tier selection is invalid (result: ${{ needs.tier-selection.result }})" exit 1 fi if [ "${{ needs.test-images.result }}" != "success" ]; then echo "Source-bound E2E test images were not produced (result: ${{ needs.test-images.result }})" exit 1 fi if [ "${{ needs.e2e.result }}" != "success" ]; then echo "Stable-tier E2E shards did not all pass (result: ${{ needs.e2e.result }})" exit 1 fi if [ "${{ needs.agent-registration.result }}" != "success" ]; then echo "Agent registration lifecycle did not pass (result: ${{ needs.agent-registration.result }})" exit 1 fi echo "All stable-tier E2E shards and the agent registration lifecycle passed (probation tier is non-gating)"