Skip to content
Closed
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
2 changes: 1 addition & 1 deletion .oxlintrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
61 changes: 61 additions & 0 deletions apps/host-selfhost/e2e/fixtures.ts
Original file line number Diff line number Diff line change
@@ -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<this> {
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<void> {
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 };
45 changes: 45 additions & 0 deletions apps/host-selfhost/e2e/org-setup.spec.ts
Original file line number Diff line number Diff line change
@@ -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}`);
});
});
58 changes: 58 additions & 0 deletions apps/host-selfhost/e2e/server.ts
Original file line number Diff line number Diff line change
@@ -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);
});
7 changes: 6 additions & 1 deletion apps/host-selfhost/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -44,13 +47,15 @@
"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:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@vitejs/plugin-react": "catalog:",
"bun-types": "catalog:",
"playwright": "^1.60.0",
"typescript": "catalog:",
"vite": "catalog:",
"vitest": "catalog:"
Expand Down
58 changes: 58 additions & 0 deletions apps/host-selfhost/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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",
},
});
2 changes: 2 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading