diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f269214d..7d6c8f64 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,14 +1,25 @@ name: CI -# Runs on every PR and on master: typecheck, unit + catalog-smoke tests, an -# authoring build, and Playwright e2e (deterministic UI flow — the live-render -# checks that need the external Sandpack bundler are gated behind E2E_LIVE). -# Also callable (workflow_call) so the deploy workflow can gate on it. +# The reusable CI suite, as a DAG (same shape as the handsontable monorepo's +# test workflow): build fans out to the consumers, artifacts are passed instead +# of rebuilt, and the Playwright job runs inside the pinned Playwright +# container so the browser always matches the test package. +# +# unit ──► build ──► authoring ──► e2e +# +# unit gates build: it is the cheapest job (pnpm test builds the runtime +# itself, by design), so a broken helper fails the run before any minutes go +# into the app build or a browser container. +# +# The live-render checks that need the external Sandpack bundler stay gated +# behind E2E_LIVE (e2e-live.yml). Also callable (workflow_call) so the deploy +# workflows can gate on the whole DAG. +# No push trigger of its own: master pushes run this exactly once through +# master.yml's `test` job (workflow_call). Before that split, one push touching +# runner/packages/** ran this suite three times. on: pull_request: {} - push: - branches: [master] workflow_call: {} workflow_dispatch: {} @@ -20,7 +31,31 @@ concurrency: cancel-in-progress: true jobs: - test: + unit: + runs-on: ubuntu-latest + defaults: + run: + working-directory: runner + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + package_json_file: runner/package.json + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: runner/pnpm-lock.yaml + + - run: pnpm install --frozen-lockfile + + - name: Unit + catalog-smoke tests + run: pnpm test + + build: + needs: unit runs-on: ubuntu-latest defaults: run: @@ -44,17 +79,90 @@ jobs: - run: pnpm build + # Typecheck rides with build: apps/authoring typechecks against + # packages/runtime/dist, which this job just produced. - name: Typecheck run: pnpm typecheck - - name: Unit + catalog-smoke tests - run: pnpm test + - name: Upload the runtime build + uses: actions/upload-artifact@v4 + with: + name: runtime-dist + path: runner/packages/runtime/dist/ + retention-days: 1 + if-no-files-found: error + + authoring: + needs: build + runs-on: ubuntu-latest + defaults: + run: + working-directory: runner + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + package_json_file: runner/package.json + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: runner/pnpm-lock.yaml + + - run: pnpm install --frozen-lockfile + + - name: Download the runtime build + uses: actions/download-artifact@v4 + with: + name: runtime-dist + path: runner/packages/runtime/dist/ - name: Build authoring (build smoke + e2e target) run: pnpm --filter @handsontable/demo-authoring build - - name: Install Playwright browser - run: pnpm exec playwright install --with-deps chromium + - name: Upload the authoring build + uses: actions/upload-artifact@v4 + with: + name: authoring-dist + path: runner/apps/authoring/dist/ + retention-days: 1 + if-no-files-found: error + + e2e: + needs: authoring + runs-on: ubuntu-latest + # The official Playwright image ships the browsers, so there is no + # `playwright install` step. Keep this tag in lockstep with + # `@playwright/test` in runner/package.json — bump the two together, never + # apart (the same rule the handsontable monorepo enforces via its pnpm + # catalog comment). + container: + image: mcr.microsoft.com/playwright:v1.61.1-noble + defaults: + run: + working-directory: runner + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + package_json_file: runner/package.json + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: runner/pnpm-lock.yaml + + - run: pnpm install --frozen-lockfile + + - name: Download the authoring build (the e2e target) + uses: actions/download-artifact@v4 + with: + name: authoring-dist + path: runner/apps/authoring/dist/ - name: E2E (Playwright) run: pnpm e2e @@ -64,5 +172,9 @@ jobs: uses: actions/upload-artifact@v4 with: name: playwright-report - path: runner/playwright-report/ + # playwright-report/ holds the html report, test-results/ the traces. + path: | + runner/playwright-report/ + runner/test-results/ retention-days: 7 + if-no-files-found: warn diff --git a/.github/workflows/deploy-runner-api.yml b/.github/workflows/deploy-runner-api.yml deleted file mode 100644 index 05c9372f..00000000 --- a/.github/workflows/deploy-runner-api.yml +++ /dev/null @@ -1,82 +0,0 @@ -name: Deploy runner — API + Tier-2 container image - -# Deploys the orchestration worker (handsontable-demos-api) to the main -# Handsontable Cloudflare account on push to master touching the API/containers. -# Runs CI first (test job) and only deploys if it passes. `wrangler deploy` -# builds + pushes the containers/live Tier-2 image (Vue baked) with Docker -# (present on ubuntu-latest). Auth: CLOUDFLARE_API_TOKEN secret. - -on: - push: - branches: [master] - paths: - - "runner/workers/api/**" - - "runner/containers/**" - - "runner/scripts/**" - - "runner/config/**" - - "runner/packages/**" - - "runner/pnpm-lock.yaml" - - ".github/workflows/deploy-runner-api.yml" - - ".github/workflows/ci.yml" - workflow_dispatch: {} - -concurrency: - group: deploy-runner-api - cancel-in-progress: false # never cancel a container build/push mid-flight - -jobs: - # Gate: run the full CI suite (typecheck, unit/smoke, e2e) before deploying. - test: - uses: ./.github/workflows/ci.yml - - deploy: - needs: test - runs-on: ubuntu-latest - defaults: - run: - working-directory: runner - steps: - - uses: actions/checkout@v4 - - - uses: pnpm/action-setup@v4 - with: - package_json_file: runner/package.json - - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: pnpm - cache-dependency-path: runner/pnpm-lock.yaml - - - run: pnpm install --frozen-lockfile - - run: pnpm build - - # Apply pending D1 schema changes to the remote DB before shipping code - # that may depend on them. Migrations are idempotent (IF NOT EXISTS). - - name: Apply D1 migrations (remote) - working-directory: runner/workers/api - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - run: pnpm exec wrangler d1 migrations apply handsontable-demos --remote - - # ubuntu-latest ships Docker with the daemon running; wrangler uses it to - # build + push the containers/live image. Use the package `deploy` script, - # which attaches the demos.handsontable.com routes via --routes (routes are - # intentionally NOT in wrangler.jsonc — see ADR-0020 / run-and-deploy.md). - - name: Deploy API worker (builds + pushes Tier-2 image, attaches routes) - working-directory: runner/workers/api - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - run: pnpm run deploy - - - name: Smoke test — prod API health - working-directory: . - run: | - for i in $(seq 1 12); do - code=$(curl -s -o /dev/null -w "%{http_code}" https://demos.handsontable.com/api/health || true) - echo "health attempt $i -> $code" - [ "$code" = "200" ] && exit 0 - sleep 6 - done - echo "::error::prod /api/health did not return 200 after deploy" - exit 1 diff --git a/.github/workflows/deploy-runner-authoring.yml b/.github/workflows/deploy-runner-authoring.yml deleted file mode 100644 index 3ca277b4..00000000 --- a/.github/workflows/deploy-runner-authoring.yml +++ /dev/null @@ -1,95 +0,0 @@ -name: Deploy runner — authoring (frontend) - -# Auto-deploys the static authoring app (handsontable-demos-authoring, Workers -# Assets) to the main Handsontable Cloudflare account on every push to master -# that touches the frontend. No Docker needed. Auth: CLOUDFLARE_API_TOKEN secret. -# -# Restored after the move to Cloudflare Workers Builds (b26cba8): Workers Builds -# requires one-time dashboard Git-integration setup that was never completed, so -# the frontend silently stopped deploying. GitHub Actions needs no CF dashboard -# access — only the existing CLOUDFLARE_API_TOKEN secret (already used by -# deploy-runner-api.yml). - -on: - push: - branches: [master] - paths: - - "runner/apps/authoring/**" - - "runner/packages/**" - - "runner/config/**" - # The authoring build imports catalog.json at compile time, so a - # catalog-only change (e.g. after `pnpm import`) must redeploy the app. - - "runner/catalog.json" - - "runner/pnpm-lock.yaml" - - ".github/workflows/deploy-runner-authoring.yml" - - ".github/workflows/ci.yml" - workflow_dispatch: {} - -concurrency: - group: deploy-runner-authoring - cancel-in-progress: true - -jobs: - # Gate: run the full CI suite (typecheck, unit/smoke, e2e) before deploying. - test: - uses: ./.github/workflows/ci.yml - - deploy: - needs: test - runs-on: ubuntu-latest - defaults: - run: - working-directory: runner - steps: - - uses: actions/checkout@v4 - - - uses: pnpm/action-setup@v4 - with: - package_json_file: runner/package.json - - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: pnpm - cache-dependency-path: runner/pnpm-lock.yaml - - - run: pnpm install --frozen-lockfile - - # Build workspace packages, then the authoring app. VITE_API_BASE comes - # from apps/authoring/.env.production (committed) so it targets prod. - - run: pnpm build - # SENTRY_* are only set here, on the deploying build. The `test` job reuses - # ci.yml, which gets no token, so PR builds neither emit source maps nor - # upload a release — see apps/authoring/vite.config.ts. - - run: pnpm --filter @handsontable/demo-authoring build - env: - SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_ORG: ${{ vars.SENTRY_ORG }} - SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }} - GITHUB_SHA: ${{ github.sha }} - - - name: Deploy authoring worker - working-directory: runner/apps/authoring - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - run: npx -y wrangler@4.108.0 deploy - - - name: Smoke test — prod frontend serves current bundle - working-directory: . - run: | - # The entry bundle is the one referenced by the built index.html — - # don't glob dist/assets (lazy chunks like index--*.js sort - # before the entry and produce a false mismatch). - built_asset=$(grep -oE '/assets/index-[A-Za-z0-9_-]+\.js' runner/apps/authoring/dist/index.html | head -1) - if [ -z "$built_asset" ]; then - echo "::error::could not find entry bundle in built index.html" - exit 1 - fi - for i in $(seq 1 12); do - prod_asset=$(curl -s https://demos.handsontable.com/ | grep -oE '/assets/index-[A-Za-z0-9_-]+\.js' | head -1) - echo "attempt $i -> prod: $prod_asset, built: $built_asset" - [ "$prod_asset" = "$built_asset" ] && exit 0 - sleep 5 - done - echo "::error::prod frontend does not serve the freshly built bundle" - exit 1 diff --git a/.github/workflows/e2e-live.yml b/.github/workflows/e2e-live.yml index d2b5f55b..89c38628 100644 --- a/.github/workflows/e2e-live.yml +++ b/.github/workflows/e2e-live.yml @@ -62,6 +62,10 @@ concurrency: jobs: live: runs-on: ubuntu-latest + # Same image rule as ci.yml: keep the tag in lockstep with @playwright/test + # in runner/package.json — bump the two together, never apart. + container: + image: mcr.microsoft.com/playwright:v1.61.1-noble # Local live run takes seconds once built; leave headroom for a cold # hosted-bundler transpile of the heavier examples. The deployed run also # boots four real containers for the Style panel suite, one at a time. @@ -90,9 +94,6 @@ jobs: pnpm build pnpm --filter @handsontable/demo-authoring build - - name: Install Playwright browser - run: pnpm exec playwright install --with-deps chromium - - name: E2E with live-render checks env: E2E_LIVE: '1' @@ -123,5 +124,9 @@ jobs: uses: actions/upload-artifact@v4 with: name: playwright-report-live - path: runner/playwright-report/ + # playwright-report/ holds the html report, test-results/ the traces. + path: | + runner/playwright-report/ + runner/test-results/ retention-days: 7 + if-no-files-found: warn diff --git a/.github/workflows/e2e-starter-matrix.yml b/.github/workflows/e2e-starter-matrix.yml index e9c964b1..a2ae96ef 100644 --- a/.github/workflows/e2e-starter-matrix.yml +++ b/.github/workflows/e2e-starter-matrix.yml @@ -39,6 +39,10 @@ concurrency: jobs: matrix: runs-on: ubuntu-latest + # Same image rule as ci.yml: keep the tag in lockstep with @playwright/test + # in runner/package.json — bump the two together, never apart. + container: + image: mcr.microsoft.com/playwright:v1.61.1-noble # ~36 min observed locally (--workers=2, --retries=2); 180 leaves ample # headroom for slower CI runners + retry storms. timeout-minutes: 180 @@ -63,9 +67,6 @@ jobs: # No `pnpm build`: the matrix spec only imports catalog.json + fetches npm # and runs against deployed prod, so no workspace build is needed. - - name: Install Playwright browser - run: pnpm exec playwright install --with-deps chromium - - name: Run starter matrix env: E2E_BASE_URL: ${{ inputs.base_url }} diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml new file mode 100644 index 00000000..d074b524 --- /dev/null +++ b/.github/workflows/master.yml @@ -0,0 +1,200 @@ +name: Master + +# The single master-push pipeline (the handsontable monorepo's develop.yml +# pattern). Before this existed, one push touching runner/packages/** ran the +# CI suite three times — ci.yml's own push trigger plus a `uses: ci.yml` gate +# inside each deploy workflow. Now: one CI run, and the two deploys hang off +# it as conditional jobs, path-gated by a plain `git diff` (the push-level +# `paths:` filter can't be used once the deploys share a workflow). +# +# changes ─┐ +# ├─► deploy-authoring (if authoring paths changed) +# test ────┤ +# └─► deploy-api (if api paths changed) +# +# Manual deploys: workflow_dispatch with the two checkboxes. + +on: + push: + branches: [master] + workflow_dispatch: + inputs: + deploy_authoring: + description: 'Deploy the authoring frontend' + type: boolean + default: false + deploy_api: + description: 'Deploy the API worker + Tier-2 container image' + type: boolean + default: false + +permissions: + contents: read + +# Never cancel: a run may be mid container-build/push (the old +# deploy-runner-api rule, now covering both deploys). Queued runs wait. +concurrency: + group: master-${{ github.ref }} + cancel-in-progress: false + +jobs: + changes: + runs-on: ubuntu-latest + outputs: + authoring: ${{ steps.detect.outputs.authoring }} + api: ${{ steps.detect.outputs.api }} + steps: + - uses: actions/checkout@v4 + with: + # Full history: the diff below spans the whole push range, and a + # multi-commit push (rebase-and-merge, a direct push of several + # commits) reaches past any fixed depth. + fetch-depth: 0 + + - id: detect + name: Detect what this push touches + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "authoring=${{ inputs.deploy_authoring }}" >> "$GITHUB_OUTPUT" + echo "api=${{ inputs.deploy_api }}" >> "$GITHUB_OUTPUT" + exit 0 + fi + # The push event's `before` bounds the range: HEAD^ only covers the + # tip commit, so a multi-commit push would skip deploys for files + # changed in the earlier commits (Bugbot, #184). `before` is the + # zero sha on a branch creation, and unreachable after a force push + # that discarded it — fall back to the tip diff in both cases. + before="${{ github.event.before }}" + if [ -z "$before" ] || [ "$before" = "0000000000000000000000000000000000000000" ] \ + || ! git cat-file -e "$before" 2>/dev/null; then + before="$(git rev-parse HEAD^)" + fi + git diff --name-only "$before" HEAD > /tmp/changed.txt + echo "--- changed files ---"; cat /tmp/changed.txt + # The same path sets the two deploy workflows used to declare under + # `on.push.paths` (catalog.json is authoring-only: the app bundles it + # at build time; scripts/ and containers/ are api-only). + authoring=false; api=false + grep -qE '^runner/(apps/authoring/|packages/|config/|catalog\.json$|pnpm-lock\.yaml$)|^\.github/workflows/(master|ci)\.yml$' /tmp/changed.txt && authoring=true + grep -qE '^runner/(workers/api/|containers/|scripts/|config/|packages/|pnpm-lock\.yaml$)|^\.github/workflows/(master|ci)\.yml$' /tmp/changed.txt && api=true + echo "authoring=$authoring" >> "$GITHUB_OUTPUT" + echo "api=$api" >> "$GITHUB_OUTPUT" + + # The one CI run per master push — the deploys gate on it, and it doubles as + # the post-merge canary for pushes that deploy nothing. + test: + uses: ./.github/workflows/ci.yml + + deploy-authoring: + needs: [changes, test] + if: needs.changes.outputs.authoring == 'true' + runs-on: ubuntu-latest + defaults: + run: + working-directory: runner + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + package_json_file: runner/package.json + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: runner/pnpm-lock.yaml + + - run: pnpm install --frozen-lockfile + + # Build workspace packages, then the authoring app. VITE_API_BASE comes + # from apps/authoring/.env.production (committed) so it targets prod. + - run: pnpm build + # SENTRY_* are only set here, on the deploying build. The `test` job + # reuses ci.yml, which gets no token, so PR builds neither emit source + # maps nor upload a release — see apps/authoring/vite.config.ts. + - run: pnpm --filter @handsontable/demo-authoring build + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ vars.SENTRY_ORG }} + SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }} + GITHUB_SHA: ${{ github.sha }} + + - name: Deploy authoring worker + working-directory: runner/apps/authoring + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + run: npx -y wrangler@4.108.0 deploy + + - name: Smoke test — prod frontend serves current bundle + working-directory: . + run: | + # The entry bundle is the one referenced by the built index.html — + # don't glob dist/assets (lazy chunks like index--*.js sort + # before the entry and produce a false mismatch). + built_asset=$(grep -oE '/assets/index-[A-Za-z0-9_-]+\.js' runner/apps/authoring/dist/index.html | head -1) + if [ -z "$built_asset" ]; then + echo "::error::could not find entry bundle in built index.html" + exit 1 + fi + for i in $(seq 1 12); do + prod_asset=$(curl -s https://demos.handsontable.com/ | grep -oE '/assets/index-[A-Za-z0-9_-]+\.js' | head -1) + echo "attempt $i -> prod: $prod_asset, built: $built_asset" + [ "$prod_asset" = "$built_asset" ] && exit 0 + sleep 5 + done + echo "::error::prod frontend does not serve the freshly built bundle" + exit 1 + + deploy-api: + needs: [changes, test] + if: needs.changes.outputs.api == 'true' + runs-on: ubuntu-latest + defaults: + run: + working-directory: runner + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + package_json_file: runner/package.json + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: runner/pnpm-lock.yaml + + - run: pnpm install --frozen-lockfile + - run: pnpm build + + # Apply pending D1 schema changes to the remote DB before shipping code + # that may depend on them. Migrations are idempotent (IF NOT EXISTS). + - name: Apply D1 migrations (remote) + working-directory: runner/workers/api + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + run: pnpm exec wrangler d1 migrations apply handsontable-demos --remote + + # ubuntu-latest ships Docker with the daemon running; wrangler uses it to + # build + push the containers/live image. Use the package `deploy` script, + # which attaches the demos.handsontable.com routes via --routes (routes are + # intentionally NOT in wrangler.jsonc — see ADR-0020 / run-and-deploy.md). + - name: Deploy API worker (builds + pushes Tier-2 image, attaches routes) + working-directory: runner/workers/api + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + run: pnpm run deploy + + - name: Smoke test — prod API health + working-directory: . + run: | + for i in $(seq 1 12); do + code=$(curl -s -o /dev/null -w "%{http_code}" https://demos.handsontable.com/api/health || true) + echo "health attempt $i -> $code" + [ "$code" = "200" ] && exit 0 + sleep 6 + done + echo "::error::prod /api/health did not return 200 after deploy" + exit 1 diff --git a/runner/AGENTS.md b/runner/AGENTS.md index 3cd83d70..0825eb39 100644 --- a/runner/AGENTS.md +++ b/runner/AGENTS.md @@ -111,6 +111,12 @@ What a green E2E run does and does not prove: - **Interaction states need a real pointer and `getComputedStyle`** — see [ADR-0026](docs/adr/0026-shell-styling-inline-vs-stylesheet.md). A synthetic `mouseover` does not fire CSS `:hover`, and a screenshot cannot tell a subtle live hover from a dead one. +- **Backend-bound specs self-gate on `E2E_BASE_URL`** (`share-view.spec.ts` and friends): + `vite preview` has no `/api`, `/d` or `/embed` routes, so they skip unless pointed at a + deployment. `e2e/share-view.spec.ts` additionally depends on a **permanent fixture demo** + (`FIXTURE_ID` in the spec — currently `r-react-18-0-0`). Never revoke it; if it is lost, + mint a replacement titled "E2E fixture — do not revoke" from any signed-in session and + update the constant. ## Build & deploy @@ -144,13 +150,12 @@ that term to a bare `localhost:` — catalog README text mentions dev-server por ## CI/CD -Seven workflows live in `.github/workflows/` at the repo root: +Six workflows live in `.github/workflows/` at the repo root: | Workflow | Trigger | What it does | |----------|---------|--------------| -| `ci.yml` | every PR + push to `master` | build, typecheck, unit + catalog-smoke tests, authoring build, Playwright e2e. Also `workflow_call`able, so the deploy workflows gate on it. | -| `deploy-runner-api.yml` | push to `master` touching `workers/api`, `containers`, `scripts`, `config`, `packages` (or manual) | deploys `workers/api`. | -| `deploy-runner-authoring.yml` | push to `master` touching `apps/authoring`, `packages`, `config`, **`catalog.json`** (or manual) | builds + deploys `apps/authoring`. | +| `ci.yml` | every PR; `workflow_call` from `master.yml` and manual dispatch | the reusable CI DAG: build → authoring → e2e (in the pinned Playwright container), with unit in parallel. No push trigger of its own — master runs it once through `master.yml`. | +| `master.yml` | every push to `master` (or manual dispatch with per-target checkboxes) | one CI run + path-gated deploys: `deploy-authoring` and `deploy-api` run only when a plain `git diff` says their files changed. Replaces the two `deploy-runner-*.yml` workflows, whose per-workflow CI gates ran the suite up to three times per push. | | `e2e-live.yml` | manual | the `E2E_LIVE=1` specs that mount a real preview. | | `e2e-starter-matrix.yml` | manual | every starter through a live session; serialized against the global container cap. | | `import-docs.yml` | manual, or `repository_dispatch: docs-examples-sync` from the docs repo | re-imports the documentation-guide examples. | diff --git a/runner/e2e/docs-examples.spec.ts b/runner/e2e/docs-examples.spec.ts index 738782a4..44ff1edf 100644 --- a/runner/e2e/docs-examples.spec.ts +++ b/runner/e2e/docs-examples.spec.ts @@ -514,7 +514,8 @@ test("a deep link whose manifest row has no artifact shows not-found", async ({ }); // Live render — needs the external Sandpack bundler; opt-in via E2E_LIVE=1. -test("live: a JavaScript example renders a Handsontable grid", async ({ page }) => { +// @smoke: the post-deploy subset (DEV-2203) uses this as its docs-example canary. +test("live: a JavaScript example renders a Handsontable grid", { tag: "@smoke" }, async ({ page }) => { test.skip(process.env.E2E_LIVE !== "1", "set E2E_LIVE=1 to run live-render checks"); test.setTimeout(120_000); // (column-adding was removed from the docs; accessibility example1 is a diff --git a/runner/e2e/docs-frameworks.spec.ts b/runner/e2e/docs-frameworks.spec.ts new file mode 100644 index 00000000..93a9ec0a --- /dev/null +++ b/runner/e2e/docs-frameworks.spec.ts @@ -0,0 +1,81 @@ +import { test, expect } from "@playwright/test"; +import { expectGridRendered, isKnownNoise, previewReady, trackSessions } from "./helpers"; + +// The docs-example paths that need a container (DEV-2203). +// +// docs-examples.spec.ts renders JavaScript, TypeScript and React guide +// examples live — all Sandpack. But a docs *Vue* or *Angular* example runs in +// a real container (config/frameworks.json gives both engine: "container" for +// docs imports), so until now the two wrappers most likely to break on a +// container image change had no live docs coverage at all. +// +// Two boots, no more. The pool holds five global slots shared with real +// traffic, so this spec must run with --workers=1 and never grows a +// per-example walk — the 1261-entry bucket belongs to the manifest tests and +// the import pipeline. Fixture: the context-menu guide (react + vue variants +// in every bucket) and the accessibility guide's Angular example. + +const REACT_DOCS = "/?docs=guides/accessories-and-menus/context-menu/react/example1.tsx&v=18.0.0"; +const ANGULAR_DOCS = "/?docs=guides/accessibility/accessibility/angular/example1.ts&v=18.0.0"; + +test.describe("docs examples on the container engine", () => { + test.skip(process.env.E2E_LIVE !== "1", "set E2E_LIVE=1 to run live-render checks"); + test.skip(!process.env.E2E_BASE_URL, "containers need a deployed API origin — vite preview has no /api proxy"); + test.describe.configure({ timeout: 300_000 }); + + test("switching a docs example to Vue renders the Vue variant in a container", async ({ page, request }) => { + // Two engines in sequence: a Sandpack ready (up to 120s) *and then* a + // container ready (240s) plus the grid poll — the shared 300s describe + // budget fits a single boot, not both (Bugbot, #184). + test.setTimeout(480_000); + const tracked = trackSessions(page); + try { + await page.goto(REACT_DOCS); + await previewReady(page, "sandpack"); + + // The Framework menu labels options with the docs display name — match + // by prefix so "Vue 3" does not couple this test to the exact wording. + await page.getByRole("button", { name: "Framework", exact: true }).click(); + await page.getByRole("option", { name: /^Vue/ }).click(); + await expect(page).toHaveURL(/docs=guides%2Faccessories-and-menus%2Fcontext-menu%2Fvue%2F/); + + await previewReady(page, "container"); + await expectGridRendered(page); + } finally { + await tracked.cleanup(request); + } + }); + + test("an Angular docs example renders, and its HMR websocket connects", async ({ page, request }) => { + const consoleLines: string[] = []; + page.on("console", (m) => consoleLines.push(`${m.type()}: ${m.text()}`)); + + const tracked = trackSessions(page); + try { + await page.goto(ANGULAR_DOCS); + await previewReady(page, "container"); + await expectGridRendered(page); + + // The HMR socket must be *connected*, not merely quiet: this spec's + // first prod run found the proxy refusing the `vite-hmr` upgrade with a + // 400 (vite gates it on `server.allowedHosts`; fixed by DEV-2541), and + // the defect stayed invisible for three days precisely because the page + // rendered and only the console knew. A boot that never reaches the HMR + // client at all would silently pass an errors-only check the same way. + await expect(async () => { + expect( + consoleLines.some((l) => /\[vite\] connected/.test(l)), + "the dev server's HMR client reported [vite] connected", + ).toBe(true); + }).toPass({ timeout: 60_000 }); + + // Angular's dev server is the only one that type-checks, so a broken + // generated file fails *silently* by serving the last good bundle + // (DEV-2216) — a clean console is part of "it rendered". + const real = consoleLines.filter((l) => l.startsWith("error:")).filter((e) => !isKnownNoise(e)); + expect(real, `console errors:\n${real.join("\n")}`).toEqual([]); + } finally { + await tracked.cleanup(request); + } + }); +}); diff --git a/runner/e2e/editor-download.spec.ts b/runner/e2e/editor-download.spec.ts new file mode 100644 index 00000000..98515df9 --- /dev/null +++ b/runner/e2e/editor-download.spec.ts @@ -0,0 +1,81 @@ +import { readFileSync } from "node:fs"; +import { test, expect, type Page } from "@playwright/test"; +import { strFromU8, unzipSync } from "fflate"; +import { activeEditor, expectGridRendered, previewReady, stubShell } from "./helpers"; + +// The two editor promises nothing was proving (DEV-2203): +// +// 1. Download hands over the workspace *as edited*. In play and share modes the +// zip is the only way out with your changes — the button even highlights to +// say so — yet no test ever opened one. The zip is built client-side with +// fflate (App.tsx downloadWorkspaceZip), so this is deterministic: no +// bundler, no API, runs in PR CI. +// +// 2. A plain content edit reaches the rendered grid. preview-recovery.spec.ts +// proves error → recovery round-trips and style-apply.spec.ts proves theme +// modules land, but "type a thing, see the thing" — the whole point of the +// editor — was only ever implied. Needs the live bundler, so E2E_LIVE. + +const MARKER = "// e2e-download-marker"; + +/** Insert text at the top of the visible editor through CodeMirror's own + * dispatch — `.cm-content` is contenteditable but virtualised, so typing via + * the keyboard depends on scroll position while a dispatch does not. */ +async function insertAtTop(page: Page, text: string) { + await activeEditor(page).waitFor(); + await page.evaluate(`(() => { + const view = document.querySelector('[data-pane-active="true"] .cm-content').cmTile.view; + view.dispatch({ changes: { from: 0, insert: ${JSON.stringify(text + "\n")} } }); + })()`); +} + +test("Download zips the workspace including an unsaved edit", async ({ page }) => { + await stubShell(page); + await page.goto("/?example=react"); + await insertAtTop(page, MARKER); + + // The edit marks the workspace dirty, so the button gains its "•" nudge — + // waiting for it doubles as "the edit reached the files state". + const download1 = page.waitForEvent("download"); + await page.getByRole("button", { name: /^Download( •)?$/ }).click(); + const download = await download1; + + expect(download.suggestedFilename()).toBe("react-vite-ts.zip"); + + const zipPath = await download.path(); + const entries = unzipSync(readFileSync(zipPath!)); + + // Paths in the zip lose their leading slash — `/src/index.tsx` unzips to a + // real relative path, not a root-anchored one. + const paths = Object.keys(entries); + expect(paths).toContain("src/index.tsx"); + expect(paths).toContain("package.json"); + expect(paths.some((p) => p.startsWith("/"))).toBe(false); + + expect(strFromU8(entries["src/index.tsx"])).toContain(MARKER); +}); + +test("an edit to the example reaches the rendered grid", async ({ page }) => { + test.skip(process.env.E2E_LIVE !== "1", "set E2E_LIVE=1 to run live-render checks"); + test.setTimeout(240_000); + + await page.goto("/?example=react"); + await previewReady(page, "sandpack"); + await expectGridRendered(page); + + // Rename the first column header. A header is asserted by its text, so the + // check cannot pass by accident the way a data cell's value could. + await activeEditor(page).waitFor(); + await page.evaluate(`(() => { + const view = document.querySelector('[data-pane-active="true"] .cm-content').cmTile.view; + const doc = view.state.doc.toString(); + const at = doc.indexOf("'Company name'"); + if (at < 0) throw new Error("fixture changed: 'Company name' not found in the react starter"); + view.dispatch({ changes: { from: at, to: at + "'Company name'".length, insert: "'E2E header'" } }); + })()`); + + const renamed = page.frameLocator('iframe[title="Demo preview"]').locator("th", { hasText: "E2E header" }); + await expect(async () => { + expect(await renamed.count()).toBeGreaterThan(0); + }).toPass({ timeout: 90_000 }); +}); diff --git a/runner/e2e/engine-smoke.spec.ts b/runner/e2e/engine-smoke.spec.ts new file mode 100644 index 00000000..a94fd203 --- /dev/null +++ b/runner/e2e/engine-smoke.spec.ts @@ -0,0 +1,59 @@ +import { test, expect } from "@playwright/test"; +import { expectGridRendered, previewReady, trackSessions } from "./helpers"; + +// One render per engine (DEV-2203) — the smallest set that still proves both +// halves of the runtime can put a grid on screen. +// +// The starter matrix covers all 19 starters × 5 majors, but it is a manual, +// three-hour workflow. These two tests are the @smoke slice: cheap enough to +// run after every deploy, wide enough that "Sandpack broke" or "containers +// broke" cannot both hide. react-js is the container case on purpose — it is +// a Tier-1 example that ships engine: "container" (the five UI-library +// starters share that shape), so it also pins the rule that `engine`, not +// `tier`, picks the runtime. + +test("a Sandpack starter renders a grid", { tag: "@smoke" }, async ({ page }) => { + test.skip(process.env.E2E_LIVE !== "1", "set E2E_LIVE=1 to run live-render checks"); + test.setTimeout(240_000); + + await page.goto("/?example=react"); + await previewReady(page, "sandpack"); + await expectGridRendered(page); +}); + +test("a container starter boots and renders a grid at the requested version", { tag: "@smoke" }, async ({ + page, + request, +}) => { + test.skip(process.env.E2E_LIVE !== "1", "set E2E_LIVE=1 to run live-render checks"); + test.skip(!process.env.E2E_BASE_URL, "containers need a deployed API origin — vite preview has no /api proxy"); + test.setTimeout(300_000); + + let postedHtVersion: string | null = null; + page.on("request", (req) => { + if (req.method() === "POST" && /\/api\/session$/.test(req.url())) { + try { + postedHtVersion = (JSON.parse(req.postData() ?? "{}") as { htVersion?: string }).htVersion ?? null; + } catch { + // malformed payload just leaves the assertion below to fail + } + } + }); + + const tracked = trackSessions(page); + try { + await page.goto("/?example=react-js"); + await previewReady(page, "container"); + await expectGridRendered(page); + // "At the requested version" needs a real oracle, not non-null: with no + // ?v= in the URL the app must resolve /api/versions' latest, so ask the + // same endpoint and compare. not.toBeNull() stayed green when version + // resolution regressed to a stale hardcoded default (audit, DEV-2203). + const versions = (await (await request.get(`${process.env.E2E_BASE_URL}/api/versions`)).json()) as { + latest?: string; + }; + expect(postedHtVersion, "the session was asked for the resolved latest version").toBe(versions.latest); + } finally { + await tracked.cleanup(request); + } +}); diff --git a/runner/e2e/helpers.ts b/runner/e2e/helpers.ts new file mode 100644 index 00000000..b8b4c5f7 --- /dev/null +++ b/runner/e2e/helpers.ts @@ -0,0 +1,135 @@ +import { expect, type APIRequestContext, type Page } from "@playwright/test"; + +// Shared helpers for the e2e suite (DEV-2203). +// +// Everything here existed first as copy-paste: `stubShell` and `signIn` were +// declared verbatim in nine specs, the session-cleanup pattern in two, the +// noise filter in one. New specs import from here; existing specs keep their +// local copies and migrate opportunistically — a mass rewrite would churn +// thousands of spec lines for zero behaviour change. +// +// The file is not matched by `*.spec.ts`, so Playwright never collects it. + +export const EMAIL = "dev@handsontable.com"; + +/** + * The deterministic-shell recipe: a stubbed version list, both Sandpack hosts + * aborted (no external bundler, no grid — the shell renders fine without one), + * and the login redirect neutered so a stray click cannot leave the app. + */ +export async function stubShell(page: Page) { + await page.route("**/api/versions", (route) => + route.fulfill({ json: { latest: "18.0.0", next: "19.0.0-next.1", versions: ["18.0.0", "17.1.0"] } }), + ); + await page.route("https://sandpack.codesandbox.io/**", (route) => route.abort()); + await page.route("https://sandpack-bundler.codesandbox.io/**", (route) => route.abort()); + await page.route("**/broker/login**", (route) => route.abort()); +} + +/** + * Sign-in faked at the token layer — the app reads `sessionStorage.hot_token` + * and asks the broker who that is. Faking here rather than via `VITE_DEV_USER` + * keeps the production auth path in play (see sidebar-crud.spec.ts for the full + * argument). Never add storage *clears* to an init script: it runs on + * `page.reload()` too and silently defeats persistence tests (AGENTS.md). + */ +export async function signIn(page: Page, email: string = EMAIL) { + await page.addInitScript(() => sessionStorage.setItem("hot_token", "e2e-token")); + await page.route("**/broker/userinfo", (route) => route.fulfill({ json: { email } })); +} + +/** + * The visible editor pane. Scoped since T12 (DEV-2169): every open tab keeps + * its own CodeMirror instance mounted, so a bare `.cm-content` trips strict + * mode as soon as a test opens a second file. + * + * For *reading file contents*, prefer `workspaceFiles()` — CodeMirror + * virtualises long documents, so `.cm-content` only holds the lines on screen. + */ +export function activeEditor(page: Page) { + return page.locator('[data-pane-active="true"] .cm-content'); +} + +/** + * The workspace files, via the `window.__HOT_FILES__` test contract + * (apps/authoring/src/App.tsx). Poll with `expect(...).toPass()` when the + * write you are waiting for rides the Style panel's 250 ms debounce. + */ +export function workspaceFiles(page: Page): Promise> { + return page.evaluate(() => { + const hook = (window as unknown as { __HOT_FILES__?: () => Record }).__HOT_FILES__; + if (!hook) throw new Error("window.__HOT_FILES__ is not installed — is the app older than DEV-2203?"); + return hook(); + }); +} + +/** + * The version and framework pickers are custom listboxes in the preview bar + * (T2, DEV-2156) — `selectOption` does not apply. Open the trigger, click the + * option. + */ +export async function pickFromMenu(page: Page, menu: "Handsontable version" | "Framework", option: string) { + await page.getByRole("button", { name: menu, exact: true }).click(); + await page.getByRole("option", { name: option, exact: true }).click(); +} + +/** + * Wait for the preview to declare itself ready. Readiness comes off + * `data-preview-status` on the preview section (PreviewPane.tsx — a documented + * test contract), never off visible text. For containers, "ready" only means + * the dev server responded; follow up with `expectGridRendered` before + * asserting anything about the demo itself. + */ +export async function previewReady(page: Page, engine: "sandpack" | "container" = "sandpack") { + await expect(page.locator('section[aria-label="Preview"]')).toHaveAttribute("data-preview-status", "ready", { + timeout: engine === "container" ? 240_000 : 120_000, + }); +} + +/** The rendered grid inside the preview frame — the real functional check. */ +export function gridCells(page: Page) { + return page.frameLocator('iframe[title="Demo preview"]').locator(".handsontable .htCore td"); +} + +export async function expectGridRendered(page: Page) { + await expect + .poll(async () => gridCells(page).count().catch(() => 0), { timeout: 60_000, intervals: [1_000] }) + .toBeGreaterThan(0); +} + +// Console/page noise that isn't a real break. Extend as live runs surface new +// false positives (same list as starter-matrix.spec.ts). +export const NOISE = [ + /non-commercial|evaluation license/i, + /Download the React DevTools/i, + /favicon\.ico/i, + /\[vite\] (connecting|connected)/i, + /ERR_BLOCKED_BY_CLIENT|third-party cookie/i, +]; +export const isKnownNoise = (message: string) => NOISE.some((re) => re.test(message)); + +/** + * Track Tier-2 sessions a test creates so they can be torn down even when the + * test fails: the container pool holds five global slots shared with real + * traffic, and a leaked session squats one for its whole idle window. + * + * const tracked = trackSessions(page); + * try { ... } finally { await tracked.cleanup(request); } + */ +export function trackSessions(page: Page) { + const sessions: { id: string; apiBase: string }[] = []; + page.on("response", async (res) => { + if (res.request().method() === "POST" && /\/api\/session$/.test(res.url()) && res.ok()) { + const body = (await res.json().catch(() => null)) as { sessionId?: string } | null; + if (body?.sessionId) sessions.push({ id: body.sessionId, apiBase: new URL(res.url()).origin }); + } + }); + return { + sessions, + async cleanup(request: APIRequestContext) { + for (const { id, apiBase } of sessions) { + await request.delete(`${apiBase}/api/session/${id}`).catch(() => {}); + } + }, + }; +} diff --git a/runner/e2e/share-view.spec.ts b/runner/e2e/share-view.spec.ts new file mode 100644 index 00000000..fd53054f --- /dev/null +++ b/runner/e2e/share-view.spec.ts @@ -0,0 +1,83 @@ +import { test, expect } from "@playwright/test"; + +// The share viewer, read from the outside (DEV-2203). +// +// `/d/:id` is what a client actually receives when someone shares a demo: a +// prebuilt static page served from R2 by the API worker, framed by `/share` +// and `/embed`. Everything the app-side specs prove happens *before* this +// point — `authed-actions.spec.ts` stubs `/api/demos` and never leaves the +// SPA. Nothing exercised the deployed contract: the redirect, the frame +// policies that make `/embed` docs-only, and the promise that a share never +// leaks its source snapshot. +// +// Read-only on purpose. These tests hit a permanent fixture demo, so they run +// against production with zero containers, zero writes and zero flake budget — +// cheap enough for the post-deploy smoke. The authed write path (create → +// build → view → revoke) is `share-create-live.spec.ts`, which needs a real +// broker token and is gated separately. +// +// E2E_BASE_URL=https://demos.handsontable.com pnpm e2e e2e/share-view.spec.ts +// +// FIXTURE_ID names a demo that must keep existing. `r-react-18-0-0` is the +// IT-540 launch-verification share; if it is ever revoked, mint a replacement +// titled "E2E fixture — do not revoke" from any signed-in session and update +// the constant (see AGENTS.md § E2E). + +const FIXTURE_ID = "r-react-18-0-0"; + +test.describe("share viewer — /d and /embed", () => { + test.skip(!process.env.E2E_BASE_URL, "needs a deployed API origin — vite preview has no /api or /d routes"); + + test("the built demo renders for an anonymous viewer", { tag: "@smoke" }, async ({ page }) => { + // The static page is the demo itself — no editor shell, no preview iframe. + await page.goto(`/d/${FIXTURE_ID}/`); + await expect + .poll(async () => page.locator(".handsontable .htCore td").count(), { timeout: 30_000 }) + .toBeGreaterThan(0); + }); + + test("the slashless URL redirects permanently instead of serving a copy", async ({ request, baseURL }) => { + const res = await request.get(`${baseURL}/d/${FIXTURE_ID}`, { maxRedirects: 0 }); + expect(res.status()).toBe(308); + expect(res.headers()["location"]).toContain(`/d/${FIXTURE_ID}/`); + }); + + test("the viewer is frame-locked to itself", async ({ request, baseURL }) => { + const res = await request.get(`${baseURL}/d/${FIXTURE_ID}/`); + expect(res.status()).toBe(200); + expect(res.headers()["content-security-policy"]).toContain("frame-ancestors 'self'"); + expect(res.headers()["x-frame-options"]).toBe("SAMEORIGIN"); + // HTML must revalidate so a re-shared id can never serve a stale build. + expect(res.headers()["cache-control"]).toContain("must-revalidate"); + }); + + test("the embed is docs-only: handsontable.com may frame it, nobody may fetch it cross-origin", async ({ + request, + baseURL, + }) => { + const res = await request.get(`${baseURL}/embed/${FIXTURE_ID}/`); + expect(res.status()).toBe(200); + const csp = res.headers()["content-security-policy"] ?? ""; + expect(csp).toContain("frame-ancestors"); + expect(csp).toContain("https://handsontable.com"); + expect(csp).toContain("https://*.handsontable.com"); + // `frame-ancestors` with an allowlist is incompatible with X-Frame-Options — + // the worker must not send both, or the stricter header wins in old engines. + expect(res.headers()["x-frame-options"]).toBeUndefined(); + // /embed is deliberately outside the cors() wrap: framing is allowed, + // fetching from another origin is not. + expect(res.headers()["access-control-allow-origin"]).toBeUndefined(); + }); + + test("the source snapshot behind a share is never served", async ({ request, baseURL }) => { + // __source.json sits in R2 next to the built assets; the worker refuses + // any `__`-prefixed segment so the un-built files cannot leak. + const res = await request.get(`${baseURL}/d/${FIXTURE_ID}/__source.json`); + expect(res.status()).toBe(404); + }); + + test("an unknown id is a 404, not an error page with a 200 on it", async ({ request, baseURL }) => { + const res = await request.get(`${baseURL}/d/zzzznope00/`); + expect(res.status()).toBe(404); + }); +}); diff --git a/runner/e2e/style-apply.spec.ts b/runner/e2e/style-apply.spec.ts index 1f886d59..7c2325a3 100644 --- a/runner/e2e/style-apply.spec.ts +++ b/runner/e2e/style-apply.spec.ts @@ -199,7 +199,9 @@ for (const { example, shape } of SHAPES) { test.skip(process.env.E2E_LIVE !== "1", "set E2E_LIVE=1 to run live-render checks"); test.describe.configure({ timeout: 300_000 }); - test("a theme applies to the grid, and Reset takes it back off", async ({ page }) => { + // @smoke on react only: the post-deploy subset (DEV-2203) wants one Style + // apply+reset round-trip, and react is the Tier-1 shape that needs no container. + test("a theme applies to the grid, and Reset takes it back off", { tag: example === "react" ? ["@smoke"] : [] }, async ({ page }) => { // Every warning the demo logs, so the alias check below sees the whole run. const console_: string[] = []; page.on("console", (message) => console_.push(message.text())); diff --git a/runner/e2e/version-pinning.spec.ts b/runner/e2e/version-pinning.spec.ts new file mode 100644 index 00000000..1e109e28 --- /dev/null +++ b/runner/e2e/version-pinning.spec.ts @@ -0,0 +1,119 @@ +import { test, expect, type Page } from "@playwright/test"; +import { activeEditor, expectGridRendered, previewReady, stubShell, trackSessions } from "./helpers"; + +// Version dispatch, end to end (DEV-2203, groundwork for DEV-2198 PR previews). +// +// pipeline/version.test.mjs proves the rewrite rules on file maps and +// starter-matrix.spec.ts proves live per-major rendering, but nothing walked +// the URL → pin → workspace path a PR-preview link will actually take: paste +// `?v=`, and the *open workspace's* package.json now pins core and wrapper in +// lockstep. The three deterministic tests below run in PR CI; the live one +// proves the newest -next build still installs through the real bundler. +// +// pkg.pr.new refs are deliberately never hardcoded live: builds expire per +// commit, `/api/versions/exists` only vouches for npm, and Sandpack cannot +// install URL tarballs at all (containers only). The mechanism is pinned +// deterministically instead, and E2E_PKG_PR_NEW_REF lets a DEV-2198 +// validation run point one real container at a fresh build id on demand. + +/** Open a root file through its tree row and hand back the visible editor. + * package.json is short enough to sit fully inside CodeMirror's viewport, so + * reading `.cm-content` is safe here — do not copy this pattern for long + * files (the virtualisation trap that killed the first style-panel draft). */ +async function openRootFile(page: Page, path: string) { + await page.locator(`.hot-file-row:has(> button[title="${path}"])`).locator(`button[title="${path}"]`).click(); + return activeEditor(page); +} + +test("a semver deep link pins core and wrapper in lockstep", async ({ page }) => { + await stubShell(page); + await page.goto("/?example=react&v=17.1.0"); + + const editor = await openRootFile(page, "/package.json"); + // Pinning happens when the bucket artifact lands, so poll rather than sample. + await expect(editor).toContainText('"handsontable": "17.1.0"'); + await expect(editor).toContainText('"@handsontable/react-wrapper": "17.1.0"'); +}); + +test("a pkg.pr.new build id rewrites every Handsontable dependency to a tarball URL", async ({ page }) => { + await stubShell(page); + // A bare id ≥ 1000 reads as a pkg.pr.new build ref and resolves the `next` + // starter bucket (there is no semver to derive a bucket from). + await page.goto("/?example=react&v=7940"); + + const editor = await openRootFile(page, "/package.json"); + await expect(editor).toContainText('"handsontable": "https://pkg.pr.new/handsontable@7940"'); + await expect(editor).toContainText('"@handsontable/react-wrapper": "https://pkg.pr.new/@handsontable/react-wrapper@7940"'); +}); + +test("a pkg.pr.new ref reaches the container session payload", async ({ page }) => { + await stubShell(page); + + // react-js is engine: container, so opening it posts /api/session. Refusing + // the session keeps the test deterministic — the payload is the assertion. + const posted: string[] = []; + await page.route("**/api/session", async (route) => { + posted.push(route.request().postData() ?? ""); + await route.fulfill({ status: 503, json: { error: "e2e: refused on purpose" } }); + }); + + await page.goto("/?example=react-js&v=7940"); + + await expect(async () => { + expect(posted.length).toBeGreaterThan(0); + const { htVersion } = JSON.parse(posted[0]) as { htVersion?: string }; + expect(htVersion, "the validated ref, not the raw URL param, travels to the container").toBe("7940"); + }).toPass({ timeout: 30_000 }); +}); + +test("the newest -next build installs and renders", async ({ page, request, baseURL }) => { + test.skip(process.env.E2E_LIVE !== "1", "set E2E_LIVE=1 to run live-render checks"); + test.skip(!process.env.E2E_BASE_URL, "needs a deployed /api/versions — vite preview has no API proxy"); + test.setTimeout(240_000); + + const versions = (await (await request.get(`${baseURL}/api/versions`)).json()) as { next?: string }; + test.skip(!versions.next, "no -next build is published right now"); + + const htRequests: string[] = []; + page.on("request", (req) => { + if (req.url().includes(`handsontable`) && req.url().includes(versions.next!)) htRequests.push(req.url()); + }); + + await page.goto(`/?example=react&v=${encodeURIComponent(versions.next!)}`); + await previewReady(page, "sandpack"); + await expectGridRendered(page); + expect(htRequests.length, `the bundler asked for handsontable@${versions.next}`).toBeGreaterThan(0); +}); + +test("a fresh pkg.pr.new build boots a real container at that ref", async ({ page, request }) => { + const ref = process.env.E2E_PKG_PR_NEW_REF; + test.skip(!ref, "set E2E_PKG_PR_NEW_REF= to verify a pkg.pr.new build end to end (DEV-2198)"); + test.skip(!process.env.E2E_BASE_URL, "needs a deployed API origin"); + test.setTimeout(300_000); + + // The ref must be *proven* to reach the session, not assumed: without this, + // deleting the ?v= pkg.pr.new dispatch entirely would leave react-js booting + // at DEFAULT_VERSION and the render assertions green (audit, DEV-2203). The + // install itself happens server-side in the container, so the session + // payload is the observable seam — the browser never fetches pkg.pr.new. + let postedHtVersion: string | null = null; + page.on("request", (req) => { + if (req.method() === "POST" && /\/api\/session$/.test(req.url())) { + try { + postedHtVersion = (JSON.parse(req.postData() ?? "{}") as { htVersion?: string }).htVersion ?? null; + } catch { + // malformed payload just leaves the assertion below to fail + } + } + }); + + const tracked = trackSessions(page); + try { + await page.goto(`/?example=react-js&v=${encodeURIComponent(ref!)}`); + await previewReady(page, "container"); + await expectGridRendered(page); + expect(postedHtVersion, "the validated pkg.pr.new ref reached the container session").toBe(ref); + } finally { + await tracked.cleanup(request); + } +}); diff --git a/runner/package.json b/runner/package.json index f03cc1dc..620421cd 100644 --- a/runner/package.json +++ b/runner/package.json @@ -20,10 +20,10 @@ "e2e:matrix:report": "node scripts/starter-matrix-report.mjs" }, "devDependencies": { - "@playwright/test": "^1.48.0", + "@playwright/test": "1.61.1", "acorn": "^8.18.0", "fflate": "^0.8.2", - "typescript": "~5.6.0", +"typescript": "~5.6.0", "vite5": "npm:vite@5.4.21" } } diff --git a/runner/playwright.config.ts b/runner/playwright.config.ts index e2d056c8..819e858b 100644 --- a/runner/playwright.config.ts +++ b/runner/playwright.config.ts @@ -12,7 +12,10 @@ export default defineConfig({ expect: { timeout: 15_000 }, fullyParallel: true, retries: process.env.CI ? 2 : 0, - reporter: process.env.CI ? "github" : "list", + // In CI, "github" alone leaves `playwright-report/` empty — the workflows + // upload that directory on failure, and until DEV-2203 the artifact was a + // no-op. The html reporter fills it; traces land in `test-results/`. + reporter: process.env.CI ? [["list"], ["github"], ["html", { open: "never" }]] : "list", use: { baseURL, trace: "on-first-retry" }, projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], // Start a local preview only when not pointing at an external URL. diff --git a/runner/pnpm-lock.yaml b/runner/pnpm-lock.yaml index 09a781af..5eb8b001 100644 --- a/runner/pnpm-lock.yaml +++ b/runner/pnpm-lock.yaml @@ -9,7 +9,7 @@ importers: .: devDependencies: '@playwright/test': - specifier: ^1.48.0 + specifier: 1.61.1 version: 1.61.1 acorn: specifier: ^8.18.0