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.

289 lines · 16.7 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 { createServer } from "node:net";
18import { tmpdir } from "node:os";
19import { join, dirname, resolve } from "node:path";
20import { fileURLToPath } from "node:url";
21import assert from "node:assert/strict";
22import { chromium, _electron } from "playwright";
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")}`;
35let devLog = "";
36
37const 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 });
45async 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}
54const shot = async (page, name) => SHOTS && (mkdirSync(SHOTS, { recursive: true }), await page.screenshot({ path: join(SHOTS, `${name}.png`) }));
55const shotPicker = async (page, name) => SHOTS && (await page.locator(".quick-input-widget:not([style*='display: none'])").screenshot({ path: join(SHOTS, `${name}-box.png`) }));
56const 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
59const port = await freePort();
60const base = `http://localhost:${port}`;
61const persist = join(work, "state");
62const sql = (command) => wrangler("d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command", command);
63wrangler("d1", "migrations", "apply", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist);
64const at = new Date().toISOString();
65const later = new Date(Date.now() + 86400_000).toISOString();
66sql(
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);
73const 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);
78dev.stdout.on("data", (d) => (devLog += d));
79dev.stderr.on("data", (d) => (devLog += d));
80const cleanup = () => {
81 try {
82 process.kill(-dev.pid, "SIGTERM");
83 } catch {}
84};
85process.on("exit", cleanup);
86await until("wrangler dev", async () => (await fetch(`${base}/healthz`).catch(() => null))?.ok, 120_000);
87console.log("worker up at", base);
88
89const 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()}`))));
90await api("POST", "/user/repos", { name: "hello-editor", private: true, description: "The analytical engine, in TypeScript" });
91await api("POST", "/user/repos", { name: "notes", private: false, description: "Notes on the engine, and drafts of the next paper" });
92const seed = join(work, "seed");
93mkdirSync(seed);
94const git = (cwd, ...args) => execFileSync("git", args, { cwd, stdio: "pipe", env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } }).toString();
95git(seed, "init", "-q", "-b", "main");
96writeFileSync(join(seed, "README.md"), "# hello-editor\n\nCloned from Diffinery in an editor.\n");
97git(seed, "add", ".");
98git(seed, "-c", "user.name=Ada", "-c", "user.email=ada@example.com", "commit", "-qm", "First commit");
99git(seed, "push", "-q", `http://x:${ADA_TOKEN}@localhost:${port}/ada/hello-editor.git`, "main");
100
101// ---------------------------------------------------------------- the editor
102const home = join(work, "home");
103const clones = join(home, "src");
104const userData = join(work, "user-data");
105const exts = join(work, "extensions");
106mkdirSync(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".
109const workspace = join(home, "difference-engine");
110mkdirSync(workspace, { recursive: true });
111git(workspace, "init", "-q", "-b", "main");
112writeFileSync(join(workspace, "engine.ts"), "export const add = (a: number, b: number) => a + b;\n");
113git(workspace, "add", ".");
114git(workspace, "-c", "user.name=Ada", "-c", "user.email=ada@example.com", "commit", "-qm", "Start the engine");
115mkdirSync(join(userData, "User"), { recursive: true });
116// Git saves the password the editor gave it, as a keychain would; the extension keeps that copy current.
117writeFileSync(join(home, ".gitconfig"), `[credential]\n\thelper = store\n[user]\n\tname = Ada\n\temail = ada@example.com\n`);
118writeFileSync(
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.
137const 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.
139const bin = join(work, "bin");
140mkdirSync(bin);
141const browserScript = join(bin, "xdg-open");
142writeFileSync(browserScript, `#!/bin/sh\necho "$1" >> ${opened}\n`);
143chmodSync(browserScript, 0o755);
144const 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.
146const 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}`] : [])];
147execFileSync(CLI, [...common, "--install-extension", VSIX, "--force"], { env, stdio: "pipe", timeout: 120_000 });
148console.log("extension installed");
149
150const app = await _electron.launch({ executablePath: EDITOR, args: [workspace, ...common, "--disable-workspace-trust", "--skip-welcome", "--skip-release-notes", "--skip-onboarding"], env, timeout: 120_000 });
151const win = await app.firstWindow();
152// On a failure, a screenshot of the editor as it was.
153async 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}
159process.on("unhandledRejection", fail);
160process.on("uncaughtException", fail);
161await win.waitForSelector(".monaco-workbench", { timeout: 120_000 });
162console.log("editor window open");
163await sleep(4000);
164const 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};
171const 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.
174await palette("Git: Clone");
175await until("Clone from Diffinery in the picker", async () => (await quickItems()).some((t) => t.includes("Clone from Diffinery")), 30_000);
176console.log("clone picker:", await quickItems());
177if (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 );
185await shot(win, "1-clone-picker");
186await shotPicker(win, "1-clone-picker");
187await win.keyboard.type("Clone from Diffinery");
188await sleep(300);
189await 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.
192const 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);
193await shot(win, "2-allow-sign-in");
194await allow.click();
195const authorize = await until("the editor to open the browser", async () => existsSync(opened) && readFileSync(opened, "utf8").trim().split("\n").pop(), 30_000);
196const au = new URL(authorize);
197assert.equal(au.origin + au.pathname, `${base}/oauth/authorize`);
198assert.equal(au.searchParams.get("client_id"), `${base}/oauth/clients/editor.json`);
199const 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).
201assert.equal(redirect.origin, `http://127.0.0.1:${redirect.port}`);
202assert.equal(redirect.pathname, "/callback");
203console.log("authorize:", authorize);
204
205const browser = await chromium.launch();
206const ctx = await browser.newContext();
207const page = await ctx.newPage();
208await page.goto(`${base}/login`);
209await page.evaluate((v) => (document.cookie = `__Host-dfy_session=${v}; path=/; secure; samesite=lax`), ADA_SESSION);
210await page.goto(authorize);
211const consent = ((await page.textContent("main")) ?? "").replace(/\s+/g, " ");
212assert.match(consent, /Connect Diffinery extension for VS Code and Cursor to Diffinery/);
213assert.match(consent, /published by localhost/);
214assert.match(consent, /Sends you back to\s*an app on this computer/);
215if (SHOTS) await page.screenshot({ path: join(SHOTS, "3-consent.png"), fullPage: true });
216await page.click('button[value="approve"]');
217await page.waitForURL(/^http:\/\/127\.0\.0\.1:\d+\/callback/);
218assert.match(await page.textContent("body"), /You're signed in to Diffinery/);
219if (SHOTS) await page.screenshot({ path: join(SHOTS, "3b-signed-in.png") });
220console.log("browser: signed in page shown");
221await browser.close();
222
223// 3. Signed in: the picker lists ada's repositories; search narrows it.
224await until("ada's repositories in the picker", async () => (await quickItems()).some((t) => t.includes("ada/hello-editor")), 60_000);
225console.log("repositories:", await quickItems());
226await shot(win, "4-repositories");
227await shotPicker(win, "4-repositories");
228await win.keyboard.type("hello");
229await until("the search result", async () => {
230 const items = await quickItems();
231 return items.length === 1 && items[0].includes("ada/hello-editor");
232}, 20_000);
233await shot(win, "5-search");
234await 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.
237const destination = win.getByText("Select as Repository Destination", { exact: true });
238await destination.waitFor({ timeout: 30_000 });
239await sleep(500);
240await shot(win, "6-folder");
241await destination.click();
242const clonedReadme = join(clones, "hello-editor", "README.md");
243await until("the clone on disk", async () => existsSync(clonedReadme), 90_000).catch(async (e) => {
244 await shot(win, "6b-folder-stuck");
245 throw e;
246});
247assert.match(readFileSync(clonedReadme, "utf8"), /Cloned from Diffinery/);
248console.log("cloned:", clonedReadme);
249const stored = readFileSync(join(home, ".git-credentials"), "utf8");
250assert.match(stored, new RegExp(`^http://ada:dfy_[^@]+@localhost%3a${port}$|^http://ada:dfy_[^@]+@localhost:${port}$`, "m"));
251assert.ok(!stored.includes(ADA_TOKEN), "the saved credential is the editor's own token");
252console.log("git credential saved for ada");
253await sleep(4000);
254const cloneWin = app.windows().at(-1);
255await cloneWin.waitForSelector(".monaco-workbench", { timeout: 60_000 });
256await sleep(3000);
257await shot(cloneWin, "7-opened");
258
259// 5. Publishing the open folder: Git's "Publish Branch" offers Diffinery, which creates the repository and pushes.
260await palette("Git: Publish Branch");
261await until("Publish to Diffinery in the picker", async () => (await quickItems()).some((t) => t.includes("Publish to Diffinery")), 30_000);
262console.log("publish picker:", await quickItems());
263await win.keyboard.type("Diffinery");
264await sleep(300);
265await win.keyboard.press("Enter");
266await 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);
267await shot(win, "8-publish-name");
268await win.keyboard.press("Enter");
269await until("the visibility choice", async () => (await quickItems()).some((t) => t.includes("Private")), 20_000);
270await win.keyboard.press("Enter");
271const 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);
272assert.equal(published.commit.sha, git(workspace, "rev-parse", "HEAD").trim());
273assert.equal((await api("GET", "/repos/ada/difference-engine")).private, true);
274assert.match(git(workspace, "remote", "get-url", "origin"), /\/ada\/difference-engine\.git/);
275await shot(win, "9-published");
276console.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.
279await palette("Diffinery: Sign Out");
280await until("the saved credential to go", async () => !readFileSync(join(home, ".git-credentials"), "utf8").includes("ada:"), 20_000);
281const 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;
282assert.ok(grants.length === 1 && grants[0].revoked_at, "the connection is revoked");
283console.log("signed out: credential removed, connection revoked");
284
285await app.close();
286cleanup();
287if (process.env.KEEP !== "1") rmSync(work, { recursive: true, force: true });
288console.log("editor smoke test passed");
289process.exit(0);