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 (changed 2026-04-28 — see auto-promote gap below):
  #
  # Always fires on push/pull_request; only does real work when canvas/
  # or this workflow file changed. The detect-changes job uses
  # dorny/paths-filter to decide; the playwright job runs only if
  # changes match. Otherwise no-op emits success so the workflow always
  # produces a `completed/success` run record.
  #
  # Why: auto-promote-staging.yml's gate-check (line 99) treats
  # "workflow didn't run" as failure, which dead-locked platform-only
  # pushes to staging. Dropping the trigger path filter and gating real
  # work internally guarantees a result the auto-promote chain can
  # read. Same pattern applied to e2e-api.yml in the same PR.
  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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
      - 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

  # Same `name:` as the playwright job below so the check-run is
  # indistinguishable from the real one for branch protection. Mirrors
  # the e2e-api.yml fix in the same PR — see that file for the
  # 2026-04-28 incident reference.
  no-op:
    needs: detect-changes
    if: needs.detect-changes.outputs.canvas != 'true'
    name: Canvas tabs E2E
    runs-on: ubuntu-latest
    steps:
      - 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)."

  playwright:
    needs: detect-changes
    if: needs.detect-changes.outputs.canvas == 'true'
    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:
      - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

      - name: Verify admin token present
        run: |
          if [ -z "$MOLECULE_ADMIN_TOKEN" ]; then
            echo "::error::Missing MOLECULE_STAGING_ADMIN_TOKEN"
            exit 2
          fi

      - name: Set up Node
        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
        run: npm ci

      - name: Install Playwright browsers
        run: npx playwright install --with-deps chromium

      - name: Run staging canvas E2E
        run: npx playwright test --config=playwright.staging.config.ts

      - name: Upload Playwright report on failure
        if: failure()
        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()
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: playwright-screenshots
          path: canvas/test-results/
          retention-days: 14

      # Safety-net teardown mirrors the bash-harness workflow — if
      # globalTeardown didn't run (worker crash, runner cancel), this
      # step sweeps any e2e-canvas-* org tagged with today's date.
      - name: Teardown safety net
        if: always()
        env:
          ADMIN_TOKEN: ${{ secrets.MOLECULE_STAGING_ADMIN_TOKEN }}
        run: |
          set +e
          # Midnight-UTC rollover guard: a single-date filter misses
          # orgs created on the prior UTC day when the run crosses
          # midnight (incident 2026-04-26 23:46Z → 2026-04-27 00:12Z:
          # slug `e2e-canvas-20260426-1u8nz3` survived because the
          # safety-net step ran on the 27th, computed `today=20260427`,
          # and the filter `e2e-canvas-20260427-` never matched). Sweep
          # both today AND yesterday's dates so a cross-midnight run
          # still cleans up its own slug.
          orgs=$(curl -sS "$MOLECULE_CP_URL/cp/admin/orgs" \
            -H "Authorization: Bearer $ADMIN_TOKEN" 2>/dev/null \
            | python3 -c "
          import json, sys, datetime
          d = json.load(sys.stdin)
          today = datetime.date.today()
          yesterday = today - datetime.timedelta(days=1)
          prefixes = (
              f'e2e-canvas-{today.strftime(\"%Y%m%d\")}-',
              f'e2e-canvas-{yesterday.strftime(\"%Y%m%d\")}-',
          )
          candidates = [o['slug'] for o in d.get('orgs', [])
                        if any(o.get('slug','').startswith(p) for p in prefixes)
                        and o.get('status') not in ('purged',)]
          print('\n'.join(candidates))
          " 2>/dev/null)
          for slug in $orgs; do
            curl -sS -X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
              -H "Authorization: Bearer $ADMIN_TOKEN" \
              -H "Content-Type: application/json" \
              -d "{\"confirm\":\"$slug\"}" >/dev/null || true
          done
          exit 0
