From 4a29fbec9cc85da2c638c0d1c81665fb2ac27176 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan Date: Mon, 1 Jun 2026 21:22:25 -0700 Subject: [PATCH] Add fully-assembled self-host e2e suite + first scenario (org setup) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boots the real prod self-host server (bun src/serve.ts: built SPA + typed API + Better Auth + MCP) against a throwaway data dir and drives it in a real browser through its real surfaces — the seed of a realistic, host-parametric e2e suite. First scenario: a zero-config first run sets up an org. The browser fills the setup form, real Better Auth signup runs, and the org + owner are confirmed via the same API the signed-in console calls — so any broken seam between frontend, auth, API, and DB turns it red. Hermetic (in-process Better Auth + libSQL — no external services or secrets) and watchable: test:e2e:watch (headed + slowed), test:e2e:ui (time-travel UI), plus an HTML report + trace on every run. Fixtures keep the scenario reading as a user journey behind a SelfHostApp handle that is also the future host-driver seam. --- .oxlintrc.jsonc | 2 +- apps/host-selfhost/e2e/fixtures.ts | 61 ++++++++++++++++++++++++ apps/host-selfhost/e2e/org-setup.spec.ts | 45 +++++++++++++++++ apps/host-selfhost/e2e/server.ts | 58 ++++++++++++++++++++++ apps/host-selfhost/package.json | 7 ++- apps/host-selfhost/playwright.config.ts | 58 ++++++++++++++++++++++ bun.lock | 2 + 7 files changed, 231 insertions(+), 2 deletions(-) create mode 100644 apps/host-selfhost/e2e/fixtures.ts create mode 100644 apps/host-selfhost/e2e/org-setup.spec.ts create mode 100644 apps/host-selfhost/e2e/server.ts create mode 100644 apps/host-selfhost/playwright.config.ts diff --git a/.oxlintrc.jsonc b/.oxlintrc.jsonc index f8897c394..8a6ac1d29 100644 --- a/.oxlintrc.jsonc +++ b/.oxlintrc.jsonc @@ -70,7 +70,7 @@ { // Playwright e2e specs drive a real browser: they stringify browser-side // errors and use console/promise APIs that the Effect-domain rules forbid. - "files": ["apps/cloud/e2e/**/*.{ts,tsx}"], + "files": ["apps/*/e2e/**/*.{ts,tsx}"], "rules": { "executor/no-promise-catch": "off", "executor/no-try-catch-or-throw": "off", diff --git a/apps/host-selfhost/e2e/fixtures.ts b/apps/host-selfhost/e2e/fixtures.ts new file mode 100644 index 000000000..a1ec62f88 --- /dev/null +++ b/apps/host-selfhost/e2e/fixtures.ts @@ -0,0 +1,61 @@ +import { test as base, expect, type Page } from "@playwright/test"; + +// --------------------------------------------------------------------------- +// The "self-host app under test" handle. +// +// Scenarios talk to the app ONLY through this — never through internal modules — +// so a test reads like a user journey, not like plumbing. Today it wraps the +// running self-host server; this is also the seam where, later, a different host +// driver (cloud / cloudflare) gets swapped in while the scenarios above stay +// identical. API reads go through `page.request` so they share the browser's +// session cookie (i.e. they see exactly what the signed-in user sees). +// --------------------------------------------------------------------------- + +export class SelfHostApp { + constructor(readonly page: Page) {} + + async openConsole(): Promise { + await this.page.goto("/"); + return this; + } + + /** `{ needsSetup }` — true on a brand-new instance, false once an org exists. */ + async setupStatus(): Promise<{ needsSetup: boolean }> { + const res = await this.page.request.get("/api/setup-status"); + return res.json(); + } + + /** The signed-in user + their org, as the console itself sees it. */ + async account(): Promise<{ + user: { id: string; email: string; name: string; avatarUrl: string | null }; + organization: { id: string; name: string }; + }> { + const res = await this.page.request.get("/api/account/me"); + expect(res.ok(), "GET /api/account/me should succeed for a signed-in admin").toBeTruthy(); + return res.json(); + } + + /** + * The turnkey first-run: fill the setup form. The first person to sign up on a + * fresh instance becomes the org owner (no invite code needed). + */ + async completeFirstRunSetup(admin: { + name: string; + email: string; + password: string; + }): Promise { + await expect(this.page.locator("#name"), "the setup form should be showing").toBeVisible(); + await this.page.locator("#name").fill(admin.name); + await this.page.locator("#email").fill(admin.email); + await this.page.locator("#password").fill(admin.password); + await this.page.getByRole("button", { name: /create admin account/i }).click(); + } +} + +export const test = base.extend<{ app: SelfHostApp }>({ + app: async ({ page }, use) => { + await use(new SelfHostApp(page)); + }, +}); + +export { expect }; diff --git a/apps/host-selfhost/e2e/org-setup.spec.ts b/apps/host-selfhost/e2e/org-setup.spec.ts new file mode 100644 index 000000000..96a1dbed4 --- /dev/null +++ b/apps/host-selfhost/e2e/org-setup.spec.ts @@ -0,0 +1,45 @@ +import { expect, test } from "./fixtures"; + +// --------------------------------------------------------------------------- +// Milestone scenario: an org gets set up. +// +// The whole assembled stack is exercised through real surfaces — the browser +// drives the zero-config first-run form, the real Better Auth signup runs, and +// we confirm the org + owner the way the console itself would (the same API the +// signed-in UI calls). No internals are touched; if any seam between frontend, +// auth, API, and DB is broken, this goes red. +// --------------------------------------------------------------------------- + +// Narrated play-by-play for the watchable run (shows up in the terminal + the +// HTML report alongside each step). +const narrate = (message: string) => console.log(` ▸ ${message}`); + +test("an org gets set up — zero-config first run", async ({ app, page }) => { + await test.step("a brand-new instance reports it needs setup", async () => { + narrate("opening the console on a freshly-booted, unconfigured self-host"); + await app.openConsole(); + expect((await app.setupStatus()).needsSetup, "a fresh instance needs setup").toBe(true); + await expect(page.locator("#name"), "the first-run setup form is shown").toBeVisible(); + }); + + await test.step("the first person signs up and becomes the org owner", async () => { + narrate("filling the first-run setup form as Ada"); + await app.completeFirstRunSetup({ + name: "Ada Admin", + email: "ada@example.com", + password: "hunter2hunter2", + }); + }); + + await test.step("the org is set up and the admin lands in the console", async () => { + narrate("confirming we left setup and the org now exists with Ada as owner"); + // We left the setup form (the app reloaded into the authenticated console). + await expect(page.locator("#name")).toBeHidden(); + expect((await app.setupStatus()).needsSetup, "setup is complete").toBe(false); + + const account = await app.account(); + expect(account.user.email).toBe("ada@example.com"); + expect(account.organization.name).toBe("E2E Test Org"); + narrate(`org "${account.organization.name}" is set up, owned by ${account.user.email}`); + }); +}); diff --git a/apps/host-selfhost/e2e/server.ts b/apps/host-selfhost/e2e/server.ts new file mode 100644 index 000000000..b92ebf277 --- /dev/null +++ b/apps/host-selfhost/e2e/server.ts @@ -0,0 +1,58 @@ +// --------------------------------------------------------------------------- +// Boots host-selfhost FULLY ASSEMBLED for the Playwright e2e suite. +// +// This runs the real production server (`bun run src/serve.ts`) — the same +// artifact that ships — serving the built SPA + the typed Effect API + Better +// Auth + MCP from one Bun process, against a FRESH throwaway data dir so every +// run starts at the zero-config first-run state (`needsSetup: true`). +// +// Hermetic by construction: Better Auth + libSQL are in-process, so there are +// no external services, no secrets, nothing to stub. That's the whole point — +// the bugs we care about live in the *assembly*, and this boots the real +// assembly end to end. Used by playwright.config.ts's `webServer`. +// --------------------------------------------------------------------------- + +import { spawn } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const appDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const PORT = process.env.E2E_PORT ?? "4796"; +const ORIGIN = `http://localhost:${PORT}`; + +// A fresh data dir per boot guarantees the turnkey first-run: the seeded org has +// zero members, so the app shows the setup form. Removed on exit. +const dataDir = mkdtempSync(join(tmpdir(), "executor-selfhost-e2e-")); + +const child = spawn("bun", ["run", "src/serve.ts"], { + cwd: appDir, + stdio: "inherit", + env: { + ...process.env, + EXECUTOR_DATA_DIR: dataDir, + PORT, + EXECUTOR_WEB_BASE_URL: ORIGIN, + BETTER_AUTH_SECRET: "e2e_selfhost_secret_0123456789abcdef", + EXECUTOR_ORG_NAME: "E2E Test Org", + // EXECUTOR_BOOTSTRAP_ADMIN_* intentionally unset → turnkey setup-form path. + }, +}); + +const removeDataDir = () => rmSync(dataDir, { recursive: true, force: true }); + +child.on("exit", (code) => { + removeDataDir(); + process.exit(code ?? 0); +}); +process.on("SIGINT", () => { + child.kill("SIGTERM"); + removeDataDir(); + process.exit(0); +}); +process.on("SIGTERM", () => { + child.kill("SIGTERM"); + removeDataDir(); + process.exit(0); +}); diff --git a/apps/host-selfhost/package.json b/apps/host-selfhost/package.json index d967253a9..44f5420d5 100644 --- a/apps/host-selfhost/package.json +++ b/apps/host-selfhost/package.json @@ -13,7 +13,10 @@ "start": "bun run src/serve.ts", "typecheck": "tsgo --noEmit", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "test:e2e": "bun run build && playwright test", + "test:e2e:watch": "bun run build && E2E_SLOWMO=400 playwright test --headed", + "test:e2e:ui": "bun run build && playwright test --ui" }, "dependencies": { "@better-auth/api-key": "^1.6.11", @@ -44,6 +47,7 @@ "devDependencies": { "@effect/vitest": "catalog:", "@executor-js/vite-plugin": "workspace:*", + "@playwright/test": "^1.60.0", "@tailwindcss/vite": "catalog:", "@tanstack/router-plugin": "^1.167.12", "@types/node": "catalog:", @@ -51,6 +55,7 @@ "@types/react-dom": "catalog:", "@vitejs/plugin-react": "catalog:", "bun-types": "catalog:", + "playwright": "^1.60.0", "typescript": "catalog:", "vite": "catalog:", "vitest": "catalog:" diff --git a/apps/host-selfhost/playwright.config.ts b/apps/host-selfhost/playwright.config.ts new file mode 100644 index 000000000..4f7619b7a --- /dev/null +++ b/apps/host-selfhost/playwright.config.ts @@ -0,0 +1,58 @@ +import { defineConfig, devices } from "@playwright/test"; + +// --------------------------------------------------------------------------- +// Playwright e2e for host-selfhost — the realistic, fully-assembled suite. +// +// Boots the real prod server (e2e/server.ts → `bun run src/serve.ts`) against a +// throwaway data dir and drives it in a real browser through its real surfaces. +// Watchable: `bun run test:e2e:watch` runs headed + slowed down so you can see +// each step; `bun run test:e2e:ui` opens Playwright's time-travel UI; every run +// also writes an HTML report + trace you can replay. +// --------------------------------------------------------------------------- + +const PORT = process.env.E2E_PORT ?? "4796"; +const BASE_URL = `http://localhost:${PORT}`; +const slowMo = Number(process.env.E2E_SLOWMO ?? 0); + +export default defineConfig({ + testDir: "./e2e", + testMatch: "**/*.spec.ts", + fullyParallel: false, + workers: 1, + forbidOnly: !!process.env.CI, + retries: 0, + timeout: 60_000, + expect: { timeout: 15_000 }, + reporter: process.env.CI ? "github" : [["list"], ["html", { open: "never" }]], + use: { + baseURL: BASE_URL, + headless: true, + // Trace gives full time-travel replay (DOM/network/console) with no extra + // system deps — that's the watchable artifact. (Video would need ffmpeg.) + trace: "on", + screenshot: "only-on-failure", + launchOptions: { slowMo }, + }, + projects: [ + { + name: "chromium", + // Drive system Chrome by default (no Chromium download); CI sets + // PLAYWRIGHT_USE_CHROMIUM=1 to use the Playwright-managed browser. + use: process.env.PLAYWRIGHT_USE_CHROMIUM + ? { ...devices["Desktop Chrome"] } + : { ...devices["Desktop Chrome"], channel: "chrome" }, + }, + ], + webServer: { + command: "bun run e2e/server.ts", + url: `${BASE_URL}/api/setup-status`, + timeout: 120_000, + // Always boot a fresh instance — the first-run scenario requires the org to + // start with zero members (needsSetup: true). + reuseExistingServer: false, + // Keep the watch run readable: suppress the server's request logs, but still + // surface stderr so a real boot failure is visible. + stdout: "ignore", + stderr: "pipe", + }, +}); diff --git a/bun.lock b/bun.lock index 6207f90d3..260fa836e 100644 --- a/bun.lock +++ b/bun.lock @@ -229,6 +229,7 @@ "devDependencies": { "@effect/vitest": "catalog:", "@executor-js/vite-plugin": "workspace:*", + "@playwright/test": "^1.60.0", "@tailwindcss/vite": "catalog:", "@tanstack/router-plugin": "^1.167.12", "@types/node": "catalog:", @@ -236,6 +237,7 @@ "@types/react-dom": "catalog:", "@vitejs/plugin-react": "catalog:", "bun-types": "catalog:", + "playwright": "^1.60.0", "typescript": "catalog:", "vite": "catalog:", "vitest": "catalog:",