/** * Multi-client browser e2e: two independent browsers (owner and collaborator) and one HTTP agent co-edit * the same file in VS Code for the Web, see each other's text and cursors, commit, and the collaborator * is kicked when their sessions are revoked. * * EDITOR_E2E_URL=http://localhost:3000 GITFOR_TOKEN=... GITFOR_WRITER_TOKEN=... GITFOR_E2E_D1=... \ * E2E_LOGIN=tgr E2E_PASSWORD=... E2E_WRITER_LOGIN=... E2E_WRITER_PASSWORD=... bun run e2e/editor-browser.e2e.ts */ import { Browser, sleep, waitFor } from "./editor-cdp"; import { BASE, call, check, log, finish, LOCAL_D1, mint, PAT, quote, seedRepo, sql, WRITER_PAT, } from "./editor-helpers"; const META = 4; const SHIFT = 8; const CONTROL = 2; const ENTER = 13; const REVOKE_WAIT_MS = 120_000; const SHOTS = process.env.E2E_SCREENSHOTS ?? ""; const login = { name: process.env.E2E_LOGIN ?? "", password: process.env.E2E_PASSWORD ?? "" }; const writerLogin = { name: process.env.E2E_WRITER_LOGIN ?? "", password: process.env.E2E_WRITER_PASSWORD ?? "" }; if (!PAT || !WRITER_PAT || !login.name || !writerLogin.name) throw new Error("Set GITFOR_TOKEN, GITFOR_WRITER_TOKEN and both logins"); const repo = await seedRepo("editor-collab", { "README.md": "# collab\n", "src/app.ts": "export const answer = 41;\n", }); const writer = await call("/api/v1/user", WRITER_PAT); await call(`${repo.api}/collaborators/${writer.body.login}`, PAT, { method: "PUT", body: { permission: "write" } }); log(`# ${repo.owner}/${repo.name} on ${BASE}`); const owner = await Browser.launch("owner"); const collaborator = await Browser.launch("collaborator"); const viewLines = `(document.querySelector('.editor-instance .monaco-editor .view-lines')?.innerText ?? "").replace(/\\u00a0/g, " ")`; async function openWorkbench(browser: Browser, who: { name: string; password: string }): Promise { await browser.login(BASE, who.name, who.password); await browser.navigate(`${BASE}/${repo.owner}/${repo.name}/edit/main?path=src/app.ts`); const frame = await browser .frame(`!!document.querySelector('.monaco-workbench')`, "workbench", 90_000) .catch(async (error: unknown) => { await browser.screenshot(`${SHOTS || "/tmp"}/failed-${who.name}.png`); log(`# ${who.name} at ${await browser.evaluate("location.href")}; frames=${browser.frames.length}`); log( browser.logs .filter((line) => !line.startsWith("[log]")) .slice(-15) .join("\n"), ); throw error; }); await waitFor( "explorer entries", () => browser.evaluate(`document.body.innerText.includes("README.md")`, frame), 60_000, ); return frame; } async function fileOpen(browser: Browser, frame: string): Promise { await waitFor("src/app.ts open", () => browser.evaluate(`${viewLines}.includes("answer")`, frame), 60_000); await browser.evaluate(`document.querySelector('.editor-instance .monaco-editor textarea')?.focus()`, frame); await sleep(1000); } async function screenshot(browser: Browser, name: string): Promise { if (SHOTS) await browser.screenshot(`${SHOTS}/${name}.png`); } try { const [ownerFrame, collabFrame] = await Promise.all([ openWorkbench(owner, login), openWorkbench(collaborator, writerLogin), ]); check(true, "both browsers loaded VS Code for the Web on the editor origin"); const frameOrigin = await owner.evaluate("location.origin", ownerFrame); check(frameOrigin !== BASE && frameOrigin.includes("editor"), `workbench runs on its own origin (${frameOrigin})`); check((await owner.evaluate("document.cookie", ownerFrame)) === "", "workbench has no cookies"); await fileOpen(owner, ownerFrame); await fileOpen(collaborator, collabFrame); check(true, "both opened src/app.ts from the ?path= deep link"); await owner.key("ArrowUp", "ArrowUp", 38, META, ownerFrame); await owner.type("// from the owner\n", ownerFrame); check( await waitFor( "collaborator sees owner", () => collaborator.evaluate(`${viewLines}.includes("from the owner")`, collabFrame), 20_000, ).catch(() => false), "the collaborator's editor shows the owner's typing", ); await collaborator.key("ArrowDown", "ArrowDown", 40, META, collabFrame); await collaborator.type("// from the collaborator\n", collabFrame); check( await waitFor( "owner sees collaborator", () => owner.evaluate(`${viewLines}.includes("from the collaborator")`, ownerFrame), 20_000, ).catch(() => false), "the owner's editor shows the collaborator's typing", ); const cursorLabel = (name: string) => `[...document.styleSheets].some((sheet) => { try { return [...sheet.cssRules].some((rule) => rule.cssText.includes('content: ') && rule.cssText.includes('${name}')); } catch { return false; } })`; check( await waitFor( "owner cursor at collaborator", () => collaborator.evaluate(cursorLabel(login.name), collabFrame), 20_000, ).catch(() => false), "the collaborator sees the owner's labelled cursor", ); check( await waitFor( "collaborator cursor at owner", () => owner.evaluate(cursorLabel(writer.body.name ?? writer.body.login), ownerFrame), 20_000, ).catch(() => false), "the owner sees the collaborator's labelled cursor", ); const agentToken = await mint(repo.api, PAT); const snapshot = await call("/api/editor/room/file?path=src/app.ts", agentToken); const agent = await call("/api/editor/room/edit", agentToken, { method: "POST", body: { path: "src/app.ts", agent: "review-bot", baseVersion: snapshot.body.version, edits: [{ from: 0, to: 0, insert: "// from the agent\n" }], }, }); check(agent.body.ok, "the agent applied an edit through the room"); const agentVisible = await Promise.all( [ [owner, ownerFrame], [collaborator, collabFrame], ].map(([browser, frame]) => waitFor( "agent text", () => (browser as Browser).evaluate(`${viewLines}.includes("from the agent")`, frame as string), 20_000, ).catch(() => false), ), ); check(agentVisible.every(Boolean), "both browsers show the agent's edit live"); check( await waitFor( "agent cursor", () => owner.evaluate(cursorLabel("review-bot"), ownerFrame).catch(() => false), 20_000, ).catch(() => false), "the agent's cursor is labelled in the editor", ); await screenshot(owner, "owner-coediting"); await screenshot(collaborator, "collaborator-coediting"); await owner.key("g", "KeyG", 71, CONTROL | SHIFT, ownerFrame); await waitFor( "scm input", () => owner.evaluate(`!!document.querySelector('.scm-editor .monaco-editor')`, ownerFrame), 20_000, ); await owner.evaluate(`document.querySelector('.scm-editor textarea')?.focus()`, ownerFrame); await owner.type("Co-edited by two people and an agent", ownerFrame); await owner.key("Enter", "Enter", ENTER, META, ownerFrame); const landed = await waitFor( "commit", async () => { const blob = await call("/api/editor/blob?path=src/app.ts", await mint(repo.api, PAT)); return blob.body.content?.includes("from the agent") && blob.body.content.includes("from the collaborator") ? blob.body.content : null; }, 30_000, ).catch(() => null); check( landed && landed.includes("from the owner"), "Commit from the Source Control view landed everyone's edits on main", ); await screenshot(owner, "owner-committed"); if (LOCAL_D1) { sql(`UPDATE users SET sessions_revoked_at = ${Date.now() + 1000} WHERE account_id = ${quote(writer.body.id)}`); const kicked = await waitFor( "collaborator kicked", () => collaborator.evaluate( `document.querySelector('.statusbar')?.innerText.includes("Access revoked") ?? false`, collabFrame, ), REVOKE_WAIT_MS, ).catch(() => false); check(kicked, "the revoked collaborator is kicked from the room"); await screenshot(collaborator, "collaborator-revoked"); sql(`UPDATE users SET sessions_revoked_at = NULL WHERE account_id = ${quote(writer.body.id)}`); } } finally { owner.close(); collaborator.close(); await call(repo.api, PAT, { method: "DELETE" }); } finish();