From 443004cee9e3b6b875a107e589e7b0620f685f0a Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Sat, 5 Sep 2026 11:41:19 +0200 Subject: [PATCH 1/2] ci(e2e): run the Playwright suite on pull requests, sharded four ways The suite was excluded from CI because a serial run took most of an hour and timed out unpredictably. Nothing has gated it since, which is how three specs rotted on main behind security fixes nobody saw break them (#492). Each shard boots its own e2e:dev stack on its own disposable SQLite database, so the shards share no state and the setup projects run once per shard. Measured locally with CI=1: shard 1 of 4 is 68 tests in 6 minutes, against 15.6 minutes for the whole suite serially. It runs as its own workflow so a browser run never delays the build, lint and test feedback a PR needs first. forbidOnly is on under CI, so a stray .only can no longer green the suite by running a single test. Reports and traces upload as artifacts when a shard fails. --- .github/workflows/ci.yml | 9 +++--- .github/workflows/e2e.yml | 60 +++++++++++++++++++++++++++++++++++++++ playwright.config.ts | 3 ++ 3 files changed, 67 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/e2e.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d362178d..5275ff0d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,8 +72,7 @@ jobs: - name: Test run: bun test -# NOTE: The Playwright E2E suite is intentionally NOT run in CI yet. As -# configured (`workers: 1`, fully serial, 156 tests) it takes ~45-60 min and is -# timeout-fragile against the un-optimized dev server, so it cannot gate PRs -# reliably today. Run it locally with `bun run test:e2e`. It will be reintroduced -# as its own workflow once the suite is sharded/parallelized and stabilized. +# The Playwright E2E suite runs in its own workflow (.github/workflows/e2e.yml), +# sharded four ways so each shard is a few minutes rather than one serial hour. +# It is kept separate from this workflow so a slow browser run never delays the +# build/lint/test feedback a PR needs first. diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 000000000..f4e7c15a0 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,60 @@ +name: E2E + +on: + pull_request: + branches: + - main + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: e2e-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + e2e: + name: Playwright (shard ${{ matrix.shard }}/4) + runs-on: ubuntu-latest + # A shard is ~4-6 min of tests plus the per-shard setup projects; the cap is + # slack for a slow runner, not a target. + timeout-minutes: 30 + strategy: + # One red shard should not hide the state of the other three. + fail-fast: false + matrix: + shard: [1, 2, 3, 4] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.11 + + - name: Install dependencies + run: bun install --frozen-lockfile + + # Chromium only — the suite pins one browser (see playwright.config.ts). + - name: Install Playwright browser + run: bunx playwright install --with-deps chromium + + # Each shard boots its own `e2e:dev` stack on its own disposable SQLite + # database, so the shards share no state and the setup projects + # (owner, dashboard preflight, personas) run once per shard. + - name: Run E2E + run: bunx playwright test --shard=${{ matrix.shard }}/4 + + - name: Upload report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report-shard-${{ matrix.shard }} + path: | + .tmp/playwright-report + .tmp/playwright-results + retention-days: 7 diff --git a/playwright.config.ts b/playwright.config.ts index c63068bb0..9c9ae3b29 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -16,6 +16,9 @@ export default defineConfig({ expect: { timeout: 10_000, }, + // A stray `.only` would otherwise green the whole suite in CI by running + // one test. + forbidOnly: Boolean(process.env.CI), retries: process.env.CI ? 1 : 0, reporter: [ ['list'], From d5d4331402091d30de4b492408df1206c93265b6 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Sat, 5 Sep 2026 14:37:42 +0200 Subject: [PATCH 2/2] ci(e2e): run the suite against the built SPA on one origin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first Actions run failed on every shard the same way: the Bun CMS came up, Playwright waited on the Vite dev server at :5174, and Vite printed nothing for two minutes before taking SIGTERM still alive. The dev server hangs on Linux runners under Bun. A cold start here is 267 ms, so nothing local could have caught it. Rather than nurse the dev server through CI, the E2E stack now builds the admin SPA and serves it from the same Bun process that serves the published site. No Vite dev server, no second port, no dev-only proxy plugins in the loop. `vite build` is a batch step and already passes on Linux CI on every PR; it was only the long-lived dev server that did not. The suite now exercises the bundle users actually get. One origin is safe by design: the session cookie is `Path=/admin`, and `visitPublicPage` opens a fresh browser context, so anonymity never depended on the port. The public specs were already hitting Bun on :3002 directly; only the admin moves, from Vite-served to dist-served. `scripts/e2e-dev.ts` is replaced by `scripts/e2e-server.ts` (`bun run e2e:serve`), the Playwright `webServer` budget covers the build, `tests/e2e/helpers/constants.ts` exports one `ADMIN_BASE_URL` that `account.e2e.ts` now imports instead of duplicating, and the `devWorkflow` gate test asserts the new shape. Running the whole suite in one process for the first time also surfaced a shared-state leak that sharding had hidden: `content-seo-meta` publishes a Posts template at priority 300 and never removes it, and the template chain keeps one template per breadth level, so CONTENT-008 (priority 150) and SITE-018 (a 300 that loses the tie on document order) rendered through the wrong template whenever that spec ran first. A shared `deleteTemplate` helper now removes every Posts template a spec publishes before the spec ends, in all five places one is created. Verification (local, Chromium, new harness): bunx playwright test --project=setup 1 passed content-seo-meta + content + visual-builder 38 passed (the leak ordering) bunx playwright test --shard=1/4 68 passed bunx playwright test --shard=2/4 29 passed (was 1 failed before the cleanup) bunx playwright test --shard=3/4 37 passed bunx playwright test --shard=4/4 44 passed full suite, one process 161 passed, 1 failed, 1 flaky failed: CONTENT-009, the client-side untitled-slug pick (product bug, tracked separately); flaky: SITE-005 drag (timing, passes on retry) bunx eslint tests/e2e/… clean bunx tsc -p tests/e2e/tsconfig.json --noEmit clean --- .github/workflows/e2e.yml | 6 ++- docs/e2e/README.md | 42 +++++++++------ package.json | 2 +- playwright.config.ts | 13 +++-- scripts/e2e-dev.ts | 75 -------------------------- scripts/e2e-server.ts | 90 +++++++++++++++++++++++++++++++ src/__tests__/devWorkflow.test.ts | 12 +++-- tests/e2e/account.e2e.ts | 2 +- tests/e2e/content-seo-meta.e2e.ts | 8 +++ tests/e2e/content.e2e.ts | 16 ++++++ tests/e2e/helpers/constants.ts | 27 +++++++--- tests/e2e/helpers/editor.ts | 24 +++++++++ tests/e2e/visual-builder.e2e.ts | 6 +++ 13 files changed, 211 insertions(+), 112 deletions(-) delete mode 100644 scripts/e2e-dev.ts create mode 100644 scripts/e2e-server.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index f4e7c15a0..e67a1c88b 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -43,9 +43,11 @@ jobs: - name: Install Playwright browser run: bunx playwright install --with-deps chromium - # Each shard boots its own `e2e:dev` stack on its own disposable SQLite + # Each shard boots its own `e2e:serve` stack on its own disposable SQLite # database, so the shards share no state and the setup projects - # (owner, dashboard preflight, personas) run once per shard. + # (owner, dashboard preflight, personas) run once per shard. That command + # builds the SPA and serves it from the Bun process -- no Vite dev + # server, which hangs on these runners. - name: Run E2E run: bunx playwright test --shard=${{ matrix.shard }}/4 diff --git a/docs/e2e/README.md b/docs/e2e/README.md index 21c512ca0..5e5ca0ab2 100644 --- a/docs/e2e/README.md +++ b/docs/e2e/README.md @@ -42,27 +42,29 @@ bun run test:e2e The Playwright config starts a disposable local stack by default: -- Admin UI: `http://127.0.0.1:5174` -- CMS/public site: `http://127.0.0.1:3002` +- Admin UI **and** public site: `http://127.0.0.1:3002` - Database: `.tmp/e2e-agent.db` - Uploads: `.tmp/e2e-uploads` -`scripts/e2e-dev.ts` resets only those `.tmp/e2e-*` paths, then runs the same -Vite + Bun CMS stack a developer uses — with one deliberate difference: the CMS -runs **without** `bun --watch`. A regression suite needs a stable server, and -under watch the publish pipeline writing baked HTML (and the SQLite DB churning) -can reload the server mid-test and drop in-memory state. Vite is likewise told to -ignore the runtime-written paths (`.tmp`, `uploads`, `dist` in `vite.config.ts`), -so publishing never reloads the admin app mid-test. The Vite dev proxy follows -the configured CMS `PORT`, keeping the Playwright admin UI pointed at the -disposable CMS instead of any regular dev server on port 3001. +`scripts/e2e-server.ts` resets only those `.tmp/e2e-*` paths, builds the admin +SPA, and serves it from the same Bun process that serves the published site. +That is **not** the `bun run dev` stack, on purpose: -When Vite itself runs on Bun, its Node-compatible native proxy can stop -draining multi-megabyte request bodies after socket backpressure fills both -sides. `largeBodyDevProxyPlugin` intercepts only known-length CMS API bodies of -at least 1 MiB, buffers them with the same 128 MiB ceiling as `Bun.serve`, and -forwards them with an explicit `Content-Length`. Small requests, non-CMS -traffic, and streaming AI responses remain on Vite's native proxy. +- **No Vite.** The suite exercises the bundle users actually get. It also keeps + the suite off the Vite dev server running inside Bun, which is fragile enough + to carry its own warnings in `vite.config.ts` and which hangs outright on + Linux CI runners. `vite build` is a batch step and runs there fine — it is + the long-lived dev server that does not. +- **No `--watch`.** A regression suite needs a stable server: under watch, the + publish pipeline writing baked HTML (and the SQLite DB churning) reloads the + server mid-test and drops in-memory state. +- **One origin.** Admin and the public site share a port, so no two base URLs + can drift apart. Anonymous visitor checks stay honest because + `visitPublicPage` opens a fresh browser context, and the session cookie is + scoped to `Path=/admin` so it never rides along on a public request. + +Because the command builds before it serves, the `webServer` start budget is +five minutes rather than the usual two. For debugging against a server you started yourself, set `E2E_REUSE_SERVER=1` and override `E2E_ADMIN_BASE_URL` / @@ -106,6 +108,12 @@ first retry automatically. homepage), clean-install dashboard coverage runs before shared mutations, account-global security changes stay on the account persona, fixture-owned AI defaults are removed, and publish→assert happens within a single test. +- **Template rule.** A spec that publishes a Posts template removes it before + it ends (`deleteTemplate` in `helpers/editor.ts`). The template chain keeps + one template per breadth level — highest priority, then document order — so a + template left behind decides which template every later spec's entry route + renders through. Sharding hides this by splitting specs across databases; a + full single-process run does not, and that is the run that must be green. ### Automated coverage map diff --git a/package.json b/package.json index 9fee8320d..8b0289240 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "dev:agent": "bun run dev:server", "dev:server": "bun --watch server/index.ts", "dev:vite": "bun run scripts/vite.ts", - "e2e:dev": "bun run scripts/e2e-dev.ts", + "e2e:serve": "bun run scripts/e2e-server.ts", "db:drop": "bun run scripts/db-drop.ts", "start": "bun run scripts/start.ts", "docker:up": "docker compose -f compose.prod.yml -f compose.build.yml up --build", diff --git a/playwright.config.ts b/playwright.config.ts index 9c9ae3b29..507ffc7af 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,8 +1,11 @@ import { defineConfig } from '@playwright/test' import { OWNER_STATE_FILE } from './tests/e2e/helpers/constants' -const ADMIN_BASE_URL = process.env.E2E_ADMIN_BASE_URL ?? 'http://127.0.0.1:5174' -process.env.E2E_PUBLIC_BASE_URL ??= 'http://127.0.0.1:3002' +// One origin. The suite runs against the built SPA served by the same Bun +// process that serves the published site (`scripts/e2e-server.ts`), so the +// admin and the public site share a base URL. +const ADMIN_BASE_URL = process.env.E2E_ADMIN_BASE_URL ?? 'http://127.0.0.1:3002' +process.env.E2E_PUBLIC_BASE_URL ??= ADMIN_BASE_URL const LOCAL_TRACE = process.env.E2E_TRACE === '1' const LOCAL_VIDEO = process.env.E2E_VIDEO === '1' @@ -64,10 +67,12 @@ export default defineConfig({ }, ], webServer: { - command: 'bun run e2e:dev', + command: 'bun run e2e:serve', url: ADMIN_BASE_URL, reuseExistingServer: process.env.E2E_REUSE_SERVER === '1', - timeout: 120_000, + // The command builds the SPA before it serves it, so this budget covers + // `tsc -b && vite build` on a cold runner, not just process startup. + timeout: 300_000, gracefulShutdown: { signal: 'SIGTERM', timeout: 500 }, stdout: 'pipe', stderr: 'pipe', diff --git a/scripts/e2e-dev.ts b/scripts/e2e-dev.ts deleted file mode 100644 index 770c47ecb..000000000 --- a/scripts/e2e-dev.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Disposable local server for automated browser E2E tests. - * - * This wrapper owns only the `.tmp/e2e-*` data used by Playwright. It resets - * that data, then runs the same Vite + Bun CMS stack a developer uses — with one - * deliberate difference: the CMS runs WITHOUT `--watch`. - * - * Why no watch: under `bun --watch`, the publish pipeline writing baked HTML into - * the uploads dir (and the SQLite DB churning) can trigger a server reload mid - * test, which drops in-memory state and tears the stack down. A regression suite - * needs a stable server, so E2E pins one. Vite is additionally told to ignore the - * runtime-written paths (see `vite.config.ts`), so publishing never reloads the - * admin app mid-test either. - */ -import { mkdir, rm } from 'node:fs/promises' -import { bunCommand, viteCommand } from './lib/bunCommand' -import { ensureDependencies } from './lib/ensureDependencies' - -const DATABASE_PATH = './.tmp/e2e-agent.db' -const UPLOADS_DIR = './.tmp/e2e-uploads' -const CMS_PORT = process.env.E2E_CMS_PORT ?? '3002' -const VITE_PORT = process.env.E2E_VITE_PORT ?? '5174' - -// Same guard as `bun run dev`: Playwright starts this stack right after a -// `git pull`, and a stale node_modules would otherwise surface as a CMS crash -// on its first import instead of a missing install. -await ensureDependencies((msg) => console.error(`[e2e-dev] ${msg}`)) - -await mkdir('./.tmp', { recursive: true }) -await rm(DATABASE_PATH, { force: true }) -await rm(`${DATABASE_PATH}-shm`, { force: true }) -await rm(`${DATABASE_PATH}-wal`, { force: true }) -await rm(UPLOADS_DIR, { force: true, recursive: true }) - -// Shared by both children: the CMS port drives the Vite dev proxy target, so the -// admin UI talks to this disposable CMS instead of any regular dev server. -const sharedEnv = { - ...process.env, - PORT: CMS_PORT, - DATABASE_URL: `sqlite:${DATABASE_PATH}`, - UPLOADS_DIR, -} - -const children: Bun.Subprocess[] = [] -let shuttingDown = false - -function stopChildren(signal: NodeJS.Signals = 'SIGTERM'): void { - shuttingDown = true - for (const child of children) { - if (child.exitCode === null) child.kill(signal) - } -} - -for (const command of [ - bunCommand('server/index.ts'), - viteCommand('--host', '127.0.0.1', '--port', VITE_PORT, '--strictPort'), -]) { - const child = Bun.spawn(command, { - env: sharedEnv, - stdin: 'inherit', - stdout: 'inherit', - stderr: 'inherit', - }) - children.push(child) - void child.exited.then((code) => { - if (shuttingDown) return - // One half of the stack died on its own — bring the other down and exit so - // Playwright sees the failure instead of half a stack. - stopChildren() - process.exit(code ?? 1) - }) -} - -process.on('SIGINT', () => stopChildren('SIGINT')) -process.on('SIGTERM', () => stopChildren('SIGTERM')) diff --git a/scripts/e2e-server.ts b/scripts/e2e-server.ts new file mode 100644 index 000000000..36ee9d1f8 --- /dev/null +++ b/scripts/e2e-server.ts @@ -0,0 +1,90 @@ +/** + * Disposable server for the Playwright suite. + * + * Builds the admin SPA, then serves it from the same Bun process that serves + * the published site — ONE origin, exactly the shape a self-hosted install + * runs in. That is deliberately not the `bun run dev` stack: + * + * - **No Vite.** The suite exercises the bundle users actually get, not a + * dev server's on-the-fly transforms. It also removes a dependency on the + * Vite dev server surviving inside Bun, which is fragile enough that the + * dev proxy already carries its own warning (see `vite.config.ts`) and + * which hangs outright on Linux CI runners. `vite build` is a batch step + * and runs fine there — it is the long-lived dev server that does not. + * - **No `--watch`.** The publish pipeline writes baked HTML into the uploads + * dir and churns the SQLite DB; under watch that reloads the server mid + * test and drops in-memory state. + * - **One origin.** Admin and the public site share a port, so nothing has to + * keep two base URLs in step. Anonymous visitor checks stay honest because + * they open a fresh browser context (`visitPublicPage`), and the admin + * session cookie is scoped to `Path=/admin` so it never rides along on a + * public request anyway. + * + * This wrapper owns only the `.tmp/e2e-*` data, which it resets on every run. + */ +import { mkdir, rm } from 'node:fs/promises' +import { bunCommand, bunRunCommand } from './lib/bunCommand' +import { ensureDependencies } from './lib/ensureDependencies' + +const DATABASE_PATH = './.tmp/e2e-agent.db' +const UPLOADS_DIR = './.tmp/e2e-uploads' +const PORT = process.env.E2E_CMS_PORT ?? '3002' + +function log(msg: string): void { + console.error(`[e2e-server] ${msg}`) +} + +// Same guard as `bun run dev`: Playwright starts this stack right after a +// `git pull`, and a stale node_modules would otherwise surface as a crash on +// the first import instead of a missing install. +await ensureDependencies((msg) => log(msg)) + +await mkdir('./.tmp', { recursive: true }) +await rm(DATABASE_PATH, { force: true }) +await rm(`${DATABASE_PATH}-shm`, { force: true }) +await rm(`${DATABASE_PATH}-wal`, { force: true }) +await rm(UPLOADS_DIR, { force: true, recursive: true }) + +// `bun run build` is `tsc -b && vite build`. Building here rather than in a +// separate CI step keeps one path: whatever a developer runs locally is what +// the runner runs. +log('building the admin SPA (bun run build)') +const build = Bun.spawnSync(bunRunCommand('build'), { stdout: 'inherit', stderr: 'inherit' }) +if (build.exitCode !== 0) { + log(`build failed (exit ${build.exitCode})`) + process.exit(build.exitCode ?? 1) +} + +const origin = `http://127.0.0.1:${PORT}` +log(`starting the CMS on ${origin}`) + +const server = Bun.spawn(bunCommand('server/index.ts'), { + env: { + ...process.env, + PORT, + HOST: '127.0.0.1', + DATABASE_URL: `sqlite:${DATABASE_PATH}`, + UPLOADS_DIR, + STATIC_DIR: './dist', + // Pin the CSRF origin to the one the suite drives, so the check compares a + // configured value instead of falling back to the inbound Host header. + PUBLIC_ORIGIN: origin, + }, + stdin: 'inherit', + stdout: 'inherit', + stderr: 'inherit', +}) + +let shuttingDown = false + +function stopServer(signal: NodeJS.Signals = 'SIGTERM'): void { + shuttingDown = true + if (server.exitCode === null) server.kill(signal) +} + +process.on('SIGINT', () => stopServer('SIGINT')) +process.on('SIGTERM', () => stopServer('SIGTERM')) + +const code = await server.exited +if (!shuttingDown) log(`server exited with code ${code}`) +process.exit(code ?? 0) diff --git a/src/__tests__/devWorkflow.test.ts b/src/__tests__/devWorkflow.test.ts index 7c2327fc0..9df82c7eb 100644 --- a/src/__tests__/devWorkflow.test.ts +++ b/src/__tests__/devWorkflow.test.ts @@ -45,7 +45,7 @@ describe('development workflow', () => { it('development launchers route local Vite binaries through Bun on Windows', () => { const devScript = readSiteFile('scripts/dev.ts') - const e2eScript = readSiteFile('scripts/e2e-dev.ts') + const e2eScript = readSiteFile('scripts/e2e-server.ts') const startScript = readSiteFile('scripts/start.ts') const viteScript = readSiteFile('scripts/vite.ts') @@ -65,9 +65,13 @@ describe('development workflow', () => { expect(devScript).not.toContain("bunRunCommand('vite'") expect(devScript).not.toContain('command: `vite') expect(devScript).not.toContain('command.split') - expect(e2eScript).toContain("viteCommand('--host', '127.0.0.1'") - expect(e2eScript).not.toContain("bunRunCommand('dev:vite'") - expect(e2eScript).not.toContain("bunRunCommand('vite'") + // The E2E stack deliberately runs NO Vite dev server: it builds the SPA + // and serves it from the Bun process, so the suite exercises the shipped + // bundle and never depends on the dev server surviving inside Bun. + expect(e2eScript).toContain("bunRunCommand('build')") + expect(e2eScript).toContain("bunCommand('server/index.ts')") + expect(e2eScript).toContain("STATIC_DIR: './dist'") + expect(e2eScript).not.toContain('viteCommand') expect(e2eScript).not.toContain("['vite'") expect(e2eScript).not.toContain("['bun'") expect(viteScript).toContain('viteCommand(...Bun.argv.slice(2))') diff --git a/tests/e2e/account.e2e.ts b/tests/e2e/account.e2e.ts index f8f216c11..321e67e0d 100644 --- a/tests/e2e/account.e2e.ts +++ b/tests/e2e/account.e2e.ts @@ -2,6 +2,7 @@ import { createHmac } from 'node:crypto' import { expect, test, type Locator, type Page } from '@playwright/test' import { ACCOUNT_PERSONA, + ADMIN_BASE_URL, ANONYMOUS_STATE, OWNER, completeStepUp as runStepUp, @@ -36,7 +37,6 @@ const TEXT_AVATAR = Buffer.from('not an image', 'utf8') const OVERSIZED_AVATAR = Buffer.alloc(5 * 1024 * 1024 + 1) const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567' -const ADMIN_BASE_URL = process.env.E2E_ADMIN_BASE_URL ?? 'http://127.0.0.1:5174' function decodeBase32(secret: string): Buffer { let bits = '' diff --git a/tests/e2e/content-seo-meta.e2e.ts b/tests/e2e/content-seo-meta.e2e.ts index f533802b2..99ff0a801 100644 --- a/tests/e2e/content-seo-meta.e2e.ts +++ b/tests/e2e/content-seo-meta.e2e.ts @@ -3,6 +3,7 @@ import { ANONYMOUS_STATE, OWNER, PUBLIC_BASE_URL, + deleteTemplate, insertModuleViaPicker, insertNotchModule, login, @@ -120,5 +121,12 @@ test.describe('entry SEO meta', () => { await context.close() } }) + + // Priority 300 outranks every other Posts template the suite publishes; + // left behind, it would render every later spec's entry route. + await test.step('remove the template so later specs resolve their own', async () => { + await openSiteEditor(page) + await deleteTemplate(page, `SEO Meta Template ${suffix}`) + }) }) }) diff --git a/tests/e2e/content.e2e.ts b/tests/e2e/content.e2e.ts index 2dd5285d7..3dbdfcbf5 100644 --- a/tests/e2e/content.e2e.ts +++ b/tests/e2e/content.e2e.ts @@ -4,6 +4,7 @@ import { OWNER, canvasFrame, completeStepUp, + deleteTemplate, insertModuleViaPicker, insertNotchModule, login, @@ -145,6 +146,11 @@ test.describe('content', () => { liveFrame.getByLabel('Post body (live preview)'), ).toBeVisible() }) + + await test.step('remove the template so later specs resolve their own', async () => { + await openSiteEditor(page) + await deleteTemplate(page, `E2E Posts Template ${suffix}`) + }) }) }) @@ -287,6 +293,11 @@ test.describe('content', () => { }, }) }) + + await test.step('remove the template so later specs resolve their own', async () => { + await openSiteEditor(page) + await deleteTemplate(page, `E2E Posts Template ${suffix}`) + }) }) }) @@ -436,6 +447,11 @@ test.describe('content', () => { hiddenText: [tokenText, `{currentEntry.${fieldId}}`, templateName], }) }) + + await test.step('remove the template so later specs resolve their own', async () => { + await openSiteEditor(page) + await deleteTemplate(page, templateName) + }) }) }) }) diff --git a/tests/e2e/helpers/constants.ts b/tests/e2e/helpers/constants.ts index acafa923d..f7a794414 100644 --- a/tests/e2e/helpers/constants.ts +++ b/tests/e2e/helpers/constants.ts @@ -1,11 +1,12 @@ /** * Shared constants for the automated Playwright E2E suite. * - * The Playwright `webServer` (`scripts/e2e-dev.ts`) resets the disposable - * `.tmp/e2e-*` database once per run and then serves a single shared stack: - * one admin origin, one public origin, one SQLite database. Every spec runs - * serially against that shared state (`workers: 1`), so these constants are the - * single source of truth for the suite identities and origins. + * The Playwright `webServer` (`scripts/e2e-server.ts`) resets the disposable + * `.tmp/e2e-*` database once per run, builds the admin SPA, and serves it from + * the same Bun process that serves the published site: one origin, one SQLite + * database. Every spec runs serially against that shared state (`workers: 1`), + * so these constants are the single source of truth for the suite identities + * and origins. */ /** First-run owner created by the `setup` project and reused by ordinary specs. */ @@ -15,9 +16,19 @@ export const OWNER = { siteName: 'Automated E2E Site', } as const -/** Public (visitor-facing) origin. Different port → always a fresh context. */ -export const PUBLIC_BASE_URL = - process.env.E2E_PUBLIC_BASE_URL ?? 'http://127.0.0.1:3002' +/** Admin origin. Also the public origin — the stack serves both from one port. */ +export const ADMIN_BASE_URL = + process.env.E2E_ADMIN_BASE_URL ?? 'http://127.0.0.1:3002' + +/** + * Public (visitor-facing) origin — the same origin as the admin. + * + * What keeps a visitor check honest is the fresh browser context + * `visitPublicPage` opens, not a separate port: the admin session cookie is + * scoped to `Path=/admin`, so it never accompanies a public request even on a + * shared origin. + */ +export const PUBLIC_BASE_URL = process.env.E2E_PUBLIC_BASE_URL ?? ADMIN_BASE_URL /** * Saved owner authentication state. The `setup` project writes this after diff --git a/tests/e2e/helpers/editor.ts b/tests/e2e/helpers/editor.ts index 16356f3f7..e868bdbd6 100644 --- a/tests/e2e/helpers/editor.ts +++ b/tests/e2e/helpers/editor.ts @@ -166,6 +166,30 @@ export async function createPage( ).toBeVisible() } +/** + * Delete a template from the Site Explorer by its title, confirming the + * prompt. Assumes the Site editor is open. + * + * Every spec that publishes a Posts template MUST call this before it ends. + * The suite shares one database, and the template chain keeps at most one + * template per breadth level (highest priority, then document order), so a + * template left behind decides which template every later spec's entry route + * renders through. One leaked priority-300 template is enough to make an + * unrelated spec's public-route assertion fail depending on run order. + */ +export async function deleteTemplate(page: Page, name: string): Promise { + await openSitePanel(page) + const item = page.getByRole('treeitem', { name: `Open template ${name}` }) + await expect(item).toBeVisible() + await item.click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Delete' }).click() + // Templates are pages, so the explorer's page prompt is the one that opens. + const dialog = page.getByRole('alertdialog', { name: 'Delete page?' }) + await expect(dialog).toBeVisible() + await dialog.getByRole('button', { name: 'Delete page' }).click() + await expect(item).toHaveCount(0) +} + /** * Publish the current draft. Publishing is a sensitive action that may require * a fresh-password step-up; this satisfies the prompt with the owner password diff --git a/tests/e2e/visual-builder.e2e.ts b/tests/e2e/visual-builder.e2e.ts index 1713c3399..14be72e19 100644 --- a/tests/e2e/visual-builder.e2e.ts +++ b/tests/e2e/visual-builder.e2e.ts @@ -5,6 +5,7 @@ import { canvasFrameForBreakpoint, completeStepUp, createPage, + deleteTemplate, insertModuleViaPicker, insertNotchModule, login, @@ -562,6 +563,11 @@ test.describe('visual builder', () => { visibleText: ['Template headline:', postTitle, bodyText], hiddenText: ['Example Post Title', '{currentEntry.title}', templateName], }) + + await test.step('remove the template so later specs resolve their own', async () => { + await openSiteEditor(page) + await deleteTemplate(page, templateName) + }) }) test('saves, inserts, renames, deletes, and publishes a layout (SITE-019)', async ({