#!/usr/bin/env bun /** * Browser e2e for the workspace editor: signs in, presses `.`, edits a file inside the sandboxed frame, * commits, and verifies the commit through the editor API. Drives a headless Chrome over CDP. * Usage: BASE=http://localhost:3000 GITFOR_TOKEN=... E2E_LOGIN=... E2E_PASSWORD=... bun run scripts/e2e-editor-browser.ts */ import { spawn } from "node:child_process"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; const BASE = process.env.BASE ?? "http://localhost:3000"; const PAT = process.env.GITFOR_TOKEN ?? ""; const LOGIN = process.env.E2E_LOGIN ?? ""; const PASSWORD = process.env.E2E_PASSWORD ?? ""; const CHROME = process.env.CHROME ?? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; const DEBUG_PORT = 9333; const POLL_MS = 250; const WAIT_MS = 30_000; const HTTP_CREATED = 201; const HYDRATE_MS = 3000; if (!PAT || !LOGIN || !PASSWORD) throw new Error("Set GITFOR_TOKEN, E2E_LOGIN and E2E_PASSWORD"); function check(condition: unknown, message: string): asserts condition { if (!condition) throw new Error(`FAIL: ${message}`); console.log(`ok - ${message}`); } async function api(path: string, token: string, method = "GET", body?: unknown) { const response = await fetch(`${BASE}${path}`, { method, headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), }); const text = await response.text(); return { status: response.status, body: text ? JSON.parse(text) : null }; } const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const chrome = spawn(CHROME, [ "--headless=new", `--remote-debugging-port=${DEBUG_PORT}`, `--user-data-dir=${mkdtempSync(join(tmpdir(), "gitfor-editor-e2e-"))}`, "--no-first-run", "about:blank", ]); let wsUrl = ""; for (let attempt = 0; attempt < WAIT_MS / POLL_MS && !wsUrl; attempt++) { await sleep(POLL_MS); const targets = await fetch(`http://127.0.0.1:${DEBUG_PORT}/json`).then( (response) => response.json() as Promise>, () => [], ); wsUrl = targets.find((target) => target.type === "page")?.webSocketDebuggerUrl ?? ""; } check(wsUrl, "headless Chrome started"); const socket = new WebSocket(wsUrl); await new Promise((resolve) => socket.addEventListener("open", resolve, { once: true })); let nextId = 1; const pending = new Map void>(); const frameSessions: Array<{ sessionId: string; url: string }> = []; socket.addEventListener("message", (event) => { const message = JSON.parse(String(event.data)); if (message.id && pending.has(message.id)) pending.get(message.id)?.(message); if (message.method === "Target.attachedToTarget" && message.params.targetInfo.type === "iframe") { frameSessions.push({ sessionId: message.params.sessionId, url: message.params.targetInfo.url }); } }); async function send(method: string, params: Record = {}, sessionId?: string): Promise { const id = nextId++; const reply = await new Promise<{ result?: unknown; error?: { message: string } }>((resolve) => { pending.set(id, resolve); socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); }); if (reply.error) throw new Error(`${method}: ${reply.error.message}`); return reply.result as T; } async function evaluate(expression: string, sessionId?: string): Promise { const result = await send<{ result: { value: T }; exceptionDetails?: { text: string } }>( "Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true }, sessionId, ); if (result.exceptionDetails) throw new Error(`evaluate: ${result.exceptionDetails.text}`); return result.result.value; } async function waitFor(label: string, probe: () => Promise): Promise { for (let attempt = 0; attempt < WAIT_MS / POLL_MS; attempt++) { const value = await probe().catch(() => null); if (value) return value; await sleep(POLL_MS); } throw new Error(`FAIL: timed out waiting for ${label}`); } async function navigate(url: string) { await send("Page.navigate", { url }); await waitFor("page load", () => evaluate("document.readyState === 'complete'")); } try { await send("Page.enable"); await send("Runtime.enable"); await send("Target.setAutoAttach", { autoAttach: true, waitForDebuggerOnStart: false, flatten: true }); const me = await api("/api/v1/user", PAT); const name = `editor-browser-${Date.now()}`; const created = await api("/api/v1/repos", PAT, "POST", { name, private: true }); check(created.status === HTTP_CREATED, `created ${me.body.login}/${name}`); await api(`/api/v1/repos/${me.body.login}/${name}/contents/src/app.ts`, PAT, "PUT", { branch: "main", message: "seed", content: Buffer.from("export const answer = 41;\n").toString("base64"), }); await navigate(`${BASE}/login`); await sleep(HYDRATE_MS); await evaluate(`(() => { const set = (name, value) => { const input = document.querySelector('input[name="' + name + '"]'); Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set.call(input, value); input.dispatchEvent(new Event("input", { bubbles: true })); }; set("login", ${JSON.stringify(LOGIN)}); set("password", ${JSON.stringify(PASSWORD)}); document.querySelector('button[type="submit"]').click(); })()`); await waitFor("sign-in", () => evaluate("!location.pathname.startsWith('/login')")).catch(async (error) => { console.log(await evaluate("location.href + ' ' + document.body.innerText.slice(0, 400)")); throw error; }); check(true, "signed in"); await navigate(`${BASE}/${me.body.login}/${name}`); await waitFor("repo page", () => evaluate(`document.body.innerText.includes("app.ts") || document.body.innerText.includes("src")`), ); await send("Input.dispatchKeyEvent", { type: "keyDown", key: ".", text: ".", code: "Period" }); await send("Input.dispatchKeyEvent", { type: "keyUp", key: ".", code: "Period" }); await waitFor("editor route", () => evaluate(`location.pathname.endsWith("/edit/main")`)); check(true, "pressing . opened /edit/main"); const frameContext = await waitFor("frame session", async () => { for (const frame of frameSessions) { const ready = await evaluate(`!!document.querySelector('[data-path="src/app.ts"]')`, frame.sessionId); if (ready) return frame.sessionId; } return null; }).catch(async (error) => { console.log("frames", JSON.stringify(frameSessions)); console.log( await evaluate( `JSON.stringify([...document.querySelectorAll("iframe")].map((f) => [f.src, f.sandbox.value]))`, ), ); throw error; }); check(frameContext, "sandboxed frame loaded the tree"); const frameOrigin = await evaluate("window.origin", frameContext); check(frameOrigin === "null", "frame runs in an opaque origin"); const frameCookies = await evaluate( `(() => { try { return document.cookie; } catch (error) { return "blocked:" + error.name; } })()`, frameContext, ); check(frameCookies.startsWith("blocked:") || frameCookies === "", `frame can't read cookies (${frameCookies})`); await evaluate(`document.querySelector('[data-path="src/app.ts"]').click()`, frameContext); await waitFor("file contents", () => evaluate(`document.getElementById("editor").value.includes("41")`, frameContext), ); await evaluate( `(() => { const area = document.getElementById("editor"); area.value = area.value.replace("41", "42"); area.dispatchEvent(new Event("input")); document.getElementById("message").value = "Edit in the browser editor"; document.getElementById("commit").click(); })()`, frameContext, ); const status = await waitFor("commit", () => evaluate( `(() => { const text = document.getElementById("status-msg").textContent; return text.startsWith("Committed") || text.includes("rror") ? text : null; })()`, frameContext, ), ); check(status.startsWith("Committed"), `frame reports: ${status}`); const banner = await waitFor("parent banner", () => evaluate(`document.querySelector('[data-testid="editor-committed"]')?.textContent ?? null`), ); check(banner.includes("main"), `parent shows: ${banner}`); const session = await api(`/api/v1/repos/${me.body.login}/${name}/editor-sessions`, PAT, "POST", { ref: "main" }); const blob = await api("/api/editor/blob?path=src/app.ts", session.body.token); check(blob.body.content === "export const answer = 42;\n", "commit landed on main with the edited content"); await api(`/api/v1/repos/${me.body.login}/${name}`, PAT, "DELETE"); console.log("all browser editor e2e checks passed"); } finally { socket.close(); chrome.kill(); }