Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
62 changes: 62 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
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: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. 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

- 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
42 changes: 25 additions & 17 deletions docs/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` /
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 12 additions & 4 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -16,6 +19,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'],
Expand Down Expand Up @@ -61,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',
Expand Down
75 changes: 0 additions & 75 deletions scripts/e2e-dev.ts

This file was deleted.

90 changes: 90 additions & 0 deletions scripts/e2e-server.ts
Original file line number Diff line number Diff line change
@@ -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)
12 changes: 8 additions & 4 deletions src/__tests__/devWorkflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand All @@ -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))')
Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/account.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = ''
Expand Down
Loading
Loading