name: E2E Staging Canvas (Playwright)

# Playwright test suite that provisions a fresh staging org per run and
# verifies every workspace-panel tab renders without crashing. Complements
# e2e-staging-saas.yml (which tests the API shape) by exercising the
# actual browser + canvas bundle against live staging.
#
# Triggers: push to main/staging or PR touching canvas sources + this workflow,
# manual dispatch, and weekly cron to catch browser/runtime drift even
# when canvas is quiet.
# Added staging to push/pull_request branches so the auto-promote gate
# check (--event push --branch staging) can see a completed run for this
# workflow — mirrors what PR #1891 does for e2e-api.yml.

on:
  # Trigger model (revised 2026-04-29):
  #
  # Always fires on push/pull_request; real work is gated per-step on
  # `needs.detect-changes.outputs.canvas`. When canvas/ paths haven't
  # changed, the no-op step alone runs and emits SUCCESS for the
  # `Canvas tabs E2E` check, satisfying branch protection without
  # spending CI cycles. See e2e-api.yml for the rationale on why this
  # is a single job rather than two-jobs-sharing-name.
  push:
    branches: [main, staging]
  pull_request:
    branches: [main, staging]
  workflow_dispatch:
  schedule:
    # Weekly on Sunday 08:00 UTC — catches Chrome / Playwright / Next.js
    # release-note-shaped regressions that don't ride in with a PR.
    - cron: '0 8 * * 0'

concurrency:
  # Per-SHA grouping (changed 2026-04-28 from a single global group). The
  # global group made auto-promote-staging brittle: when a staging push
  # queued behind an in-flight run and a third entrant (a PR run, a
  # follow-on push) entered the group, the staging push got cancelled —
  # leaving auto-promote-staging looking at `completed/cancelled` for a
  # required gate and refusing to advance main. Observed 2026-04-28
  # 23:51-23:53 on staging tip 3f99fede.
  #
  # The original intent of the global group was to throttle parallel
  # E2E provisions (each spins a fresh EC2). At our scale that throttle
  # isn't worth the correctness cost — fresh-org-per-run isolates the
  # state, and the cost of two parallel runs (~$0.001/min × 10min × 2)
  # is rounding error vs. the cost of a stuck pipeline.
  #
  # Per-SHA still dedupes accidental double-triggers for the SAME SHA.
  # It does NOT cancel obsolete-PR-version runs on force-push; that
  # wasted CI is acceptable given the alternative is losing staging-tip
  # data that auto-promote-staging needs.
  group: e2e-staging-canvas-${{ github.event.pull_request.head.sha || github.sha }}
  cancel-in-progress: false

