Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

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