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.

234 lines · 13.0 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). SHOTS=dir saves screenshots of each step.
12import { spawn, execFileSync } from "node:child_process";
13import { createHash, randomBytes } from "node:crypto";
14import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, chmodSync } from "node:fs";
15import { createServer } from "node:net";
16import { tmpdir } from "node:os";
17import { join, dirname, resolve } from "node:path";
18import { fileURLToPath } from "node:url";
19import assert from "node:assert/strict";
20import { chromium, _electron } from "playwright";
21
22const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
23const EDITOR = process.env.EDITOR_BIN;
24const CLI = process.env.EDITOR_CLI;
25const VSIX = process.env.VSIX;
26const SHOTS = process.env.SHOTS;
27if (!EDITOR || !CLI || !VSIX) throw new Error("set EDITOR_BIN, EDITOR_CLI and VSIX");
28const work = mkdtempSync(join(tmpdir(), "diffinery-editor-"));
29const hash = (s) => createHash("sha256").update(s).digest("hex");
30const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
31const ADA_SESSION = "ada-editor-session-0123456789abcdef";
32const ADA_TOKEN = `dfy_${randomBytes(24).toString("hex")}`;
33let devLog = "";
34
35const freePort = () =>
36 new Promise((res) => {
37 const s = createServer();
38 s.listen(0, "127.0.0.1", () => {
39 const { port } = s.address();
40 s.close(() => res(port));
41 });
42 });
43async function until(what, fn, timeoutMs = 60_000) {
44 const end = Date.now() + timeoutMs;
45 while (Date.now() < end) {
46 const v = await fn();
47 if (v) return v;
48 await sleep(300);
49 }
50 throw new Error(`timed out waiting for ${what}\n--- wrangler log tail ---\n${devLog.slice(-3000)}`);
51}
52const shot = async (page, name) => SHOTS && (mkdirSync(SHOTS, { recursive: true }), await page.screenshot({ path: join(SHOTS, `${name}.png`) }));
53const wrangler = (...args) => execFileSync("pnpm", ["exec", "wrangler", ...args], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } });
54
55// ---------------------------------------------------------------- a local Diffinery with ada and two repositories
56const port = await freePort();
57const base = `http://localhost:${port}`;
58const persist = join(work, "state");
59const sql = (command) => wrangler("d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command", command);
60wrangler("d1", "migrations", "apply", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist);
61const at = new Date().toISOString();
62const later = new Date(Date.now() + 86400_000).toISOString();
63sql(
64 [
65 `INSERT INTO accounts (id, login, kind, name, email, status, created_at) VALUES ('ada1', 'ada', 'user', 'Ada', 'ada@example.com', 'active', '${at}')`,
66 `INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash(ADA_SESSION)}', 'ada1', 'passkey', '${at}', '${at}', '${later}')`,
67 `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}')`,
68 ].join("; "),
69);
70const dev = spawn(
71 "pnpm",
72 ["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"],
73 { cwd: APP, env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, stdio: ["ignore", "pipe", "pipe"], detached: true },
74);
75dev.stdout.on("data", (d) => (devLog += d));
76dev.stderr.on("data", (d) => (devLog += d));
77const cleanup = () => {
78 try {
79 process.kill(-dev.pid, "SIGTERM");
80 } catch {}
81};
82process.on("exit", cleanup);
83await until("wrangler dev", async () => (await fetch(`${base}/healthz`).catch(() => null))?.ok, 120_000);
84console.log("worker up at", base);
85
86const 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()}`))));
87await api("POST", "/user/repos", { name: "hello-editor", private: true, description: "Private repository the editor clones" });
88await api("POST", "/user/repos", { name: "notes", private: false, description: "Another one" });
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 });
104mkdirSync(join(userData, "User"), { recursive: true });
105// Git saves the password the editor gave it, as a keychain would; the extension keeps that copy current.
106writeFileSync(join(home, ".gitconfig"), `[credential]\n\thelper = store\n[user]\n\tname = Ada\n\temail = ada@example.com\n`);
107writeFileSync(
108 join(userData, "User", "settings.json"),
109 JSON.stringify({
110 "diffinery.serverUrl": base,
111 "diffinery.checkForUpdates": false,
112 "files.simpleDialog.enable": true,
113 "git.openAfterClone": "always",
114 "git.defaultCloneDirectory": clones,
115 "security.workspace.trust.enabled": false,
116 "workbench.startupEditor": "none",
117 "window.dialogStyle": "custom",
118 "window.titleBarStyle": "custom",
119 "telemetry.telemetryLevel": "off",
120 "update.mode": "none",
121 "extensions.autoCheckUpdates": false,
122 }),
123);
124// The browser the editor opens: record the URL, as a person's browser would receive it.
125const opened = join(work, "opened.txt");
126const browserScript = join(work, "browser.sh");
127writeFileSync(browserScript, `#!/bin/sh\necho "$1" >> ${opened}\n`);
128chmodSync(browserScript, 0o755);
129const env = { ...process.env, HOME: home, BROWSER: browserScript, XDG_CURRENT_DESKTOP: "", DONT_PROMPT_WSL_INSTALL: "1" };
130const common = ["--user-data-dir", userData, "--extensions-dir", exts, "--no-sandbox", "--disable-gpu"];
131execFileSync(CLI, [...common, "--install-extension", VSIX, "--force"], { env, stdio: "pipe", timeout: 120_000 });
132console.log("extension installed");
133
134const app = await _electron.launch({ executablePath: EDITOR, args: [...common, "--disable-workspace-trust", "--skip-welcome", "--skip-release-notes", "--new-window"], env, timeout: 120_000 });
135const win = await app.firstWindow();
136await win.waitForSelector(".monaco-workbench", { timeout: 120_000 });
137console.log("editor window open");
138await sleep(4000);
139const palette = async (cmd) => {
140 await win.keyboard.press("Control+Shift+P");
141 await win.waitForSelector(".quick-input-widget:not([style*='display: none']) input");
142 await win.keyboard.type(cmd);
143 await sleep(500);
144 await win.keyboard.press("Enter");
145};
146const quickItems = async () => (await win.$$eval(".quick-input-list .monaco-list-row", (rows) => rows.map((r) => r.textContent?.trim() ?? ""))).filter(Boolean);
147
148// 1. The clone picker lists Diffinery as a remote source.
149await palette("Git: Clone");
150await until("Clone from Diffinery in the picker", async () => (await quickItems()).some((t) => t.includes("Clone from Diffinery")), 30_000);
151console.log("clone picker:", await quickItems());
152await shot(win, "1-clone-picker");
153await win.keyboard.type("Clone from Diffinery");
154await sleep(300);
155await win.keyboard.press("Enter");
156
157// 2. Signing in: the editor asks to allow the sign-in, then opens Diffinery's consent page in the browser.
158const 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);
159await shot(win, "2-allow-sign-in");
160await allow.click();
161const authorize = await until("the editor to open the browser", async () => existsSync(opened) && readFileSync(opened, "utf8").trim().split("\n").pop(), 30_000);
162const au = new URL(authorize);
163assert.equal(au.origin + au.pathname, `${base}/oauth/authorize`);
164assert.equal(au.searchParams.get("client_id"), `${base}/oauth/clients/editor.json`);
165const scheme = au.searchParams.get("redirect_uri").split(":")[0];
166console.log("authorize:", authorize);
167
168const browser = await chromium.launch();
169const ctx = await browser.newContext();
170let callback;
171// The consent form's answer redirects to the editor's own scheme, which the browser hands to the operating system.
172ctx.on("response", (r) => {
173 const loc = r.headers().location;
174 if (loc?.startsWith(`${scheme}://`)) callback = loc;
175});
176const page = await ctx.newPage();
177await page.goto(`${base}/login`);
178await page.evaluate((v) => (document.cookie = `__Host-dfy_session=${v}; path=/; secure; samesite=lax`), ADA_SESSION);
179await page.goto(authorize);
180const consent = ((await page.textContent("main")) ?? "").replace(/\s+/g, " ");
181assert.match(consent, /Connect Diffinery extension for VS Code and Cursor to Diffinery/);
182assert.match(consent, /published by localhost/);
183if (SHOTS) await page.screenshot({ path: join(SHOTS, "3-consent.png"), fullPage: true });
184await page.click('button[value="approve"]', { noWaitAfter: true }).catch(() => {});
185// The browser hands the editor's own scheme to the operating system, which passes it to the editor.
186const cb = await until("the redirect back to the editor", async () => callback, 30_000);
187console.log("callback:", cb.replace(/code=[^&]+/, "code=…"));
188await browser.close();
189execFileSync(CLI, [...common, "--open-url", cb], { env, stdio: "pipe" });
190
191// 3. Signed in: the picker lists ada's repositories; search narrows it.
192await until("ada's repositories in the picker", async () => (await quickItems()).some((t) => t.includes("ada/hello-editor")), 60_000);
193console.log("repositories:", await quickItems());
194await shot(win, "4-repositories");
195await win.keyboard.type("hello");
196await until("the search result", async () => {
197 const items = await quickItems();
198 return items.length === 1 && items[0].includes("ada/hello-editor");
199}, 20_000);
200await shot(win, "5-search");
201await win.keyboard.press("Enter");
202
203// 4. Choosing where to clone (the editor's own folder dialog), then Git clones with the extension's password.
204await until("the folder dialog", async () => (await quickItems()).length > 0 || (await win.$(".quick-input-widget .quick-input-action")), 30_000).catch(() => {});
205await sleep(800);
206await shot(win, "6-folder");
207await win.keyboard.press("Enter");
208await sleep(500);
209const clonedReadme = join(clones, "hello-editor", "README.md");
210await until("the clone on disk", async () => existsSync(clonedReadme), 90_000).catch(async (e) => {
211 await shot(win, "6b-folder-stuck");
212 throw e;
213});
214assert.match(readFileSync(clonedReadme, "utf8"), /Cloned from Diffinery/);
215console.log("cloned:", clonedReadme);
216const stored = readFileSync(join(home, ".git-credentials"), "utf8");
217assert.match(stored, new RegExp(`^http://ada:dfy_[^@]+@localhost%3a${port}$|^http://ada:dfy_[^@]+@localhost:${port}$`, "m"));
218assert.ok(!stored.includes(ADA_TOKEN), "the saved credential is the editor's own token");
219console.log("git credential saved for ada");
220await sleep(3000);
221await shot(win, "7-opened");
222
223// 5. Signing out removes Git's saved copy and disconnects the editor on the server.
224await palette("Diffinery: Sign Out");
225await until("the saved credential to go", async () => !readFileSync(join(home, ".git-credentials"), "utf8").includes("ada:"), 20_000);
226const 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;
227assert.ok(grants.length === 1 && grants[0].revoked_at, "the connection is revoked");
228console.log("signed out: credential removed, connection revoked");
229
230await app.close();
231cleanup();
232if (process.env.KEEP !== "1") rmSync(work, { recursive: true, force: true });
233console.log("editor smoke test passed");
234process.exit(0);