jobs:
  detect-changes:
    runs-on: ubuntu-latest
    outputs:
      canvas: ${{ steps.decide.outputs.canvas }}
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
      - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
        id: filter
        with:
          filters: |
            canvas:
              - 'canvas/**'
              - '.github/workflows/e2e-staging-canvas.yml'
      - id: decide
        # Always run real tests for manual dispatch and the weekly cron —
        # both exist precisely to exercise the suite, regardless of diff.
        run: |
          if [ "${{ github.event_name }}" = "workflow_dispatch" ] || [ "${{ github.event_name }}" = "schedule" ]; then
            echo "canvas=true" >> "$GITHUB_OUTPUT"
          else
            echo "canvas=${{ steps.filter.outputs.canvas }}" >> "$GITHUB_OUTPUT"
          fi

  # ONE job (no job-level `if:`) that always runs and reports under the
  # required-check name `Canvas tabs E2E`. Real work is gated per-step on
  # `needs.detect-changes.outputs.canvas`. See e2e-api.yml for the full
  # rationale — same path-filter check-name parity issue blocked PR #2264
  # (staging→main) on 2026-04-29 because branch protection treats matching-
  # name check runs as a SET, and any SKIPPED member fails the eval.
  playwright:
    needs: detect-changes
    name: Canvas tabs E2E
    runs-on: ubuntu-latest
    timeout-minutes: 40

    env:
      CANVAS_E2E_STAGING: '1'
      MOLECULE_CP_URL: https://staging-api.moleculesai.app
      MOLECULE_ADMIN_TOKEN: ${{ secrets.MOLECULE_STAGING_ADMIN_TOKEN }}

    defaults:
      run:
        working-directory: canvas

    steps:
      - name: No-op pass (paths filter excluded this commit)
        if: needs.detect-changes.outputs.canvas != 'true'
        working-directory: .
        run: |
          echo "No canvas / workflow changes — E2E Staging Canvas gate satisfied without running tests."
          echo "::notice::E2E Staging Canvas no-op pass (paths filter excluded this commit)."

      - if: needs.detect-changes.outputs.canvas == 'true'
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Verify admin token present
        if: needs.detect-changes.outputs.canvas == 'true'
        run: |
          if [ -z "$MOLECULE_ADMIN_TOKEN" ]; then
            echo "::error::Missing MOLECULE_STAGING_ADMIN_TOKEN"
            exit 2
          fi

      - name: Set up Node
        if: needs.detect-changes.outputs.canvas == 'true'
        uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
        with:
          node-version: '20'
          cache: 'npm'
          cache-dependency-path: canvas/package-lock.json

      - name: Install canvas deps
        if: needs.detect-changes.outputs.canvas == 'true'
        run: npm ci

      - name: Install Playwright browsers
        if: needs.detect-changes.outputs.canvas == 'true'
        run: npx playwright install --with-deps chromium

      - name: Run staging canvas E2E
        if: needs.detect-changes.outputs.canvas == 'true'
        run: npx playwright test --config=playwright.staging.config.ts

      - name: Upload Playwright report on failure
        if: failure() && needs.detect-changes.outputs.canvas == 'true'
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: playwright-report-staging
          path: canvas/playwright-report-staging/
          retention-days: 14

      - name: Upload screenshots on failure
        if: failure() && needs.detect-changes.outputs.canvas == 'true'
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: playwright-screenshots
          path: canvas/test-results/
          retention-days: 14

      # Safety-net teardown — fires only when Playwright's globalTeardown
      # didn't (worker crash, runner cancel). Reads the slug from
      # canvas/.playwright-staging-state.json (written by staging-setup
      # as its first action, before any CP call) and deletes only that
      # slug.
      #
      # Earlier versions of this step pattern-swept `e2e-canvas-<today>-*`
      # orgs to compensate for setup-crash-before-state-file-write. That
      # over-aggressive cleanup raced concurrent canvas-E2E runs and
      # poisoned each other's tenants — observed 2026-04-30 when three
      # real-test runs killed each other mid-test, surfacing as
      # `getaddrinfo ENOTFOUND` once CP had cleaned up the just-deleted
      # DNS record. Pattern-sweep removed; setup now writes the state
      # file before any CP work, so the slug is always recoverable.
      - name: Teardown safety net
        if: always() && needs.detect-changes.outputs.canvas == 'true'
        env:
          ADMIN_TOKEN: ${{ secrets.MOLECULE_STAGING_ADMIN_TOKEN }}
        run: |
          set +e
          STATE_FILE=".playwright-staging-state.json"
          if [ ! -f "$STATE_FILE" ]; then
            echo "::notice::No state file at canvas/$STATE_FILE — Playwright globalTeardown handled it (or setup never ran)."
            exit 0
          fi
          slug=$(python3 -c "import json; print(json.load(open('$STATE_FILE')).get('slug',''))")
          if [ -z "$slug" ]; then
            echo "::warning::State file present but slug missing; nothing to clean up."
            exit 0
          fi
          echo "Deleting orphan tenant: $slug"
          # Verify HTTP 2xx instead of `>/dev/null || true` swallowing
          # failures. A 5xx or timeout previously looked identical to
          # success, leaving the tenant alive for up to ~45 min until
          # sweep-stale-e2e-orgs caught it. Surface failures as
          # workflow warnings naming the slug. Don't `exit 1` — a single
          # cleanup miss shouldn't fail-flag the canvas test when the
          # actual smoke check passed; the sweeper is the safety net.
          # See molecule-controlplane#420.
          # Tempfile-routed -w + set +e/-e prevents curl-exit-code
          # pollution of the captured status (lint-curl-status-capture.yml).
          set +e
          curl -sS -o /tmp/canvas-cleanup.out -w "%{http_code}" \
            -X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
            -H "Authorization: Bearer $ADMIN_TOKEN" \
            -H "Content-Type: application/json" \
            -d "{\"confirm\":\"$slug\"}" >/tmp/canvas-cleanup.code
          set -e
          code=$(cat /tmp/canvas-cleanup.code 2>/dev/null || echo "000")
          if [ "$code" = "200" ] || [ "$code" = "204" ]; then
            echo "[teardown] deleted $slug (HTTP $code)"
          else
            echo "::warning::canvas teardown for $slug returned HTTP $code — sweep-stale-e2e-orgs will catch it within ~45 min. Body: $(head -c 300 /tmp/canvas-cleanup.out 2>/dev/null)"
          fi
          exit 0
