diff --git a/packages/app/README.md b/packages/app/README.md index 42a6881509..54d1b2861b 100644 --- a/packages/app/README.md +++ b/packages/app/README.md @@ -31,18 +31,20 @@ Your app is ready to be deployed! ## E2E Testing -The Playwright runner expects the app already running at `http://localhost:3000`. +Playwright starts the Vite dev server automatically via `webServer`, and UI tests need an opencode backend (defaults to `localhost:4096`). +Use the local runner to create a temp sandbox, seed data, and run the tests. ```bash -bun add -D @playwright/test bunx playwright install -bun run test:e2e +bun run test:e2e:local +bun run test:e2e:local -- --grep "settings" ``` Environment options: -- `PLAYWRIGHT_BASE_URL` (default: `http://localhost:3000`) -- `PLAYWRIGHT_PORT` (default: `3000`) +- `PLAYWRIGHT_SERVER_HOST` / `PLAYWRIGHT_SERVER_PORT` (backend address, default: `localhost:4096`) +- `PLAYWRIGHT_PORT` (Vite dev server port, default: `3000`) +- `PLAYWRIGHT_BASE_URL` (override base URL, default: `http://localhost:`) ## Deployment diff --git a/packages/app/e2e/prompt.spec.ts b/packages/app/e2e/prompt.spec.ts index 26cab5a384..3e5892ce8d 100644 --- a/packages/app/e2e/prompt.spec.ts +++ b/packages/app/e2e/prompt.spec.ts @@ -37,24 +37,19 @@ test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) .poll( async () => { const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? []) - const assistant = messages - .slice() - .reverse() - .find((m) => m.info.role === "assistant") - - return ( - assistant?.parts - .filter((p) => p.type === "text") - .map((p) => p.text) - .join("\n") ?? "" - ) + return messages + .filter((m) => m.info.role === "assistant") + .flatMap((m) => m.parts) + .filter((p) => p.type === "text") + .map((p) => p.text) + .join("\n") }, { timeout: 90_000 }, ) .toContain(token) - const reply = page.locator('[data-component="text-part"]').filter({ hasText: token }).first() + const reply = page.locator('[data-slot="session-turn-summary-section"]').filter({ hasText: token }).first() await expect(reply).toBeVisible({ timeout: 90_000 }) } finally { page.off("pageerror", onPageError) diff --git a/packages/app/package.json b/packages/app/package.json index d71f060610..5409890600 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -15,6 +15,7 @@ "serve": "vite preview", "test": "playwright test", "test:e2e": "playwright test", + "test:e2e:local": "bun script/e2e-local.ts", "test:e2e:ui": "playwright test --ui", "test:e2e:report": "playwright show-report e2e/playwright-report" }, diff --git a/packages/app/script/e2e-local.ts b/packages/app/script/e2e-local.ts new file mode 100644 index 0000000000..dd0e9a52e2 --- /dev/null +++ b/packages/app/script/e2e-local.ts @@ -0,0 +1,130 @@ +import fs from "node:fs/promises" +import net from "node:net" +import os from "node:os" +import path from "node:path" + +async function freePort() { + return await new Promise((resolve, reject) => { + const server = net.createServer() + server.once("error", reject) + server.listen(0, () => { + const address = server.address() + if (!address || typeof address === "string") { + server.close(() => reject(new Error("Failed to acquire a free port"))) + return + } + server.close((err) => { + if (err) { + reject(err) + return + } + resolve(address.port) + }) + }) + }) +} + +async function waitForHealth(url: string) { + const timeout = Date.now() + 60_000 + while (Date.now() < timeout) { + const ok = await fetch(url) + .then((r) => r.ok) + .catch(() => false) + if (ok) return + await new Promise((r) => setTimeout(r, 250)) + } + throw new Error(`Timed out waiting for server health: ${url}`) +} + +const appDir = process.cwd() +const repoDir = path.resolve(appDir, "../..") +const opencodeDir = path.join(repoDir, "packages", "opencode") +const modelsJson = path.join(opencodeDir, "test", "tool", "fixtures", "models-api.json") + +const extraArgs = (() => { + const args = process.argv.slice(2) + if (args[0] === "--") return args.slice(1) + return args +})() + +const [serverPort, webPort] = await Promise.all([freePort(), freePort()]) + +const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-")) + +const serverEnv = { + ...process.env, + MODELS_DEV_API_JSON: modelsJson, + OPENCODE_DISABLE_MODELS_FETCH: "true", + OPENCODE_DISABLE_SHARE: "true", + OPENCODE_DISABLE_LSP_DOWNLOAD: "true", + OPENCODE_DISABLE_DEFAULT_PLUGINS: "true", + OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true", + OPENCODE_TEST_HOME: path.join(sandbox, "home"), + XDG_DATA_HOME: path.join(sandbox, "share"), + XDG_CACHE_HOME: path.join(sandbox, "cache"), + XDG_CONFIG_HOME: path.join(sandbox, "config"), + XDG_STATE_HOME: path.join(sandbox, "state"), + OPENCODE_E2E_PROJECT_DIR: repoDir, + OPENCODE_E2E_SESSION_TITLE: "E2E Session", + OPENCODE_E2E_MESSAGE: "Seeded for UI e2e", + OPENCODE_E2E_MODEL: "opencode/gpt-5-nano", + OPENCODE_CLIENT: "app", +} satisfies Record + +const runnerEnv = { + ...process.env, + PLAYWRIGHT_SERVER_HOST: "localhost", + PLAYWRIGHT_SERVER_PORT: String(serverPort), + VITE_OPENCODE_SERVER_HOST: "localhost", + VITE_OPENCODE_SERVER_PORT: String(serverPort), + PLAYWRIGHT_PORT: String(webPort), +} satisfies Record + +const seed = Bun.spawn(["bun", "script/seed-e2e.ts"], { + cwd: opencodeDir, + env: serverEnv, + stdout: "inherit", + stderr: "inherit", +}) + +const seedExit = await seed.exited +if (seedExit !== 0) { + process.exit(seedExit) +} + +const server = Bun.spawn( + [ + "bun", + "dev", + "--", + "--print-logs", + "--log-level", + "WARN", + "serve", + "--port", + String(serverPort), + "--hostname", + "127.0.0.1", + ], + { + cwd: opencodeDir, + env: serverEnv, + stdout: "inherit", + stderr: "inherit", + }, +) + +try { + await waitForHealth(`http://localhost:${serverPort}/global/health`) + + const runner = Bun.spawn(["bun", "test:e2e", ...extraArgs], { + cwd: appDir, + env: runnerEnv, + stdout: "inherit", + stderr: "inherit", + }) + + process.exitCode = await runner.exited +} finally { + server.kill() +}