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.

389 lines · 28.4 KBRaw History
1// On-demand check of the JetBrains plugin (apps/jetbrains) in a real IntelliJ IDEA, against a real `wrangler dev`
2// Worker: "Diffinery" in Get from Version Control, the browser sign-in through Diffinery's OAuth consent page, the
3// repository list and search, a clone of a private repository with Git asking the plugin for the password, Share
4// Project on Diffinery, signing in with a pasted token, and signing out. Not part of `pnpm e2e` (it needs an IDE and
5// a display). The IDE installs the plugin the way people do, from the Worker's plugin repository
6// (/-/jetbrains/updatePlugins.xml, serving apps/jetbrains/dist), and is driven through JetBrains' Remote Robot server
7// plugin, whose scripts run inside the IDE:
8//
9// (cd apps/jetbrains && ./gradlew dist) && node scripts/build-docs.mjs
10// IDE_HOME=/path/to/idea-IU-262.x ROBOT=/path/to/robot-server-plugin-0.11.23.zip \
11// pnpm --filter @diffinery/app exec node test/e2e/jetbrains-smoke.mjs
12//
13// It starts its own Xvfb display. SHOTS=dir saves screenshots of each step, and SCALE=2 renders the IDE at 2x. DOCS=1
14// takes them for the docs: the home folder is /home/ada (it must not exist) and the pictures name diffinery.com, not
15// the local Worker's address.
16import { spawn, execFileSync } from "node:child_process";
17import { createHash, randomBytes } from "node:crypto";
18import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, chmodSync, copyFileSync } from "node:fs";
19import { tmpdir } from "node:os";
20import { join, dirname, resolve } from "node:path";
21import { fileURLToPath } from "node:url";
22import assert from "node:assert/strict";
23import { chromium } from "playwright";
24import { freePort } from "./ports.mjs";
25
26const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
27const IDE = process.env.IDE_HOME;
28const ROBOT = process.env.ROBOT && resolve(process.env.ROBOT);
29const SHOTS = process.env.SHOTS;
30const DOCS = process.env.DOCS === "1";
31const SCALE = Number(process.env.SCALE || (DOCS ? 2 : 1));
32if (!IDE || !ROBOT) throw new Error("set IDE_HOME and ROBOT");
33const work = mkdtempSync(join(tmpdir(), "diffinery-jetbrains-"));
34const hash = (s) => createHash("sha256").update(s).digest("hex");
35const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
36const ADA_SESSION = "ada-jetbrains-session-0123456789abcdef";
37const ADA_TOKEN = `dfy_${randomBytes(24).toString("hex")}`;
38const CHARLES_TOKEN = `dfy_${randomBytes(24).toString("hex")}`;
39let devLog = "";
40
41async function until(what, fn, timeoutMs = 60_000) {
42 const end = Date.now() + timeoutMs;
43 let last;
44 while (Date.now() < end) {
45 try {
46 const v = await fn();
47 if (v) return v;
48 } catch (e) {
49 last = e;
50 }
51 await sleep(400);
52 }
53 throw new Error(`timed out waiting for ${what}${last ? ` (last error: ${last.message})` : ""}`);
54}
55const wrangler = (...args) => execFileSync("pnpm", ["exec", "wrangler", ...args], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } });
56const children = [];
57const cleanup = () => {
58 for (const c of children.reverse())
59 try {
60 process.kill(-c.pid, "SIGTERM");
61 } catch {}
62};
63process.on("exit", cleanup);
64
65// ---------------------------------------------------------------- a local Diffinery with ada, charles and repositories
66const port = await freePort();
67const base = `http://localhost:${port}`;
68const persist = join(work, "state");
69const sql = (command) => wrangler("d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command", command);
70wrangler("d1", "migrations", "apply", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist);
71const at = new Date().toISOString();
72const later = new Date(Date.now() + 86400_000).toISOString();
73sql(
74 [
75 `INSERT INTO accounts (id, login, kind, name, email, status, created_at) VALUES ('ada1', 'ada', 'user', 'Ada', 'ada@example.com', 'active', '${at}')`,
76 `INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash(ADA_SESSION)}', 'ada1', 'passkey', '${at}', '${at}', '${later}')`,
77 `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}')`,
78 `INSERT INTO accounts (id, login, kind, name, email, status, created_at) VALUES ('cb1', 'charles', 'user', 'Charles', 'charles@example.com', 'active', '${at}')`,
79 `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}')`,
80 ].join("; "),
81);
82const dev = spawn(
83 "pnpm",
84 ["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"],
85 { cwd: APP, env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, stdio: ["ignore", "pipe", "pipe"], detached: true },
86);
87children.push(dev);
88dev.stdout.on("data", (d) => (devLog += d));
89dev.stderr.on("data", (d) => (devLog += d));
90await until("wrangler dev", async () => (await fetch(`${base}/healthz`).catch(() => null))?.ok, 120_000).catch((e) => {
91 throw new Error(`${e.message}\n${devLog.slice(-3000)}`);
92});
93console.log("worker up at", base);
94
95const api = (method, path, body, token = ADA_TOKEN) =>
96 fetch(`${base}/api/v3${path}`, { method, headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: body && JSON.stringify(body) }).then(async (r) =>
97 r.ok ? r.json() : Promise.reject(new Error(`${method} ${path}: ${r.status} ${await r.text()}`)),
98 );
99await api("POST", "/user/repos", { name: "hello-editor", private: true, description: "The analytical engine, in TypeScript" });
100await api("POST", "/user/repos", { name: "notes", private: false, description: "Notes on the engine, and drafts of the next paper" });
101await api("POST", "/user/repos", { name: "engine-drawings", private: true }, CHARLES_TOKEN);
102const git = (cwd, ...args) => execFileSync("git", args, { cwd, stdio: "pipe", env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } }).toString();
103const seed = join(work, "seed");
104mkdirSync(seed);
105git(seed, "init", "-q", "-b", "main");
106writeFileSync(join(seed, "README.md"), "# hello-editor\n\nCloned from Diffinery in a JetBrains IDE.\n");
107git(seed, "add", ".");
108git(seed, "-c", "user.name=Ada", "-c", "user.email=ada@example.com", "commit", "-qm", "First commit");
109git(seed, "push", "-q", `http://x:${ADA_TOKEN}@localhost:${port}/ada/hello-editor.git`, "main");
110
111// ---------------------------------------------------------------- the IDE
112const home = DOCS ? "/home/ada" : join(work, "home");
113if (DOCS && existsSync(home)) throw new Error(`${home} exists already`);
114const ide = join(work, "ide");
115for (const d of ["config/options", "system", "plugins", "log"]) mkdirSync(join(ide, d), { recursive: true });
116mkdirSync(home, { recursive: true });
117execFileSync("unzip", ["-q", ROBOT, "-d", join(ide, "plugins")]);
118// Git saves nothing itself here: every password must come from the plugin.
119writeFileSync(join(home, ".gitconfig"), "[user]\n\tname = Ada\n\temail = ada@example.com\n[credential]\n\thelper =\n");
120// The server, as someone would set it under Settings → Version Control → Diffinery for a self-hosted Diffinery.
121// No desktop keyring under Xvfb: keep passwords in the IDE's own KeePass file, as people without one do.
122writeFileSync(join(ide, "config/options/security.xml"), `<application>\n <component name="PasswordSafe">\n <option name="PROVIDER" value="KEEPASS" />\n </component>\n</application>\n`);
123writeFileSync(join(ide, "config/options/diffinery.xml"), `<application>\n <component name="Diffinery">\n <option name="server" value="${base}" />\n </component>\n</application>\n`);
124// A local repository with a commit and no remote, for Share Project on Diffinery.
125const engine = join(home, "difference-engine");
126mkdirSync(engine, { recursive: true });
127git(engine, "init", "-q", "-b", "main");
128writeFileSync(join(engine, "engine.ts"), "export const add = (a: number, b: number) => a + b;\n");
129git(engine, "add", ".");
130git(engine, "-c", "user.name=Ada", "-c", "user.email=ada@example.com", "commit", "-qm", "Start the engine");
131// An IDE project already (so opening it skips the New Project wizard), with .idea kept out of Git.
132mkdirSync(join(engine, ".idea"));
133writeFileSync(join(engine, ".git", "info", "exclude"), ".idea/\n");
134// The browser the IDE opens: record the URL, as a person's browser would receive it.
135const bin = join(work, "bin");
136mkdirSync(bin);
137const opened = join(work, "opened.txt");
138writeFileSync(join(bin, "xdg-open"), `#!/bin/sh\necho "$1" >> ${opened}\n`);
139chmodSync(join(bin, "xdg-open"), 0o755);
140const robotPort = await freePort();
141writeFileSync(join(ide, "idea.properties"), ["config", "system", "plugins", "log"].map((d) => `idea.${d}.path=${join(ide, d)}`).join("\n") + "\n");
142writeFileSync(
143 join(ide, "idea.vmoptions"),
144 readFileSync(join(IDE, "bin", "idea64.vmoptions"), "utf8") +
145 [
146 `-Drobot-server.port=${robotPort}`,
147 "-Djb.consents.confirmation.enabled=false",
148 "-Djb.privacy.policy.text=<!--999.999-->",
149 "-Didea.trust.all.projects=true",
150 "-Dide.show.tips.on.startup.default.value=false",
151 "-Didea.initially.ask.config=never",
152 "-Dide.newUsersOnboarding=false",
153 `-Dsun.java2d.uiScale=${SCALE}`,
154 `-Duser.home=${home}`,
155 ].join("\n") +
156 "\n",
157);
158const display = `:${100 + Math.floor(Math.random() * 400)}`;
159console.log(`IDE on display ${display}, robot server on port ${robotPort}`);
160const xvfb = spawn("Xvfb", [display, "-screen", "0", `${1600 * SCALE}x${1000 * SCALE}x24`, "-nolisten", "tcp"], { stdio: "ignore", detached: true });
161children.push(xvfb);
162await sleep(1000);
163const env = { ...process.env, HOME: home, DISPLAY: display, PATH: `${bin}:${process.env.PATH}`, BROWSER: join(bin, "xdg-open"), IDEA_PROPERTIES: join(ide, "idea.properties"), IDEA_VM_OPTIONS: join(ide, "idea.vmoptions"), JAVA_TOOL_OPTIONS: "", GIT_TERMINAL_PROMPT: "0" };
164// The plugin comes from this Worker's plugin repository (/-/jetbrains/updatePlugins.xml), as for people: through
165// Settings → Plugins (the default), or INSTALL=cli for the `idea installPlugins` command.
166const FEED = `${base}/-/jetbrains/updatePlugins.xml`;
167const CLI_INSTALL = process.env.INSTALL === "cli";
168if (CLI_INSTALL) {
169 const installed = execFileSync(join(IDE, "bin", "idea.sh"), ["installPlugins", "com.diffinery", FEED], { env, stdio: "pipe", timeout: 300_000 }).toString();
170 assert.ok(existsSync(join(ide, "plugins", "diffinery-jetbrains")), `installPlugins didn't install the plugin:\n${installed.slice(-2000)}`);
171}
172let ideLog = "";
173const ideLogText = () => (existsSync(join(ide, "log/idea.log")) ? readFileSync(join(ide, "log/idea.log"), "utf8") : "");
174const launched = ideLogText().length; // installPlugins logged before this
175const idea = spawn(join(IDE, "bin", "idea.sh"), [], { env, stdio: ["ignore", "pipe", "pipe"], detached: true });
176children.push(idea);
177idea.stdout.on("data", (d) => (ideLog += d));
178idea.stderr.on("data", (d) => (ideLog += d));
179
180/** A screenshot of the whole display, or of the window titled `title`. */
181async function shot(name, title) {
182 if (!SHOTS) return;
183 mkdirSync(SHOTS, { recursive: true });
184 const file = join(SHOTS, `${name}.png`);
185 // Tips the IDE shows over the window aren't part of the picture.
186 await js(`var b = button("Got It"); if (b) b.doClick(); "ok"`);
187 if (DOCS) await js(`var a = showing(); for (var i = 0; i < a.length; i++) if (a[i] instanceof javax.swing.JLabel && text(a[i]).indexOf(${JSON.stringify(new URL(base).host)}) >= 0) a[i].setText(text(a[i]).replace(${JSON.stringify(new URL(base).host)}, "diffinery.com")); "ok"`);
188 await sleep(300);
189 execFileSync("import", ["-display", display, "-window", "root", file]);
190 if (title) {
191 const b = JSON.parse(await js(`var w = windowTitled(${JSON.stringify(title)}); var p = w.getLocationOnScreen(); var s = w.getSize(); JSON.stringify([p.x, p.y, s.width, s.height])`));
192 const sx = SCALE;
193 execFileSync("convert", [file, "-crop", `${b[2] * sx}x${b[3] * sx}+${b[0] * sx}+${b[1] * sx}`, "+repage", file]);
194 }
195}
196
197// Scripts run inside the IDE (Rhino, with the IDE's classes). The prelude finds components in showing windows.
198const PRELUDE = `
199var App = com.intellij.openapi.application.ApplicationManager.getApplication();
200function walk(c, out) { out.push(c); if (c instanceof java.awt.Container) { var k = c.getComponents(); for (var i = 0; i < k.length; i++) walk(k[i], out); } return out; }
201function showing() { var out = []; var ws = java.awt.Window.getWindows(); for (var i = 0; i < ws.length; i++) if (ws[i].isShowing()) walk(ws[i], out); return out; }
202function find(pred) { var a = showing(); for (var i = 0; i < a.length; i++) if (a[i].isShowing() && pred(a[i])) return a[i]; return null; }
203function cls(c) { return String(c.getClass().getName()); }
204function text(c) { try { return String(c.getText()); } catch (e) { return ""; } }
205function button(t) { return find(function (c) { return c instanceof javax.swing.AbstractButton && text(c).replace(/<[^>]+>/g, "") === t; }); }
206function windowTitled(t) { var ws = java.awt.Window.getWindows(); for (var i = 0; i < ws.length; i++) { var w = ws[i]; if (w.isShowing() && w instanceof java.awt.Dialog && String(w.getTitle()) === t) return w; } return null; }
207function later(f) { App.invokeLater(new java.lang.Runnable({ run: f }), com.intellij.openapi.application.ModalityState.any()); }
208function click(t) { var b = button(t); if (!b) throw new Error("no button " + t); later(function () { b.doClick(); }); return "ok"; }
209function pluginClass(name) { return com.intellij.ide.plugins.PluginManagerCore.getPlugin(com.intellij.openapi.extensions.PluginId.getId("com.diffinery")).getPluginClassLoader().loadClass(name); }
210function accounts() { return pluginClass("com.diffinery.jetbrains.Accounts").getMethod("get").invoke(null); }
211function runAction(id, component) { var am = com.intellij.openapi.actionSystem.ActionManager.getInstance(); later(function () { am.tryToExecute(am.getAction(id), null, component || null, "DiffinerySmoke", true); }); return "ok"; }
212function repoList() { return find(function (c) { return c instanceof javax.swing.JList && String(c.getName()) === "diffinery.repositories"; }); }
213function repoNames() { var l = repoList(); if (!l) return "[]"; var out = []; for (var i = 0; i < l.getModel().getSize(); i++) out.push(String(l.getModel().getElementAt(i)).match(/fullName=([^,]+)/)[1]); return JSON.stringify(out); }
214`;
215/** Runs a script in the IDE (on the UI thread unless `edt` is false) and returns its result as a string. */
216async function js(script, edt = true) {
217 const r = await fetch(`http://127.0.0.1:${robotPort}/js/retrieveAny`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ script: `${PRELUDE}\nString(eval(${JSON.stringify(script)}))`, runInEdt: edt }) });
218 const j = await r.json();
219 if (j.status !== "SUCCESS") throw new Error(`IDE script failed: ${j.message ?? ""} ${String(j.exception ?? "").slice(0, 800)}`);
220 // A Java-serialized String: 0xACED0005, then TC_STRING (0x74) with a 2-byte length, or TC_LONGSTRING (0x7C) with 8.
221 const b = Buffer.from(j.bytes.map((x) => x & 255));
222 if (b[4] === 0x74) return b.subarray(7, 7 + b.readUInt16BE(5)).toString("utf8");
223 if (b[4] === 0x7c) return b.subarray(13, 13 + Number(b.readBigUInt64BE(5))).toString("utf8");
224 throw new Error("IDE script didn't return a string");
225}
226
227async function fail(e) {
228 await shot("failed").catch(() => {});
229 console.error(e);
230 // KEEP=1 leaves the IDE running to look around in (its robot server's port is printed at the start).
231 if (process.env.KEEP) return;
232 console.error("--- IDE log tail ---\n" + (existsSync(join(ide, "log/idea.log")) ? readFileSync(join(ide, "log/idea.log"), "utf8").split("\n").filter((l) => /diffinery|ERROR|SEVERE/i.test(l)).slice(-40).join("\n") : ideLog.slice(-3000)));
233 cleanup();
234 process.exit(1);
235}
236process.on("unhandledRejection", fail);
237process.on("uncaughtException", fail);
238
239// On its first start after installPlugins the IDE asks whether to trust the plugin, since it isn't JetBrains': a
240// person clicks Accept, here Enter (its default button) through java.awt.Robot, until the IDE loads the plugin.
241writeFileSync(join(work, "PressEnter.java"), "public class PressEnter { public static void main(String[] a) throws Exception { var r = new java.awt.Robot(); r.keyPress(10); r.keyRelease(10); r.delay(200); } }\n");
242if (CLI_INSTALL) await until("the IDE to load the plugin", async () => {
243 const log = ideLogText().slice(launched);
244 if (/Loaded custom plugins: .*Diffinery \(/.test(log)) return true;
245 if (/Disabled plugins|Marking plugins 'disabled': \[[^\]]*com\.diffinery/.test(log)) throw new Error("the IDE disabled the plugin");
246 if (/Computed user scale factor/.test(log)) execFileSync(join(IDE, "jbr/bin/java"), [join(work, "PressEnter.java")], { env: { ...env, JAVA_TOOL_OPTIONS: "" }, stdio: "ignore" });
247 await sleep(2000);
248}, 180_000);
249await until("the IDE's robot server", async () => (await fetch(`http://127.0.0.1:${robotPort}/hello`).catch(() => null))?.ok, 180_000);
250await until("the welcome screen", async () => (await js(`com.intellij.openapi.wm.impl.welcomeScreen.WelcomeFrame.getInstance() != null`)) === "true", 120_000);
251const diffineryPlugin = `com.intellij.ide.plugins.PluginManagerCore.getPlugin(com.intellij.openapi.extensions.PluginId.getId("com.diffinery"))`;
252if (!CLI_INSTALL) {
253 // 0. Settings → Plugins → ⚙ → Manage Plugin Repositories → the feed; then Marketplace → "Diffinery" → Install.
254 assert.equal(await js(`${diffineryPlugin} == null`), "true");
255 await js(`com.intellij.openapi.updateSettings.impl.UpdateSettings.getInstance().getStoredPluginHosts().add(${JSON.stringify(FEED)}); "ok"`);
256 await js(`later(function () { com.intellij.openapi.options.ShowSettingsUtil.getInstance().showSettingsDialog(null, com.intellij.ide.plugins.PluginManagerConfigurable); }); "ok"`);
257 const tabs = `find(function (c) { return c instanceof javax.swing.JTabbedPane && cls(c).indexOf("TabbedPaneHeaderComponent") >= 0; })`;
258 await until("the Plugins page", async () => (await js(`${tabs} != null`)) === "true", 30_000);
259 assert.equal(await js(`${tabs}.getTitleAt(0)`), "Marketplace");
260 await js(`${tabs}.setSelectedIndex(0); "ok"`);
261 const search = `find(function (c) { return c instanceof com.intellij.ui.SearchTextField && cls(c).indexOf("PluginsTab") >= 0; })`;
262 await until("the Marketplace search", async () => (await js(`${search} != null`)) === "true", 30_000);
263 await js(`${search}.setText("Diffinery"); "ok"`);
264 await until("Diffinery in the Marketplace results", async () => (await js(`button("Install") != null`)) === "true", 60_000);
265 await sleep(1500);
266 await shot("0-plugins", "Settings");
267 await js(`click("Install")`);
268 // The IDE warns that the plugin isn't JetBrains'; a person accepts.
269 await until("the third-party plugin notice", async () => (await js(`windowTitled("Third-Party Plugins Notice") != null`)) === "true", 30_000);
270 await js(`click("Accept")`);
271 await until("the plugin installed", async () => (await js(`button("Installed") != null`)) === "true", 60_000);
272 await js(`click("OK")`);
273 // It loads without restarting the IDE.
274 await until("the plugin loaded", async () => (await js(`var p = ${diffineryPlugin}; p != null && com.intellij.ide.plugins.PluginManagerCore.isLoaded(p.getPluginId())`)) === "true", 60_000);
275}
276console.log("IDE up, plugin", await js(`${diffineryPlugin}.getVersion()`));
277await sleep(3000);
278
279// 1. Get from Version Control lists Diffinery next to GitHub and GitLab.
280await js(`runAction("Vcs.VcsClone")`);
281// "Get from Version Control" until 2025.x, "Clone Repository" since.
282const CLONE = await until("Get from Version Control", async () => await js(`var w = windowTitled("Get from Version Control") || windowTitled("Clone Repository"); w ? w.getTitle() : ""`), 30_000);
283const sources = JSON.parse(
284 await js(`var l = find(function (c) { return cls(c).indexOf("VcsCloneDialogExtensionList") >= 0; }); var out = []; var m = l.getModel(); for (var i = 0; i < m.getSize(); i++) { var e = m.getElementAt(i); out.push(String(e.getName ? e.getName() : e)); } JSON.stringify(out)`),
285);
286console.log("Get from Version Control:", sources);
287assert.ok(sources.includes("Diffinery"), "Diffinery is a source");
288await js(`var l = find(function (c) { return cls(c).indexOf("VcsCloneDialogExtensionList") >= 0; }); var m = l.getModel(); for (var i = 0; i < m.getSize(); i++) if (String(m.getElementAt(i).getName()) === "Diffinery") l.setSelectedIndex(i); "ok"`);
289await until("the sign-in page", async () => (await js(`button("Sign In with Browser…") != null`)) === "true", 20_000);
290await sleep(500);
291await shot("1-get-from-vcs", CLONE);
292
293// 2. Signing in: the IDE opens Diffinery's consent page in the browser and listens on 127.0.0.1 for the answer.
294await js(`click("Sign In with Browser…")`);
295const authorize = await until("the IDE to open the browser", async () => existsSync(opened) && readFileSync(opened, "utf8").trim().split("\n").pop(), 30_000);
296const au = new URL(authorize);
297assert.equal(au.origin + au.pathname, `${base}/oauth/authorize`);
298assert.equal(au.searchParams.get("client_id"), `${base}/oauth/clients/jetbrains.json`);
299assert.match(au.searchParams.get("redirect_uri"), /^http:\/\/127\.0\.0\.1:\d+\/callback$/);
300const browser = await chromium.launch();
301const page = await (await browser.newContext()).newPage();
302await page.context().addCookies([{ name: "__Host-dfy_session", value: ADA_SESSION, url: base }]).catch(async () => {
303 await page.goto(`${base}/login`);
304 await page.evaluate((v) => (document.cookie = `__Host-dfy_session=${v}; path=/; secure; samesite=lax`), ADA_SESSION);
305});
306await page.goto(authorize);
307const consent = await page.textContent("main");
308assert.match(consent, /Connect Diffinery plugin for JetBrains IDEs/);
309assert.match(consent, /Sends you back to\s*an app on this computer/);
310if (SHOTS) await page.screenshot({ path: join(SHOTS, "2-consent.png"), fullPage: true });
311await page.click('button[value="approve"]');
312await page.waitForURL(/^http:\/\/127\.0\.0\.1:\d+\/callback/);
313assert.match(await page.textContent("body"), /You're signed in to Diffinery/);
314if (SHOTS) await page.screenshot({ path: join(SHOTS, "2b-signed-in.png") });
315await browser.close();
316console.log("browser: consent approved, signed-in page shown");
317
318// 3. Signed in: the page lists ada's repositories; search narrows them.
319await until("ada's repositories", async () => JSON.parse(await js(`repoNames()`)).length >= 2, 30_000);
320const repos = JSON.parse(await js(`repoNames()`));
321console.log("repositories:", repos);
322assert.deepEqual([...repos].sort(), ["ada/hello-editor", "ada/notes"]);
323await sleep(500);
324await shot("3-repositories", CLONE);
325await js(`var f = find(function (c) { return c instanceof com.intellij.ui.SearchTextField; }); f.setText("hello"); "ok"`);
326await until("the search result", async () => JSON.stringify(JSON.parse(await js(`repoNames()`))) === JSON.stringify(["ada/hello-editor"]), 20_000);
327const directory = await until("the directory", async () => {
328 const d = await js(`var l = repoList(); l.setSelectedIndex(0); var f = find(function (c) { return c instanceof com.intellij.openapi.ui.TextFieldWithBrowseButton; }); f ? f.getText() : ""`);
329 return d.endsWith("hello-editor") && d;
330});
331assert.equal(directory, join(home, "IdeaProjects", "hello-editor"));
332await sleep(300);
333await shot("4-search", CLONE);
334
335// 4. Clone: Git asks the plugin for the password (no prompt), and the IDE opens the project.
336await until("the Clone button enabled", async () => (await js(`var b = button("Clone"); b != null && b.isEnabled()`)) === "true", 10_000);
337await js(`click("Clone")`);
338const readme = join(directory, "README.md");
339await until("the clone on disk", async () => existsSync(readme), 120_000);
340assert.match(readFileSync(readme, "utf8"), /Cloned from Diffinery/);
341assert.match(git(directory, "remote", "get-url", "origin"), new RegExp(`^${base}/ada/hello-editor\\.git`));
342console.log("cloned:", directory);
343await until("the project window", async () => (await js(`var ps = com.intellij.openapi.project.ProjectManager.getInstance().getOpenProjects(); var n = []; for (var i = 0; i < ps.length; i++) n.push(String(ps[i].getName())); n.join(",")`)).includes("hello-editor"), 120_000);
344await sleep(4000);
345await shot("5-opened");
346
347// 5. Share Project on Diffinery: a local repository with no remote becomes ada/difference-engine, pushed.
348await js(`later(function () { com.intellij.ide.impl.ProjectUtil.openOrImport(java.nio.file.Paths.get(${JSON.stringify(engine)}), null, true); }); "ok"`);
349await until("the difference-engine project", async () => (await js(`var ps = com.intellij.openapi.project.ProjectManager.getInstance().getOpenProjects(); var n = []; for (var i = 0; i < ps.length; i++) n.push(String(ps[i].getName())); n.join(",")`)).includes("difference-engine"), 120_000);
350await until("Git in the project", async () => (await js(`var ps = com.intellij.openapi.project.ProjectManager.getInstance().getOpenProjects(); var p = null; for (var i = 0; i < ps.length; i++) if (String(ps[i].getName()) === "difference-engine") p = ps[i]; String(pluginClass("git4idea.repo.GitRepositoryManager").getMethod("getInstance", com.intellij.openapi.project.Project).invoke(null, p).getRepositories().size())`, false)) === "1", 60_000);
351await js(`var ps = com.intellij.openapi.project.ProjectManager.getInstance().getOpenProjects(); var p = null; for (var i = 0; i < ps.length; i++) if (String(ps[i].getName()) === "difference-engine") p = ps[i]; runAction("Diffinery.Share", com.intellij.openapi.wm.WindowManager.getInstance().getFrame(p).getRootPane())`);
352await until("the Share dialog", async () => (await js(`windowTitled("Share Project on Diffinery") != null`)) === "true", 30_000);
353assert.equal(await js(`var f = find(function (c) { return c instanceof com.intellij.ui.components.JBTextField && c.isShowing() && javax.swing.SwingUtilities.getWindowAncestor(c) === windowTitled("Share Project on Diffinery"); }); f.getText()`), "difference-engine");
354await shot("6-share", "Share Project on Diffinery");
355await js(`click("Share")`);
356const pushed = 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);
357assert.equal(pushed.commit.sha, git(engine, "rev-parse", "HEAD").trim());
358assert.equal((await api("GET", "/repos/ada/difference-engine")).private, true);
359assert.match(git(engine, "remote", "get-url", "origin").trim(), /\/ada\/difference-engine\.git$/);
360console.log("shared: ada/difference-engine, private, main pushed");
361await sleep(1500);
362await shot("7-shared");
363
364// 6. Signing out disconnects the IDE on Diffinery too.
365await js(`accounts().signOut(); "ok"`, false);
366const 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;
367assert.ok(grants.length === 1 && grants[0].revoked_at, "the connection is revoked");
368assert.equal(await js(`String(accounts().account())`, false), "null");
369console.log("signed out: connection revoked");
370
371// 7. Signing in with a pasted token (Clone from Diffinery opens Get from Version Control on the Diffinery page).
372await js(`runAction("Diffinery.Clone")`);
373await until("the sign-in page", async () => (await js(`button("Sign In with Browser…") != null`)) === "true", 30_000);
374await js(`var l = find(function (c) { return c instanceof com.intellij.ui.components.ActionLink && text(c) === "Use an access token…"; }); later(function () { l.doClick(); }); "ok"`);
375await until("the token dialog", async () => (await js(`windowTitled("Sign In to Diffinery with a Token") != null`)) === "true", 20_000);
376await js(`var f = find(function (c) { return c instanceof javax.swing.JPasswordField; }); f.setText(${JSON.stringify(CHARLES_TOKEN)}); "ok"`);
377await js(`var w = windowTitled("Sign In to Diffinery with a Token"); var ok = null; var a = walk(w, []); for (var i = 0; i < a.length; i++) if (a[i] instanceof javax.swing.JButton && text(a[i]) === "OK") ok = a[i]; later(function () { ok.doClick(); }); "ok"`);
378await until("charles's repositories", async () => JSON.parse(await js(`repoNames()`)).includes("charles/engine-drawings"), 30_000);
379assert.equal(await js(`accounts().account().login()`, false), "charles");
380console.log("token sign-in: charles's repositories listed");
381await js(`click("Cancel")`);
382
383await js(`App.exit(true, true, false); "ok"`).catch(() => {});
384await sleep(2000);
385cleanup();
386if (process.env.KEEP !== "1") rmSync(work, { recursive: true, force: true });
387if (DOCS) rmSync(home, { recursive: true, force: true });
388console.log("jetbrains smoke test passed");
389process.exit(0);