| 1 | // On-demand check of the editor extension (apps/vscode) in a real VS Code or Cursor, against a real `wrangler dev` |
| 2 | // Worker: "Clone from Diffinery" in the clone picker, the browser sign-in through Diffinery's OAuth consent page, |
| 3 | // the repository list and search, and a clone of a private repository with Git asking the extension for the |
| 4 | // password. Not part of `pnpm e2e` (it needs a desktop editor and a display): |
| 5 | // |
| 6 | // node apps/vscode/scripts/package.mjs --out /tmp/vsix |
| 7 | // EDITOR_BIN=/path/to/VSCode-linux-x64/code EDITOR_CLI=/path/to/VSCode-linux-x64/bin/code \ |
| 8 | // VSIX=/tmp/vsix/diffinery-0.1.0.vsix xvfb-run -a pnpm --filter @diffinery/app exec node test/e2e/editor-smoke.mjs |
| 9 | // |
| 10 | // EDITOR_BIN is the editor's Electron binary, EDITOR_CLI its command-line tool (Cursor: usr/share/cursor/cursor and |
| 11 | // usr/share/cursor/bin/cursor in the extracted AppImage; Cursor also needs --skip-onboarding, which this passes last: VS Code would read the next argument as its value). SHOTS=dir |
| 12 | // saves screenshots of each step, plus the clone picker on its own (*-box.png, for the docs); THEME sets the color |
| 13 | // theme, such as "Default Light Modern", and SCALE the device scale factor. |
| 14 | import { spawn, execFileSync } from "node:child_process"; |
| 15 | import { createHash, randomBytes } from "node:crypto"; |
| 16 | import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, chmodSync } from "node:fs"; |
| 17 | import { createServer } from "node:net"; |
| 18 | import { tmpdir } from "node:os"; |
| 19 | import { join, dirname, resolve } from "node:path"; |
| 20 | import { fileURLToPath } from "node:url"; |
| 21 | import assert from "node:assert/strict"; |
| 22 | import { chromium, _electron } from "playwright"; |
| 23 | |
| 24 | const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); |
| 25 | const EDITOR = process.env.EDITOR_BIN; |
| 26 | const CLI = process.env.EDITOR_CLI; |
| 27 | const VSIX = process.env.VSIX; |
| 28 | const SHOTS = process.env.SHOTS; |
| 29 | if (!EDITOR || !CLI || !VSIX) throw new Error("set EDITOR_BIN, EDITOR_CLI and VSIX"); |
| 30 | const work = mkdtempSync(join(tmpdir(), "diffinery-editor-")); |
| 31 | const hash = (s) => createHash("sha256").update(s).digest("hex"); |
| 32 | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); |
| 33 | const ADA_SESSION = "ada-editor-session-0123456789abcdef"; |
| 34 | const ADA_TOKEN = `dfy_${randomBytes(24).toString("hex")}`; |
| 35 | let devLog = ""; |
| 36 | |
| 37 | const freePort = () => |
| 38 | new Promise((res) => { |
| 39 | const s = createServer(); |
| 40 | s.listen(0, "127.0.0.1", () => { |
| 41 | const { port } = s.address(); |
| 42 | s.close(() => res(port)); |
| 43 | }); |
| 44 | }); |
| 45 | async function until(what, fn, timeoutMs = 60_000) { |
| 46 | const end = Date.now() + timeoutMs; |
| 47 | while (Date.now() < end) { |
| 48 | const v = await fn(); |
| 49 | if (v) return v; |
| 50 | await sleep(300); |
| 51 | } |
| 52 | throw new Error(`timed out waiting for ${what}\n--- wrangler log tail ---\n${devLog.slice(-3000)}`); |
| 53 | } |
| 54 | const shot = async (page, name) => SHOTS && (mkdirSync(SHOTS, { recursive: true }), await page.screenshot({ path: join(SHOTS, `${name}.png`) })); |
| 55 | const shotPicker = async (page, name) => SHOTS && (await page.locator(".quick-input-widget:not([style*='display: none'])").screenshot({ path: join(SHOTS, `${name}-box.png`) })); |
| 56 | const wrangler = (...args) => execFileSync("pnpm", ["exec", "wrangler", ...args], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } }); |
| 57 | |
| 58 | // ---------------------------------------------------------------- a local Diffinery with ada and two repositories |
| 59 | const port = await freePort(); |
| 60 | const base = `http://localhost:${port}`; |
| 61 | const persist = join(work, "state"); |
| 62 | const sql = (command) => wrangler("d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command", command); |
| 63 | wrangler("d1", "migrations", "apply", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist); |
| 64 | const at = new Date().toISOString(); |
| 65 | const later = new Date(Date.now() + 86400_000).toISOString(); |
| 66 | sql( |
| 67 | [ |
| 68 | `INSERT INTO accounts (id, login, kind, name, email, status, created_at) VALUES ('ada1', 'ada', 'user', 'Ada', 'ada@example.com', 'active', '${at}')`, |
| 69 | `INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash(ADA_SESSION)}', 'ada1', 'passkey', '${at}', '${at}', '${later}')`, |
| 70 | `INSERT INTO tokens (id, account_id, name, token_hash, prefix, scopes, expires_at, created_at) VALUES ('t1', 'ada1', 'e2e', '${hash(ADA_TOKEN)}', '${ADA_TOKEN.slice(0, 10)}', '["admin"]', '${later}', '${at}')`, |
| 71 | ].join("; "), |
| 72 | ); |
| 73 | const dev = spawn( |
| 74 | "pnpm", |
| 75 | ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--var", "DEV_AUTH:", "--show-interactive-dev-session=false"], |
| 76 | { cwd: APP, env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, stdio: ["ignore", "pipe", "pipe"], detached: true }, |
| 77 | ); |
| 78 | dev.stdout.on("data", (d) => (devLog += d)); |
| 79 | dev.stderr.on("data", (d) => (devLog += d)); |
| 80 | const cleanup = () => { |
| 81 | try { |
| 82 | process.kill(-dev.pid, "SIGTERM"); |
| 83 | } catch {} |
| 84 | }; |
| 85 | process.on("exit", cleanup); |
| 86 | await until("wrangler dev", async () => (await fetch(`${base}/healthz`).catch(() => null))?.ok, 120_000); |
| 87 | console.log("worker up at", base); |
| 88 | |
| 89 | const api = (method, path, body) => fetch(`${base}/api/v3${path}`, { method, headers: { authorization: `Bearer ${ADA_TOKEN}`, "content-type": "application/json" }, body: body && JSON.stringify(body) }).then(async (r) => (r.ok ? r.json() : Promise.reject(new Error(`${method} ${path}: ${r.status} ${await r.text()}`)))); |
| 90 | await api("POST", "/user/repos", { name: "hello-editor", private: true, description: "The analytical engine, in TypeScript" }); |
| 91 | await api("POST", "/user/repos", { name: "notes", private: false, description: "Notes on the engine, and drafts of the next paper" }); |
| 92 | const seed = join(work, "seed"); |
| 93 | mkdirSync(seed); |
| 94 | const git = (cwd, ...args) => execFileSync("git", args, { cwd, stdio: "pipe", env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } }).toString(); |
| 95 | git(seed, "init", "-q", "-b", "main"); |
| 96 | writeFileSync(join(seed, "README.md"), "# hello-editor\n\nCloned from Diffinery in an editor.\n"); |
| 97 | git(seed, "add", "."); |
| 98 | git(seed, "-c", "user.name=Ada", "-c", "user.email=ada@example.com", "commit", "-qm", "First commit"); |
| 99 | git(seed, "push", "-q", `http://x:${ADA_TOKEN}@localhost:${port}/ada/hello-editor.git`, "main"); |
| 100 | |
| 101 | // ---------------------------------------------------------------- the editor |
| 102 | const home = join(work, "home"); |
| 103 | const clones = join(home, "src"); |
| 104 | const userData = join(work, "user-data"); |
| 105 | const exts = join(work, "extensions"); |
| 106 | mkdirSync(clones, { recursive: true }); |
| 107 | // Opened on a folder: Cursor shows its agent window instead of the editor until you log in to Cursor, unless a folder is open. |
| 108 | // The folder the editor opens: a local repository with a commit and no remote, for "Publish to Diffinery". |
| 109 | const workspace = join(home, "difference-engine"); |
| 110 | mkdirSync(workspace, { recursive: true }); |
| 111 | git(workspace, "init", "-q", "-b", "main"); |
| 112 | writeFileSync(join(workspace, "engine.ts"), "export const add = (a: number, b: number) => a + b;\n"); |
| 113 | git(workspace, "add", "."); |
| 114 | git(workspace, "-c", "user.name=Ada", "-c", "user.email=ada@example.com", "commit", "-qm", "Start the engine"); |
| 115 | mkdirSync(join(userData, "User"), { recursive: true }); |
| 116 | // Git saves the password the editor gave it, as a keychain would; the extension keeps that copy current. |
| 117 | writeFileSync(join(home, ".gitconfig"), `[credential]\n\thelper = store\n[user]\n\tname = Ada\n\temail = ada@example.com\n`); |
| 118 | writeFileSync( |
| 119 | join(userData, "User", "settings.json"), |
| 120 | JSON.stringify({ |
| 121 | "diffinery.serverUrl": base, |
| 122 | "diffinery.checkForUpdates": false, |
| 123 | "files.simpleDialog.enable": true, |
| 124 | "git.openAfterClone": "alwaysNewWindow", |
| 125 | "git.defaultCloneDirectory": clones, |
| 126 | "security.workspace.trust.enabled": false, |
| 127 | "workbench.startupEditor": "none", |
| 128 | "window.dialogStyle": "custom", |
| 129 | "window.titleBarStyle": "custom", |
| 130 | "telemetry.telemetryLevel": "off", |
| 131 | "update.mode": "none", |
| 132 | "extensions.autoCheckUpdates": false, |
| 133 | ...(process.env.THEME ? { "workbench.colorTheme": process.env.THEME } : {}), |
| 134 | }), |
| 135 | ); |
| 136 | // The browser the editor opens: record the URL, as a person's browser would receive it. |
| 137 | const opened = join(work, "opened.txt"); |
| 138 | // Electron opens links with xdg-open on Linux, which hands them to $BROWSER when there's no desktop session. |
| 139 | const bin = join(work, "bin"); |
| 140 | mkdirSync(bin); |
| 141 | const browserScript = join(bin, "xdg-open"); |
| 142 | writeFileSync(browserScript, `#!/bin/sh\necho "$1" >> ${opened}\n`); |
| 143 | chmodSync(browserScript, 0o755); |
| 144 | const env = { ...process.env, HOME: home, PATH: `${bin}:${process.env.PATH}`, BROWSER: browserScript, XDG_CURRENT_DESKTOP: "", DONT_PROMPT_WSL_INSTALL: "1" }; |
| 145 | // No OS keyring in a test container: the editor keeps secrets with its own weaker encryption instead. |
| 146 | const common = ["--user-data-dir", userData, "--extensions-dir", exts, "--no-sandbox", "--disable-gpu", "--password-store=basic", ...(process.env.SCALE ? [`--force-device-scale-factor=${process.env.SCALE}`] : [])]; |
| 147 | execFileSync(CLI, [...common, "--install-extension", VSIX, "--force"], { env, stdio: "pipe", timeout: 120_000 }); |
| 148 | console.log("extension installed"); |
| 149 | |
| 150 | const app = await _electron.launch({ executablePath: EDITOR, args: [workspace, ...common, "--disable-workspace-trust", "--skip-welcome", "--skip-release-notes", "--skip-onboarding"], env, timeout: 120_000 }); |
| 151 | const win = await app.firstWindow(); |
| 152 | // On a failure, a screenshot of the editor as it was. |
| 153 | async function fail(e) { |
| 154 | if (SHOTS) await win.screenshot({ path: join(SHOTS, "failed.png") }).catch(() => {}); |
| 155 | console.error(e); |
| 156 | cleanup(); |
| 157 | process.exit(1); |
| 158 | } |
| 159 | process.on("unhandledRejection", fail); |
| 160 | process.on("uncaughtException", fail); |
| 161 | await win.waitForSelector(".monaco-workbench", { timeout: 120_000 }); |
| 162 | console.log("editor window open"); |
| 163 | await sleep(4000); |
| 164 | const palette = async (cmd) => { |
| 165 | await win.keyboard.press("Control+Shift+P"); |
| 166 | await win.waitForSelector(".quick-input-widget:not([style*='display: none']) .quick-input-box input"); |
| 167 | await win.keyboard.type(cmd); |
| 168 | await sleep(500); |
| 169 | await win.keyboard.press("Enter"); |
| 170 | }; |
| 171 | const quickItems = async () => (await win.$$eval(".quick-input-list .monaco-list-row", (rows) => rows.map((r) => r.textContent?.trim() ?? ""))).filter(Boolean); |
| 172 | |
| 173 | // 1. The clone picker lists Diffinery as a remote source. |
| 174 | await palette("Git: Clone"); |
| 175 | await until("Clone from Diffinery in the picker", async () => (await quickItems()).some((t) => t.includes("Clone from Diffinery")), 30_000); |
| 176 | console.log("clone picker:", await quickItems()); |
| 177 | if (process.env.DEBUG_ICON) |
| 178 | console.log( |
| 179 | await win.evaluate(async () => { |
| 180 | const el = document.querySelector(".codicon-diffinery-logo"); |
| 181 | const st = el && getComputedStyle(el, "::before"); |
| 182 | return { content: st?.content, font: st?.fontFamily, fonts: [...document.fonts].map((f) => `${f.family} ${f.status}`).filter((f) => !/codicon|Segoe/.test(f)) }; |
| 183 | }), |
| 184 | ); |
| 185 | await shot(win, "1-clone-picker"); |
| 186 | await shotPicker(win, "1-clone-picker"); |
| 187 | await win.keyboard.type("Clone from Diffinery"); |
| 188 | await sleep(300); |
| 189 | await win.keyboard.press("Enter"); |
| 190 | |
| 191 | // 2. Signing in: the editor asks to allow the sign-in, then opens Diffinery's consent page in the browser. |
| 192 | const allow = await until("the sign-in prompt", async () => (await win.$("dialog, .monaco-dialog-box")) && win.$(".monaco-dialog-box .monaco-button:has-text('Allow')"), 30_000); |
| 193 | await shot(win, "2-allow-sign-in"); |
| 194 | await allow.click(); |
| 195 | const authorize = await until("the editor to open the browser", async () => existsSync(opened) && readFileSync(opened, "utf8").trim().split("\n").pop(), 30_000); |
| 196 | const au = new URL(authorize); |
| 197 | assert.equal(au.origin + au.pathname, `${base}/oauth/authorize`); |
| 198 | assert.equal(au.searchParams.get("client_id"), `${base}/oauth/clients/editor.json`); |
| 199 | const redirect = new URL(au.searchParams.get("redirect_uri")); |
| 200 | // The extension listens on 127.0.0.1 for the browser's answer (no prompts on the way back to the editor). |
| 201 | assert.equal(redirect.origin, `http://127.0.0.1:${redirect.port}`); |
| 202 | assert.equal(redirect.pathname, "/callback"); |
| 203 | console.log("authorize:", authorize); |
| 204 | |
| 205 | const browser = await chromium.launch(); |
| 206 | const ctx = await browser.newContext(); |
| 207 | const page = await ctx.newPage(); |
| 208 | await page.goto(`${base}/login`); |
| 209 | await page.evaluate((v) => (document.cookie = `__Host-dfy_session=${v}; path=/; secure; samesite=lax`), ADA_SESSION); |
| 210 | await page.goto(authorize); |
| 211 | const consent = ((await page.textContent("main")) ?? "").replace(/\s+/g, " "); |
| 212 | assert.match(consent, /Connect Diffinery extension for VS Code and Cursor to Diffinery/); |
| 213 | assert.match(consent, /published by localhost/); |
| 214 | assert.match(consent, /Sends you back to\s*an app on this computer/); |
| 215 | if (SHOTS) await page.screenshot({ path: join(SHOTS, "3-consent.png"), fullPage: true }); |
| 216 | await page.click('button[value="approve"]'); |
| 217 | await page.waitForURL(/^http:\/\/127\.0\.0\.1:\d+\/callback/); |
| 218 | assert.match(await page.textContent("body"), /You're signed in to Diffinery/); |
| 219 | if (SHOTS) await page.screenshot({ path: join(SHOTS, "3b-signed-in.png") }); |
| 220 | console.log("browser: signed in page shown"); |
| 221 | await browser.close(); |
| 222 | |
| 223 | // 3. Signed in: the picker lists ada's repositories; search narrows it. |
| 224 | await until("ada's repositories in the picker", async () => (await quickItems()).some((t) => t.includes("ada/hello-editor")), 60_000); |
| 225 | console.log("repositories:", await quickItems()); |
| 226 | await shot(win, "4-repositories"); |
| 227 | await shotPicker(win, "4-repositories"); |
| 228 | await win.keyboard.type("hello"); |
| 229 | await until("the search result", async () => { |
| 230 | const items = await quickItems(); |
| 231 | return items.length === 1 && items[0].includes("ada/hello-editor"); |
| 232 | }, 20_000); |
| 233 | await shot(win, "5-search"); |
| 234 | await win.keyboard.press("Enter"); |
| 235 | |
| 236 | // 4. Choosing where to clone (the editor's own folder dialog), then Git clones with the extension's password. |
| 237 | const destination = win.getByText("Select as Repository Destination", { exact: true }); |
| 238 | await destination.waitFor({ timeout: 30_000 }); |
| 239 | await sleep(500); |
| 240 | await shot(win, "6-folder"); |
| 241 | await destination.click(); |
| 242 | const clonedReadme = join(clones, "hello-editor", "README.md"); |
| 243 | await until("the clone on disk", async () => existsSync(clonedReadme), 90_000).catch(async (e) => { |
| 244 | await shot(win, "6b-folder-stuck"); |
| 245 | throw e; |
| 246 | }); |
| 247 | assert.match(readFileSync(clonedReadme, "utf8"), /Cloned from Diffinery/); |
| 248 | console.log("cloned:", clonedReadme); |
| 249 | const stored = readFileSync(join(home, ".git-credentials"), "utf8"); |
| 250 | assert.match(stored, new RegExp(`^http://ada:dfy_[^@]+@localhost%3a${port}$|^http://ada:dfy_[^@]+@localhost:${port}$`, "m")); |
| 251 | assert.ok(!stored.includes(ADA_TOKEN), "the saved credential is the editor's own token"); |
| 252 | console.log("git credential saved for ada"); |
| 253 | await sleep(4000); |
| 254 | const cloneWin = app.windows().at(-1); |
| 255 | await cloneWin.waitForSelector(".monaco-workbench", { timeout: 60_000 }); |
| 256 | await sleep(3000); |
| 257 | await shot(cloneWin, "7-opened"); |
| 258 | |
| 259 | // 5. Publishing the open folder: Git's "Publish Branch" offers Diffinery, which creates the repository and pushes. |
| 260 | await palette("Git: Publish Branch"); |
| 261 | await until("Publish to Diffinery in the picker", async () => (await quickItems()).some((t) => t.includes("Publish to Diffinery")), 30_000); |
| 262 | console.log("publish picker:", await quickItems()); |
| 263 | await win.keyboard.type("Diffinery"); |
| 264 | await sleep(300); |
| 265 | await win.keyboard.press("Enter"); |
| 266 | await until("the repository name box", async () => (await win.inputValue(".quick-input-widget:not([style*='display: none']) .quick-input-box input").catch(() => "")) === "difference-engine", 30_000); |
| 267 | await shot(win, "8-publish-name"); |
| 268 | await win.keyboard.press("Enter"); |
| 269 | await until("the visibility choice", async () => (await quickItems()).some((t) => t.includes("Private")), 20_000); |
| 270 | await win.keyboard.press("Enter"); |
| 271 | const published = await until("the pushed branch on Diffinery", async () => (await api("GET", "/repos/ada/difference-engine/branches").catch(() => null))?.find((b) => b.name === "main"), 60_000); |
| 272 | assert.equal(published.commit.sha, git(workspace, "rev-parse", "HEAD").trim()); |
| 273 | assert.equal((await api("GET", "/repos/ada/difference-engine")).private, true); |
| 274 | assert.match(git(workspace, "remote", "get-url", "origin"), /\/ada\/difference-engine\.git/); |
| 275 | await shot(win, "9-published"); |
| 276 | console.log("published: ada/difference-engine, private, main pushed"); |
| 277 | |
| 278 | // 6. Signing out removes Git's saved copy and disconnects the editor on the server. |
| 279 | await palette("Diffinery: Sign Out"); |
| 280 | await until("the saved credential to go", async () => !readFileSync(join(home, ".git-credentials"), "utf8").includes("ada:"), 20_000); |
| 281 | const grants = JSON.parse(wrangler("d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--json", "--command", "SELECT revoked_at FROM oauth_grants").toString())[0].results; |
| 282 | assert.ok(grants.length === 1 && grants[0].revoked_at, "the connection is revoked"); |
| 283 | console.log("signed out: credential removed, connection revoked"); |
| 284 | |
| 285 | await app.close(); |
| 286 | cleanup(); |
| 287 | if (process.env.KEEP !== "1") rmSync(work, { recursive: true, force: true }); |
| 288 | console.log("editor smoke test passed"); |
| 289 | process.exit(0); |