Repository navigation
Playwright (frontend-doctor scenarios) #160
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Playwright (frontend-doctor scenarios) | |
| # Heavier tier-3 scenarios: spins up the full local stack (DynamoDB Local | |
| # + MinIO + Redis + axum server + frontend WASM build) and drives each | |
| # scripted scenario through a headless Chromium. Scenarios share the | |
| # provisioned stack, so adding another one is ~30s of additional | |
| # wall-clock rather than another 8-10 min of setup. | |
| # | |
| # Scheduled nightly rather than per-push because the wasm toolchain + | |
| # trunk build + Playwright install pushes total wall-clock to ~8–10 min | |
| # on a cold cache. Run on-demand via the "Run workflow" button when | |
| # touching the document or spreadsheet flows. | |
| # | |
| # Current scenarios: | |
| # - trash-flow — delete → Trash → restore → purge loop. | |
| # - spreadsheet-paste — copy/paste ref translation + no clipboard | |
| # permission prompt. | |
| # - spreadsheet-features — M-S2 editor-UI smoke (toolbar, sheet | |
| # tabs, formula bar, header-click sort, | |
| # freeze rows via context menu). | |
| # - pivot-editor — M-S2 v2 pivot-table sidebar smoke | |
| # (insert via ctx menu, field-list | |
| # checkbox routing, delete clears). | |
| # - comment-live-sync — peer's REST-driven reply lands in another | |
| # tab's open CommentPopup without manual | |
| # reload. | |
| # - pre-sync-edit-preserved / first-keystrokes — #92 input races: | |
| # keystrokes typed during the WS handshake / | |
| # the instant the editor reports ready must | |
| # survive the initial sync, exactly. | |
| on: | |
| schedule: | |
| # 06:00 UTC daily (≈23:00 PT). Matches the "nightly" expectation. | |
| - cron: "0 6 * * *" | |
| workflow_dispatch: | |
| concurrency: | |
| group: playwright-frontend-doctor | |
| cancel-in-progress: true | |
| env: | |
| CARGO_TERM_COLOR: always | |
| RUST_BACKTRACE: 1 | |
| # AWS SDK credential/endpoint shims so the server and setup_dev binary | |
| # talk to DynamoDB Local + MinIO instead of real AWS. | |
| AWS_ACCESS_KEY_ID: minioadmin | |
| AWS_SECRET_ACCESS_KEY: minioadmin | |
| AWS_REGION: us-east-1 | |
| AWS_ENDPOINT_URL_DYNAMODB: http://127.0.0.1:8000 | |
| AWS_ENDPOINT_URL_S3: http://127.0.0.1:9000 | |
| # Server config | |
| DYNAMODB_TABLE_PREFIX: "ci-" | |
| S3_BUCKET: "ci-ogrenotes" | |
| REDIS_URL: "redis://127.0.0.1:6379" | |
| DEV_MODE: "true" | |
| JWT_SECRET: "ci-jwt-secret-that-is-at-least-32-bytes-long" | |
| OAUTH_CLIENT_ID: "ci-not-used" | |
| OAUTH_CLIENT_SECRET: "ci-not-used" | |
| OAUTH_REDIRECT_URI: "http://localhost:3000/auth/callback" | |
| SEARCH_INDEX_PATH: "/tmp/ogrenotes-ci-search" | |
| # Exercise the indexed search path used by deployed API instances. The | |
| # legacy scan can miss users created late in this long scenario suite. | |
| USER_SEARCH_INDEX_READS_ENABLED: "true" | |
| API_PORT: "3000" | |
| # Bump the per-IP rate-limit ceiling well above defaults. In CI | |
| # axum receives connections without X-Forwarded-For, so every | |
| # scenario shares the "unknown" bucket; the production 10/min | |
| # default for auth_refresh trips before the suite finishes. | |
| RATE_LIMIT_AUTH_REFRESH_PER_MIN: "60" | |
| RATE_LIMIT_AUTH_LOGIN_PER_MIN: "60" | |
| # Same shared-"unknown"-bucket reason: a disconnected WS during fast | |
| # typing storms the reconnect trigger on every keydown, so the default | |
| # 30/min WS-upgrade cap trips mid-scenario (the document-details flaky). | |
| RATE_LIMIT_WS_UPGRADE_PER_MIN: "60" | |
| # Phase 6 M-6.2 piece E: passed through to the API task so the | |
| # /api/v1/ask endpoint can stream against the real Claude API. | |
| # `secrets.ANTHROPIC_API_KEY` resolves to empty string on PRs | |
| # from forks (GitHub doesn't expose secrets to fork builds); the | |
| # ask-flow scenario step below skips when the env var is empty. | |
| ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} | |
| jobs: | |
| frontend-doctor: | |
| name: Playwright frontend-doctor | |
| runs-on: ubuntu-latest | |
| # 30 was too tight on a cold-cache day: the release build alone swings from | |
| # ~3 min (warm) to ~16.5 min (cold), and a cold run measured 30.6 min — so | |
| # it tripped the cap and was *cancelled*, which skips the rust-cache save | |
| # post-step, leaving the next run cold too (a self-perpetuating loop). 45 | |
| # gives a cold run room to finish and repopulate the cache. The scenarios | |
| # themselves are ~2.5 min total and are not the bottleneck. | |
| timeout-minutes: 45 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| # ─── System libs ────────────────────────────────────────── | |
| # samael's `xmlsec` feature wraps libxml2 + xmlsec1 for SAML | |
| # signature verification. The Dockerfile got these in commit | |
| # 45e086e; the playwright workflow was missed in that pass, | |
| # so every run since 2026-05-15 (when the feature went live) | |
| # has crashed the `cargo run --bin setup_dev` step before any | |
| # scenario could execute. Mirror the Dockerfile package set: | |
| # -dev headers + libclang for bindgen at compile time; the | |
| # xmlsec OpenSSL runtime is what the runtime binary | |
| # dlopens for the actual crypto backend. | |
| - name: Install xmlsec1 + clang (samael dependencies) | |
| run: bash scripts/ci-install-native-deps.sh librsvg2-bin imagemagick | |
| # ─── Toolchain ──────────────────────────────────────────── | |
| - name: Install Rust (stable + wasm32) | |
| uses: dtolnay/rust-toolchain@stable | |
| with: | |
| targets: wasm32-unknown-unknown | |
| - name: Cache cargo | |
| uses: Swatinem/rust-cache@v2 | |
| with: | |
| env-vars: LIBCLANG_PATH OGRE_NATIVE_ABI | |
| workspaces: | | |
| . -> target | |
| frontend -> target | |
| shared-key: playwright | |
| # Save the cache even when the job fails, so one bad run doesn't | |
| # leave the next one cold. (A *cancelled* run still skips the save — | |
| # the raised timeout above is what keeps us out of cancellation.) | |
| cache-on-failure: true | |
| # trunk auto-downloads wasm-bindgen when the build needs it, so the | |
| # action here gives us a single prebuilt trunk binary and nothing else. | |
| # Pinned: `version: latest` resolved to trunk 0.19.1 on one runner and | |
| # 0.21.14 on another ten minutes apart (2026-09-26). 0.19.1 ignores | |
| # index.html's data-wasm-opt-params, so its wasm-opt 116 rejected the | |
| # bulk-memory ops rustc now emits for wasm32. 0.21.14 matches local dev. | |
| - name: Install trunk | |
| uses: taiki-e/install-action@5488ac5a951645ce95062214902ac4b6bc1a1037 # v2 | |
| with: | |
| tool: trunk@0.21.14 | |
| - name: Setup Node 20 | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: 20 | |
| # ─── Infra ──────────────────────────────────────────────── | |
| - name: Start infra (DynamoDB / MinIO / Redis) | |
| run: docker compose up -d | |
| - name: Wait for services | |
| run: | | |
| for port in 8000 9000 6379; do | |
| echo "Waiting for port $port..." | |
| for i in $(seq 1 30); do | |
| if nc -z 127.0.0.1 "$port"; then | |
| echo " port $port ready" | |
| break | |
| fi | |
| sleep 1 | |
| done | |
| done | |
| - name: Provision DynamoDB table + S3 bucket | |
| run: cargo run --locked --bin setup_dev -p ogrenotes-api | |
| # ─── Frontend build ─────────────────────────────────────── | |
| # main.rs serves static assets from /app/frontend/dist. We symlink | |
| # the workspace's build output there so the single axum process | |
| # serves both the API and the SPA over port 3000 (matching the | |
| # Playwright scenario's same-origin assumption). | |
| - name: Build frontend (trunk) | |
| working-directory: frontend | |
| run: trunk build | |
| - name: Expose frontend dist at /app/frontend/dist | |
| run: | | |
| sudo mkdir -p /app/frontend | |
| sudo ln -sfn "$(pwd)/frontend/dist" /app/frontend/dist | |
| # ─── Start API server ───────────────────────────────────── | |
| # Build and run are split so a slow cold compile doesn't eat into | |
| # the readiness-poll window. `cargo build` blocks until the binary | |
| # exists; `Start axum server` then just execs the already-compiled | |
| # artifact and backgrounds it. | |
| # Strip the `pdf` feature from the playwright build — no scenario | |
| # in doctor.js exercises PDF import/export, and `pdf-extract` + | |
| # `printpdf` together pull in ~70 transitive crates (allsorts, | |
| # azul-*, ouroboros) that pushed the cold cargo build past the | |
| # 30-minute job budget and trapped the workflow in a doom loop | |
| # (cold build → 30m timeout → no cache save → cold build again). | |
| # PDF correctness is verified by `crates/api/tests/test_import_pdf_e2e.rs` | |
| # under the regular CI workflow, which runs with the api crate's | |
| # default feature set (pdf on). | |
| - name: Build axum server (release) | |
| run: | | |
| cargo build --release --locked --bin ogrenotes-api \ | |
| --bin backfill_user_search_index \ | |
| --no-default-features --features "dev-login,xlsx,docx" | |
| - name: Backfill user-search index | |
| run: ./target/release/backfill_user_search_index | |
| - name: Start axum server | |
| run: | | |
| mkdir -p /tmp/ci-logs | |
| ./target/release/ogrenotes-api \ | |
| > /tmp/ci-logs/server.log 2>&1 & | |
| echo $! > /tmp/ci-logs/server.pid | |
| - name: Wait for server | |
| run: | | |
| # 90 iterations × 2s = 3 min. Startup is normally <5s; the | |
| # headroom covers AWS-SDK / Redis / search-index warm-up on a | |
| # slow runner without masking a genuinely broken server. | |
| for i in $(seq 1 90); do | |
| if curl -sf http://127.0.0.1:3000/api/v1/auth/dev-login \ | |
| -H 'content-type: application/json' \ | |
| -d '{"email":"healthcheck@ci"}' >/dev/null; then | |
| echo "server ready" | |
| exit 0 | |
| fi | |
| sleep 2 | |
| done | |
| echo "server failed to start" >&2 | |
| tail -n 200 /tmp/ci-logs/server.log | |
| exit 1 | |
| # ─── Playwright ─────────────────────────────────────────── | |
| - name: Install doctor deps | |
| working-directory: scripts/frontend-doctor | |
| run: npm install | |
| - name: Cache Playwright browsers | |
| uses: actions/cache@v4 | |
| id: playwright-cache | |
| with: | |
| path: ~/.cache/ms-playwright | |
| # `-cf` = chromium+firefox. Bumped when Firefox was added for the | |
| # #90 history-pane guard: the old chromium-only cache would | |
| # otherwise hit and skip the binary-download step, leaving Firefox | |
| # missing (install-deps installs system libs, not the binary). | |
| key: playwright-${{ runner.os }}-1.49.0-cf | |
| # Firefox in addition to Chromium: the #90 history-pane panic is | |
| # Gecko-specific (reproduces in Firefox, clean in Chromium), so the | |
| # regression guard for it must run in Firefox. | |
| - name: Install Chromium + Firefox | |
| if: steps.playwright-cache.outputs.cache-hit != 'true' | |
| working-directory: scripts/frontend-doctor | |
| run: npx playwright install --with-deps chromium firefox | |
| - name: Install browser system deps (cache hit) | |
| if: steps.playwright-cache.outputs.cache-hit == 'true' | |
| working-directory: scripts/frontend-doctor | |
| run: npx playwright install-deps chromium firefox | |
| - name: Doctor failure reporting browser regressions | |
| working-directory: scripts/frontend-doctor | |
| run: npm run test:browser | |
| - name: Collaboration content and native history regressions | |
| working-directory: scripts/frontend-doctor | |
| run: node collab-preservation.cjs http://127.0.0.1:3000 | |
| - name: Internationalization browser regressions | |
| working-directory: scripts/frontend-doctor | |
| env: | |
| DEBUG: pw:browser | |
| run: | | |
| set -o pipefail | |
| cargo run --locked --manifest-path ../../frontend/Cargo.toml --example i18n_fixtures > /tmp/ogre-i18n-fixtures.json | |
| python3 ../with-test-cleanup.py -- node i18n-check.cjs 2>&1 | tee /tmp/ci-logs/i18n-browser.log | |
| - name: Internationalization live persistence and collaboration | |
| working-directory: scripts/frontend-doctor | |
| run: python3 ../with-test-cleanup.py -- node i18n-live-check.cjs http://127.0.0.1:3000 | |
| - name: Run trash-flow scenario | |
| id: trash-flow | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario trash-flow \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/trash-flow | |
| # `if: always()` so a trash-flow failure doesn't hide a separate | |
| # spreadsheet-paste failure. Both scenarios exit non-zero | |
| # independently and the job as a whole fails if either fails. | |
| # But: if trash-flow was *skipped* (because an earlier setup step | |
| # like `Wait for server` or `Install doctor deps` failed), running | |
| # this scenario just produces a confusing secondary error — so | |
| # only run when trash-flow actually got to execute. | |
| - name: Run spreadsheet-paste scenario | |
| id: spreadsheet-paste | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario spreadsheet-paste \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/spreadsheet-paste | |
| # M-S2 editor-UI smoke: toolbar (format painter), sheet-tab | |
| # bar, formula bar, header-click sort, freeze rows via | |
| # context menu. Catches DOM regressions on the M-S2 features | |
| # without needing a full per-feature scenario. | |
| - name: Run spreadsheet-features scenario | |
| id: spreadsheet-features | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario spreadsheet-features \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/spreadsheet-features | |
| # #76: spreadsheet mount/unmount reclaim. Repeatedly navigate | |
| # between two spreadsheet docs (each navigation unmounts the | |
| # previous grid and runs its on_cleanup), then copy/paste on the | |
| # surviving doc — a second spawn_local that must find the engine | |
| # still live. Asserts no panic / no use-after-free, not heap | |
| # numbers (usedJSHeapSize is too noisy to gate on). | |
| - name: Run spreadsheet-lifecycle scenario | |
| id: spreadsheet-lifecycle | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario spreadsheet-lifecycle \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/spreadsheet-lifecycle | |
| # #134: focus/expand toggle round-trip. Enter + exit focus mode via | |
| # both the single header button (which must flip ⤢ ↔ ✕) and | |
| # Ctrl+Shift+F, asserting the chrome hides/shows and — importantly — | |
| # nothing panics on teardown (the focus-exit closure-drop class). | |
| - name: Run focus-mode scenario | |
| id: focus-mode | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario focus-mode \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/focus-mode | |
| # Regression for the menu-bar backdrop-intercept bug: with a menu open, | |
| # clicking another menu name must switch to it in one click (the | |
| # full-screen backdrop used to swallow the click and only close). | |
| - name: Run menu-switch scenario | |
| id: menu-switch | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario menu-switch \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/menu-switch | |
| # #146: Document-menu Rename + Duplicate. On a spreadsheet (explicit, | |
| # assertable title): rename via the prompt updates the title; duplicate | |
| # navigates to a new doc with a "<title> copy" name and copied cell. | |
| - name: Run doc-actions scenario | |
| id: doc-actions | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario doc-actions \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/doc-actions | |
| # #144: favorites — star the doc, assert the header toggle goes active | |
| # and the doc shows up in the sidebar Favorites list (live), then unstar. | |
| - name: Run favorites scenario | |
| id: favorites | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario favorites \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/favorites | |
| # #147: in-app Find & Replace — type repeated text, open the bar (Edit | |
| # menu + Ctrl+F), assert the live match count, navigate, Replace All, | |
| # and verify the editor model actually changed. | |
| - name: Run find-replace scenario | |
| id: find-replace | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario find-replace \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/find-replace | |
| # #139: Show Line Numbers — number each visual line (more numbers than | |
| # blocks once a paragraph wraps) in one uniform font; Show Page Breaks | |
| # no longer paints a full-width rule through the text. | |
| - name: Run line-numbers scenario | |
| id: line-numbers | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario line-numbers \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/line-numbers | |
| # #141: Document Details panel — opens from the Document menu and shows | |
| # metadata rows incl. a live word/character count of the typed text. | |
| - name: Run document-details scenario | |
| id: document-details | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario document-details \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/document-details | |
| # #145: Expand — the header ↗ hides the doc header and shows a floating | |
| # collapse button (.expanded on app-layout); the FAB exits. | |
| - name: Run expand scenario | |
| id: expand | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario expand \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/expand | |
| # #143: subscript/superscript toolbar toggles render <sub>/<sup> in the | |
| # editor and are mutually exclusive. | |
| - name: Run subscript scenario | |
| id: subscript | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario subscript \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/subscript | |
| # M-S2 v2 pivot-table sidebar smoke: insert a pivot via ctx | |
| # menu, field-list checkbox default-routes by type, delete | |
| # clears the editor. Skips drag-drop (HTML5 dnd in headless | |
| # Chromium is fragile) — checkbox-driven path is reliable. | |
| - name: Run pivot-editor scenario | |
| id: pivot-editor | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario pivot-editor \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/pivot-editor | |
| # Same skip-on-setup-failure rule as spreadsheet-paste: the | |
| # scenario only makes sense if the server actually came up. We | |
| # gate on trash-flow having reached a real outcome (success or | |
| # failure) — that's our proxy for "stack is provisioned and | |
| # serving HTTP." | |
| - name: Run comment-live-sync scenario | |
| id: comment-live-sync | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario comment-live-sync \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/comment-live-sync | |
| # ─── May 2026 UI batch — six new spreadsheet scenarios ──── | |
| # Each targets one or more specific issues fixed in the | |
| # recent batch that previously only had engine-level | |
| # coverage. See `scripts/frontend-doctor/doctor.js` for | |
| # scenario bodies and gates. | |
| - name: Run spreadsheet-keyboard scenario (#60 Ctrl+Y) | |
| id: spreadsheet-keyboard | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario spreadsheet-keyboard \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/spreadsheet-keyboard | |
| - name: Run spreadsheet-headers scenario (#58 status bar, #59 col-click) | |
| id: spreadsheet-headers | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario spreadsheet-headers \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/spreadsheet-headers | |
| - name: Run spreadsheet-toolbar scenario (#56 format dropdown, #64 bold) | |
| id: spreadsheet-toolbar | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario spreadsheet-toolbar \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/spreadsheet-toolbar | |
| - name: Run spreadsheet-freeze scenario (#61 off-by-one) | |
| id: spreadsheet-freeze | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario spreadsheet-freeze \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/spreadsheet-freeze | |
| - name: Run spreadsheet-sheet-tabs scenario (#65 right-click delete) | |
| id: spreadsheet-sheet-tabs | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario spreadsheet-sheet-tabs \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/spreadsheet-sheet-tabs | |
| - name: Run spreadsheet-remote-cursor scenario (#63 presence) | |
| id: spreadsheet-remote-cursor | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario spreadsheet-remote-cursor \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/spreadsheet-remote-cursor | |
| - name: Run spreadsheet cursor delayed-handshake regression | |
| id: spreadsheet-remote-cursor-delayed | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario spreadsheet-remote-cursor-delayed \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/spreadsheet-remote-cursor-delayed | |
| - name: Run mobile-spreadsheet-keyboards scenario (M-P3 piece E) | |
| id: mobile-spreadsheet-keyboards | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario mobile-spreadsheet-keyboards \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/mobile-spreadsheet-keyboards | |
| - name: Run command-palette-actions scenario (M-P4 piece D) | |
| id: command-palette-actions | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario command-palette-actions \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/command-palette-actions | |
| - name: Run embed-youtube scenario (M-P6 piece D) | |
| id: embed-youtube | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario embed-youtube \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/embed-youtube | |
| - name: Run bulk-delete scenario (M-P7 piece D) | |
| id: bulk-delete | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario bulk-delete \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/bulk-delete | |
| # M-P8 piece D: axe-core conformance gate. Walks home, editor, | |
| # and command-palette-open surfaces with the WCAG 2.1 A + AA | |
| # rule set. Threshold is zero serious + zero critical | |
| # violations per surface; minor/moderate findings land in the | |
| # uploaded axe-results.json artifact but don't fail the gate | |
| # (the team takes those as a follow-up backlog). | |
| - name: Run a11y-audit scenario (M-P8 piece D) | |
| id: a11y-audit | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario a11y-audit \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/a11y-audit | |
| # Settings → Appearance layout guard: the theme buttons must render | |
| # inside the panel card (regression for the .settings-page | |
| # fit-content collapse — see scenarioSettingsAppearance in doctor.js). | |
| - name: Run settings-appearance scenario | |
| id: settings-appearance | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario settings-appearance \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/settings-appearance | |
| # M-6.2 piece E: end-to-end agentic Ask flow. Real Claude API | |
| # call (paid). Gated on ANTHROPIC_API_KEY being set so PRs | |
| # from forks (which can't access repo secrets) don't fail — | |
| # the env var resolves to empty for fork builds, the `if` | |
| # below skips, and the scenario doesn't run at all. | |
| - name: Run ask-flow scenario (M-6.2 piece E) | |
| id: ask-flow | |
| if: always() && env.ANTHROPIC_API_KEY != '' && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario ask-flow \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/ask-flow | |
| # Persist-on-refresh regression guard for the d92dac4 / 108d4fc | |
| # fix arc — see scenarioSustainedTypeReload in doctor.js for the | |
| # rationale. | |
| - name: Run sustained-type-reload scenario | |
| id: sustained-type-reload | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario sustained-type-reload \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/sustained-type-reload | |
| # #92: typing-during-handshake guard (deterministic — holds the | |
| # first SyncStep2 via routeWebSocket). Written for the 07147fa fix | |
| # arc but never wired into CI; the is_synced() gate on the | |
| # debounced sender silently regressed it. See | |
| # scenarioPreSyncEditPreserved in doctor.js. | |
| - name: Run pre-sync-edit-preserved scenario (#92) | |
| id: pre-sync-edit-preserved | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario pre-sync-edit-preserved \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/pre-sync-edit-preserved | |
| # #92: natural-timing mount race — types the instant | |
| # [data-editor-ready] lands and asserts the EXACT text (drops and | |
| # reorders both fail), live and after reload, across several | |
| # fresh docs. See scenarioFirstKeystrokes in doctor.js. | |
| - name: Run first-keystrokes scenario (#92) | |
| id: first-keystrokes | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario first-keystrokes \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/first-keystrokes | |
| # Firefox-only: the #90 history-pane close panic is Gecko-specific | |
| # (clean in Chromium). Guards against regression of the deferred | |
| # modal-close teardown in history_viewer.rs. | |
| - name: Run history-pane scenario (Firefox, #90) | |
| id: history-pane | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| env: | |
| DOCTOR_BROWSER: firefox | |
| run: | | |
| node doctor.js \ | |
| --scenario history-pane \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/history-pane | |
| # Firefox-only: the delete-from-menu confirm-dialog close panic is | |
| # the same Gecko-specific class as #90. Guards the deferred | |
| # ConfirmDialog close path. | |
| - name: Run delete-document scenario (Firefox) | |
| id: delete-document | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| env: | |
| DOCTOR_BROWSER: firefox | |
| run: | | |
| node doctor.js \ | |
| --scenario delete-document \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/delete-document | |
| # Firefox-only: ConfirmDialog/dialog close panic class. The share | |
| # dialog guards the same `a11y::defer_close` click-close path the | |
| # CommentPopup uses, so it covers the comment-popup fix too. The | |
| # dedicated comment-popup scenario (driven via the spreadsheet | |
| # cell-comment submenu + create_thread round-trip) is flaky on the CI | |
| # runner — kept in doctor.js for manual runs, not gated here. | |
| - name: Run share-dialog scenario (Firefox) | |
| id: share-dialog | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| env: | |
| DOCTOR_BROWSER: firefox | |
| run: | | |
| node doctor.js \ | |
| --scenario share-dialog \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/share-dialog | |
| # Block deep links (#b= fragments): copy-link menu action, scroll + | |
| # flash on a valid fragment, missing-block toast (shown + auto- | |
| # dismissed), and foreign/malformed-hash inertness. Promoted from | |
| # the ad-hoc probe-block-links.mjs. | |
| - name: Run block-links scenario | |
| id: block-links | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario block-links \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/block-links | |
| # DocMention paste matrix (doc/anchor/dangling/no-access/in-sentence), | |
| # single-undo restoring the raw URL, chip glyphs, context-menu | |
| # actions, SPA click-nav, missing (trashed) state, and Convert to | |
| # Plain Link. Promoted from the ad-hoc probe-doc-mentions.mjs. | |
| - name: Run doc-mentions scenario | |
| id: doc-mentions | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario doc-mentions \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/doc-mentions | |
| # Presentations P1 (deck editor): create deck → canvas at real size | |
| # (regression net for the 0×0 flex collapse) → add title-content | |
| # slide → in-frame typing via the embedded editor → Escape → | |
| # reload → text + thumb count persisted. First green run | |
| # 2026-08-01 against a local stack (PR #186). | |
| - name: Run deck-basics scenario | |
| id: deck-basics | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario deck-basics \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/deck-basics | |
| # Frames render the full editor block set (spec: | |
| # docs/superpowers/specs/2026-08-01-deck-frame-blocks-design.md): | |
| # slash-menu mermaid insert inside a frame editor, image upload | |
| # via filechooser with durable blob-ref resolution on the canvas, | |
| # both surviving Escape and a full reload. | |
| - name: Run deck-blocks scenario | |
| id: deck-blocks | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario deck-blocks \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/deck-blocks | |
| # Present mode (P2): full-screen stage at real size, keyboard | |
| # navigation, Escape back to the editor, presenter view panels + | |
| # timer, and PDF slide export over the API. | |
| - name: Run deck-present scenario | |
| id: deck-present | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario deck-present \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/deck-present | |
| # ─── Previously orphaned scenarios ───────────────────────── | |
| # These existed in doctor.js but were never invoked by any | |
| # workflow. Wired 2026-09-02. Still NOT wired, because the CI | |
| # server can't satisfy their preconditions: admin-console | |
| # (ADMIN_EMAILS), mfa-flow (MFA key config), semantic-search | |
| # (qdrant + embeddings), import-job-round-trip (worker process), | |
| # collab-sync (needs a pre-created --doc-id and two-tab setup). | |
| # Atom caret class: typing past a block atom must not swallow or | |
| # duplicate the keystroke. | |
| - name: Run type-past-atom scenario | |
| id: type-past-atom | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario type-past-atom \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/type-past-atom | |
| - name: Run code-block-enter scenario | |
| id: code-block-enter | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario code-block-enter \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/code-block-enter | |
| - name: Run document table structure regression | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: node test-table-structure.cjs --base-url http://127.0.0.1:3000 | |
| - name: Run merged document table structure regression | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: node test-merged-table-structure.cjs --base-url http://127.0.0.1:3000 | |
| - name: Run calendar-block scenario | |
| id: calendar-block | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario calendar-block \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/calendar-block | |
| - name: Run kanban-block scenario | |
| id: kanban-block | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario kanban-block \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/kanban-block | |
| - name: Run kanban-drag scenario | |
| id: kanban-drag | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario kanban-drag \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/kanban-drag | |
| - name: Run kanban-wip-limit scenario | |
| id: kanban-wip-limit | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario kanban-wip-limit \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/kanban-wip-limit | |
| - name: Run kanban-column-reorder scenario | |
| id: kanban-column-reorder | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario kanban-column-reorder \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/kanban-column-reorder | |
| - name: Run kanban-card-metadata scenario | |
| id: kanban-card-metadata | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario kanban-card-metadata \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/kanban-card-metadata | |
| # Firefox-only: same modal-close panic class as history-pane, | |
| # exercised through the comment composer's ✕ and backdrop. | |
| - name: Run comment-popup scenario (Firefox) | |
| id: comment-popup | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| env: | |
| DOCTOR_BROWSER: firefox | |
| run: | | |
| node doctor.js \ | |
| --scenario comment-popup \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/comment-popup | |
| # Document → Export must download via authenticated fetch, not a | |
| # bearer-less window.open (2026-05-28 401 class). | |
| - name: Run menu-export-downloads scenario | |
| id: menu-export-downloads | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node doctor.js \ | |
| --scenario menu-export-downloads \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/menu-export-downloads | |
| - name: Run Mermaid modal collaboration and mobile regression | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node test-mermaid-modal.cjs \ | |
| --base-url http://127.0.0.1:3000 \ | |
| --out ../../artifacts/doctor/mermaid-modal | |
| - name: Run Mermaid semantics and keyboard accessibility regression | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: node test-mermaid-semantics.cjs --base-url http://127.0.0.1:3000 | |
| - name: Verify Mermaid rendered fixture freshness | |
| run: cargo test -p ogrenotes-mermaid --test golden --test theme --locked | |
| - name: Run Mermaid CLI PNG regression | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node test-mermaid-png.cjs --out ../../artifacts/doctor/mermaid-png | |
| - name: Run Mermaid ImageMagick fallback regression | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| python3 configure-mermaid-imagemagick.py /tmp/mermaid-imagemagick | |
| export MAGICK_CONFIGURE_PATH=/tmp/mermaid-imagemagick/config | |
| export MERMAID_CLI_PATH=/tmp/mermaid-imagemagick/bin | |
| export REFERENCE_MAGICK="$(command -v convert)" | |
| node test-mermaid-png.cjs --out ../../artifacts/doctor/mermaid-png-imagemagick | |
| - name: Run Mermaid rendered contrast regression | |
| if: always() && (steps.trash-flow.outcome == 'success' || steps.trash-flow.outcome == 'failure') | |
| working-directory: scripts/frontend-doctor | |
| run: | | |
| node test-mermaid-contrast.cjs --out ../../artifacts/doctor/mermaid-contrast | |
| # ─── Teardown / artifacts ───────────────────────────────── | |
| - name: Stop axum server | |
| if: always() | |
| run: | | |
| if [ -f /tmp/ci-logs/server.pid ]; then | |
| kill "$(cat /tmp/ci-logs/server.pid)" || true | |
| fi | |
| - name: Upload doctor artifacts | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: frontend-doctor-artifacts | |
| path: | | |
| artifacts/doctor/** | |
| /tmp/ci-logs/server.log | |
| /tmp/ci-logs/i18n-browser.log | |
| if-no-files-found: warn | |
| retention-days: 14 | |
| - name: Compose logs on failure | |
| if: failure() | |
| run: docker compose logs --no-color | |
| - name: Stop infra | |
| if: always() | |
| run: docker compose down -v |