| 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. |
| 16 | import { spawn, execFileSync } from "node:child_process"; |
| 17 | import { createHash, randomBytes } from "node:crypto"; |
| 18 | import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, chmodSync, copyFileSync } from "node:fs"; |
| 19 | import { tmpdir } from "node:os"; |
| 20 | import { join, dirname, resolve } from "node:path"; |
| 21 | import { fileURLToPath } from "node:url"; |
| 22 | import assert from "node:assert/strict"; |
| 23 | import { chromium } from "playwright"; |
| 24 | import { freePort } from "./ports.mjs"; |
| 25 | |
| 26 | const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); |
| 27 | const IDE = process.env.IDE_HOME; |
| 28 | const ROBOT = process.env.ROBOT && resolve(process.env.ROBOT); |
| 29 | const SHOTS = process.env.SHOTS; |
| 30 | const DOCS = process.env.DOCS === "1"; |
| 31 | const SCALE = Number(process.env.SCALE || (DOCS ? 2 : 1)); |
| 32 | if (!IDE || !ROBOT) throw new Error("set IDE_HOME and ROBOT"); |
| 33 | const work = mkdtempSync(join(tmpdir(), "diffinery-jetbrains-")); |
| 34 | const hash = (s) => createHash("sha256").update(s).digest("hex"); |
| 35 | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); |
| 36 | const ADA_SESSION = "ada-jetbrains-session-0123456789abcdef"; |
| 37 | const ADA_TOKEN = `dfy_${randomBytes(24).toString("hex")}`; |
| 38 | const CHARLES_TOKEN = `dfy_${randomBytes(24).toString("hex")}`; |
| 39 | let devLog = ""; |
| 40 | |
| 41 | async 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 | } |
| 55 | const wrangler = (...args) => execFileSync("pnpm", ["exec", "wrangler", ...args], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } }); |
| 56 | const children = []; |
| 57 | const cleanup = () => { |
| 58 | for (const c of children.reverse()) |
| 59 | try { |
| 60 | process.kill(-c.pid, "SIGTERM"); |
| 61 | } catch {} |
| 62 | }; |
| 63 | process.on("exit", cleanup); |
| 64 | |
| 65 | // ---------------------------------------------------------------- a local Diffinery with ada, charles and repositories |
| 66 | const port = await freePort(); |
| 67 | const base = `http://localhost:${port}`; |
| 68 | const persist = join(work, "state"); |
| 69 | const sql = (command) => wrangler("d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command", command); |
| 70 | wrangler("d1", "migrations", "apply", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist); |
| 71 | const at = new Date().toISOString(); |
| 72 | const later = new Date(Date.now() + 86400_000).toISOString(); |
| 73 | sql( |
| 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 | ); |
| 82 | const 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 | ); |
| 87 | children.push(dev); |
| 88 | dev.stdout.on("data", (d) => (devLog += d)); |
| 89 | dev.stderr.on("data", (d) => (devLog += d)); |
| 90 | await 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 | }); |
| 93 | console.log("worker up at", base); |
| 94 | |
| 95 | const 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 | ); |
| 99 | await api("POST", "/user/repos", { name: "hello-editor", private: true, description: "The analytical engine, in TypeScript" }); |
| 100 | await api("POST", "/user/repos", { name: "notes", private: false, description: "Notes on the engine, and drafts of the next paper" }); |
| 101 | await api("POST", "/user/repos", { name: "engine-drawings", private: true }, CHARLES_TOKEN); |
| 102 | const git = (cwd, ...args) => execFileSync("git", args, { cwd, stdio: "pipe", env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } }).toString(); |
| 103 | const seed = join(work, "seed"); |
| 104 | mkdirSync(seed); |
| 105 | git(seed, "init", "-q", "-b", "main"); |
| 106 | writeFileSync(join(seed, "README.md"), "# hello-editor\n\nCloned from Diffinery in a JetBrains IDE.\n"); |
| 107 | git(seed, "add", "."); |
| 108 | git(seed, "-c", "user.name=Ada", "-c", "user.email=ada@example.com", "commit", "-qm", "First commit"); |
| 109 | git(seed, "push", "-q", `http://x:${ADA_TOKEN}@localhost:${port}/ada/hello-editor.git`, "main"); |
| 110 | |
| 111 | // ---------------------------------------------------------------- the IDE |
| 112 | const home = DOCS ? "/home/ada" : join(work, "home"); |
| 113 | if (DOCS && existsSync(home)) throw new Error(`${home} exists already`); |
| 114 | const ide = join(work, "ide"); |
| 115 | for (const d of ["config/options", "system", "plugins", "log"]) mkdirSync(join(ide, d), { recursive: true }); |
| 116 | mkdirSync(home, { recursive: true }); |
| 117 | execFileSync("unzip", ["-q", ROBOT, "-d", join(ide, "plugins")]); |
| 118 | // Git saves nothing itself here: every password must come from the plugin. |
| 119 | writeFileSync(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. |
| 122 | writeFileSync(join(ide, "config/options/security.xml"), `<application>\n <component name="PasswordSafe">\n <option name="PROVIDER" value="KEEPASS" />\n </component>\n</application>\n`); |
| 123 | writeFileSync(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. |
| 125 | const engine = join(home, "difference-engine"); |
| 126 | mkdirSync(engine, { recursive: true }); |
| 127 | git(engine, "init", "-q", "-b", "main"); |
| 128 | writeFileSync(join(engine, "engine.ts"), "export const add = (a: number, b: number) => a + b;\n"); |
| 129 | git(engine, "add", "."); |
| 130 | git(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. |
| 132 | mkdirSync(join(engine, ".idea")); |
| 133 | writeFileSync(join(engine, ".git", "info", "exclude"), ".idea/\n"); |
| 134 | // The browser the IDE opens: record the URL, as a person's browser would receive it. |
| 135 | const bin = join(work, "bin"); |
| 136 | mkdirSync(bin); |
| 137 | const opened = join(work, "opened.txt"); |
| 138 | writeFileSync(join(bin, "xdg-open"), `#!/bin/sh\necho "$1" >> ${opened}\n`); |
| 139 | chmodSync(join(bin, "xdg-open"), 0o755); |
| 140 | const robotPort = await freePort(); |
| 141 | writeFileSync(join(ide, "idea.properties"), ["config", "system", "plugins", "log"].map((d) => `idea.${d}.path=${join(ide, d)}`).join("\n") + "\n"); |
| 142 | writeFileSync( |
| 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 | ); |
| 158 | const display = `:${100 + Math.floor(Math.random() * 400)}`; |
| 159 | console.log(`IDE on display ${display}, robot server on port ${robotPort}`); |
| 160 | const xvfb = spawn("Xvfb", [display, "-screen", "0", `${1600 * SCALE}x${1000 * SCALE}x24`, "-nolisten", "tcp"], { stdio: "ignore", detached: true }); |
| 161 | children.push(xvfb); |
| 162 | await sleep(1000); |
| 163 | const 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. |
| 166 | const FEED = `${base}/-/jetbrains/updatePlugins.xml`; |
| 167 | const CLI_INSTALL = process.env.INSTALL === "cli"; |
| 168 | if (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 | } |
| 172 | let ideLog = ""; |
| 173 | const ideLogText = () => (existsSync(join(ide, "log/idea.log")) ? readFileSync(join(ide, "log/idea.log"), "utf8") : ""); |
| 174 | const launched = ideLogText().length; // installPlugins logged before this |
| 175 | const idea = spawn(join(IDE, "bin", "idea.sh"), [], { env, stdio: ["ignore", "pipe", "pipe"], detached: true }); |
| 176 | children.push(idea); |
| 177 | idea.stdout.on("data", (d) => (ideLog += d)); |
| 178 | idea.stderr.on("data", (d) => (ideLog += d)); |
| 179 | |
| 180 | /** A screenshot of the whole display, or of the window titled `title`. */ |
| 181 | async 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. |
| 198 | const PRELUDE = ` |
| 199 | var App = com.intellij.openapi.application.ApplicationManager.getApplication(); |
| 200 | function 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; } |
| 201 | function 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; } |
| 202 | function 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; } |
| 203 | function cls(c) { return String(c.getClass().getName()); } |
| 204 | function text(c) { try { return String(c.getText()); } catch (e) { return ""; } } |
| 205 | function button(t) { return find(function (c) { return c instanceof javax.swing.AbstractButton && text(c).replace(/<[^>]+>/g, "") === t; }); } |
| 206 | function 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; } |
| 207 | function later(f) { App.invokeLater(new java.lang.Runnable({ run: f }), com.intellij.openapi.application.ModalityState.any()); } |
| 208 | function click(t) { var b = button(t); if (!b) throw new Error("no button " + t); later(function () { b.doClick(); }); return "ok"; } |
| 209 | function pluginClass(name) { return com.intellij.ide.plugins.PluginManagerCore.getPlugin(com.intellij.openapi.extensions.PluginId.getId("com.diffinery")).getPluginClassLoader().loadClass(name); } |
| 210 | function accounts() { return pluginClass("com.diffinery.jetbrains.Accounts").getMethod("get").invoke(null); } |
| 211 | function 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"; } |
| 212 | function repoList() { return find(function (c) { return c instanceof javax.swing.JList && String(c.getName()) === "diffinery.repositories"; }); } |
| 213 | function 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. */ |
| 216 | async 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 | |
| 227 | async 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 | } |
| 236 | process.on("unhandledRejection", fail); |
| 237 | process.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. |
| 241 | writeFileSync(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"); |
| 242 | if (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); |
| 249 | await until("the IDE's robot server", async () => (await fetch(`http://127.0.0.1:${robotPort}/hello`).catch(() => null))?.ok, 180_000); |
| 250 | await until("the welcome screen", async () => (await js(`com.intellij.openapi.wm.impl.welcomeScreen.WelcomeFrame.getInstance() != null`)) === "true", 120_000); |
| 251 | const diffineryPlugin = `com.intellij.ide.plugins.PluginManagerCore.getPlugin(com.intellij.openapi.extensions.PluginId.getId("com.diffinery"))`; |
| 252 | if (!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 | } |
| 276 | console.log("IDE up, plugin", await js(`${diffineryPlugin}.getVersion()`)); |
| 277 | await sleep(3000); |
| 278 | |
| 279 | // 1. Get from Version Control lists Diffinery next to GitHub and GitLab. |
| 280 | await js(`runAction("Vcs.VcsClone")`); |
| 281 | // "Get from Version Control" until 2025.x, "Clone Repository" since. |
| 282 | const 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); |
| 283 | const 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 | ); |
| 286 | console.log("Get from Version Control:", sources); |
| 287 | assert.ok(sources.includes("Diffinery"), "Diffinery is a source"); |
| 288 | await 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"`); |
| 289 | await until("the sign-in page", async () => (await js(`button("Sign In with Browser…") != null`)) === "true", 20_000); |
| 290 | await sleep(500); |
| 291 | await 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. |
| 294 | await js(`click("Sign In with Browser…")`); |
| 295 | const authorize = await until("the IDE to open the browser", async () => existsSync(opened) && readFileSync(opened, "utf8").trim().split("\n").pop(), 30_000); |
| 296 | const au = new URL(authorize); |
| 297 | assert.equal(au.origin + au.pathname, `${base}/oauth/authorize`); |
| 298 | assert.equal(au.searchParams.get("client_id"), `${base}/oauth/clients/jetbrains.json`); |
| 299 | assert.match(au.searchParams.get("redirect_uri"), /^http:\/\/127\.0\.0\.1:\d+\/callback$/); |
| 300 | const browser = await chromium.launch(); |
| 301 | const page = await (await browser.newContext()).newPage(); |
| 302 | await 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 | }); |
| 306 | await page.goto(authorize); |
| 307 | const consent = await page.textContent("main"); |
| 308 | assert.match(consent, /Connect Diffinery plugin for JetBrains IDEs/); |
| 309 | assert.match(consent, /Sends you back to\s*an app on this computer/); |
| 310 | if (SHOTS) await page.screenshot({ path: join(SHOTS, "2-consent.png"), fullPage: true }); |
| 311 | await page.click('button[value="approve"]'); |
| 312 | await page.waitForURL(/^http:\/\/127\.0\.0\.1:\d+\/callback/); |
| 313 | assert.match(await page.textContent("body"), /You're signed in to Diffinery/); |
| 314 | if (SHOTS) await page.screenshot({ path: join(SHOTS, "2b-signed-in.png") }); |
| 315 | await browser.close(); |
| 316 | console.log("browser: consent approved, signed-in page shown"); |
| 317 | |
| 318 | // 3. Signed in: the page lists ada's repositories; search narrows them. |
| 319 | await until("ada's repositories", async () => JSON.parse(await js(`repoNames()`)).length >= 2, 30_000); |
| 320 | const repos = JSON.parse(await js(`repoNames()`)); |
| 321 | console.log("repositories:", repos); |
| 322 | assert.deepEqual([...repos].sort(), ["ada/hello-editor", "ada/notes"]); |
| 323 | await sleep(500); |
| 324 | await shot("3-repositories", CLONE); |
| 325 | await js(`var f = find(function (c) { return c instanceof com.intellij.ui.SearchTextField; }); f.setText("hello"); "ok"`); |
| 326 | await until("the search result", async () => JSON.stringify(JSON.parse(await js(`repoNames()`))) === JSON.stringify(["ada/hello-editor"]), 20_000); |
| 327 | const 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 | }); |
| 331 | assert.equal(directory, join(home, "IdeaProjects", "hello-editor")); |
| 332 | await sleep(300); |
| 333 | await shot("4-search", CLONE); |
| 334 | |
| 335 | // 4. Clone: Git asks the plugin for the password (no prompt), and the IDE opens the project. |
| 336 | await until("the Clone button enabled", async () => (await js(`var b = button("Clone"); b != null && b.isEnabled()`)) === "true", 10_000); |
| 337 | await js(`click("Clone")`); |
| 338 | const readme = join(directory, "README.md"); |
| 339 | await until("the clone on disk", async () => existsSync(readme), 120_000); |
| 340 | assert.match(readFileSync(readme, "utf8"), /Cloned from Diffinery/); |
| 341 | assert.match(git(directory, "remote", "get-url", "origin"), new RegExp(`^${base}/ada/hello-editor\\.git`)); |
| 342 | console.log("cloned:", directory); |
| 343 | await 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); |
| 344 | await sleep(4000); |
| 345 | await shot("5-opened"); |
| 346 | |
| 347 | // 5. Share Project on Diffinery: a local repository with no remote becomes ada/difference-engine, pushed. |
| 348 | await js(`later(function () { com.intellij.ide.impl.ProjectUtil.openOrImport(java.nio.file.Paths.get(${JSON.stringify(engine)}), null, true); }); "ok"`); |
| 349 | await 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); |
| 350 | await 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); |
| 351 | 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]; runAction("Diffinery.Share", com.intellij.openapi.wm.WindowManager.getInstance().getFrame(p).getRootPane())`); |
| 352 | await until("the Share dialog", async () => (await js(`windowTitled("Share Project on Diffinery") != null`)) === "true", 30_000); |
| 353 | assert.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"); |
| 354 | await shot("6-share", "Share Project on Diffinery"); |
| 355 | await js(`click("Share")`); |
| 356 | const 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); |
| 357 | assert.equal(pushed.commit.sha, git(engine, "rev-parse", "HEAD").trim()); |
| 358 | assert.equal((await api("GET", "/repos/ada/difference-engine")).private, true); |
| 359 | assert.match(git(engine, "remote", "get-url", "origin").trim(), /\/ada\/difference-engine\.git$/); |
| 360 | console.log("shared: ada/difference-engine, private, main pushed"); |
| 361 | await sleep(1500); |
| 362 | await shot("7-shared"); |
| 363 | |
| 364 | // 6. Signing out disconnects the IDE on Diffinery too. |
| 365 | await js(`accounts().signOut(); "ok"`, false); |
| 366 | 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; |
| 367 | assert.ok(grants.length === 1 && grants[0].revoked_at, "the connection is revoked"); |
| 368 | assert.equal(await js(`String(accounts().account())`, false), "null"); |
| 369 | console.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). |
| 372 | await js(`runAction("Diffinery.Clone")`); |
| 373 | await until("the sign-in page", async () => (await js(`button("Sign In with Browser…") != null`)) === "true", 30_000); |
| 374 | await 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"`); |
| 375 | await until("the token dialog", async () => (await js(`windowTitled("Sign In to Diffinery with a Token") != null`)) === "true", 20_000); |
| 376 | await js(`var f = find(function (c) { return c instanceof javax.swing.JPasswordField; }); f.setText(${JSON.stringify(CHARLES_TOKEN)}); "ok"`); |
| 377 | await 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"`); |
| 378 | await until("charles's repositories", async () => JSON.parse(await js(`repoNames()`)).includes("charles/engine-drawings"), 30_000); |
| 379 | assert.equal(await js(`accounts().account().login()`, false), "charles"); |
| 380 | console.log("token sign-in: charles's repositories listed"); |
| 381 | await js(`click("Cancel")`); |
| 382 | |
| 383 | await js(`App.exit(true, true, false); "ok"`).catch(() => {}); |
| 384 | await sleep(2000); |
| 385 | cleanup(); |
| 386 | if (process.env.KEEP !== "1") rmSync(work, { recursive: true, force: true }); |
| 387 | if (DOCS) rmSync(home, { recursive: true, force: true }); |
| 388 | console.log("jetbrains smoke test passed"); |
| 389 | process.exit(0); |