| | @@ -32,6 +32,7 @@ |
| 32 | 32 | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); |
| 33 | 33 | const ADA_SESSION = "ada-editor-session-0123456789abcdef"; |
| 34 | 34 | const ADA_TOKEN = `dfy_${randomBytes(24).toString("hex")}`; |
| 35 | +const CHARLES_TOKEN = `dfy_${randomBytes(24).toString("hex")}`; |
| 35 | 36 | let devLog = ""; |
| 36 | 37 | |
| 37 | 38 | const freePort = () => |
| | @@ -68,6 +69,9 @@ |
| 68 | 69 | `INSERT INTO accounts (id, login, kind, name, email, status, created_at) VALUES ('ada1', 'ada', 'user', 'Ada', 'ada@example.com', 'active', '${at}')`, |
| 69 | 70 | `INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash(ADA_SESSION)}', 'ada1', 'passkey', '${at}', '${at}', '${later}')`, |
| 70 | 71 | `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}')`, |
| 72 | + // Charles signs in with an access token he pastes, in step 0. |
| 73 | + `INSERT INTO accounts (id, login, kind, name, email, status, created_at) VALUES ('cb1', 'charles', 'user', 'Charles', 'charles@example.com', 'active', '${at}')`, |
| 74 | + `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}')`, |
| 71 | 75 | ].join("; "), |
| 72 | 76 | ); |
| 73 | 77 | const dev = spawn( |
| | @@ -89,6 +93,7 @@ |
| 89 | 93 | const 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()}`)))); |
| 90 | 94 | await api("POST", "/user/repos", { name: "hello-editor", private: true, description: "The analytical engine, in TypeScript" }); |
| 91 | 95 | await api("POST", "/user/repos", { name: "notes", private: false, description: "Notes on the engine, and drafts of the next paper" }); |
| 96 | +await fetch(`${base}/api/v3/user/repos`, { method: "POST", headers: { authorization: `Bearer ${CHARLES_TOKEN}`, "content-type": "application/json" }, body: JSON.stringify({ name: "engine-drawings", private: true }) }).then((r) => assert.ok(r.ok, `charles's repository: ${r.status}`)); |
| 92 | 97 | const seed = join(work, "seed"); |
| 93 | 98 | mkdirSync(seed); |
| 94 | 99 | const git = (cwd, ...args) => execFileSync("git", args, { cwd, stdio: "pipe", env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } }).toString(); |
| | @@ -165,11 +170,39 @@ |
| 165 | 170 | await win.keyboard.press("Control+Shift+P"); |
| 166 | 171 | await win.waitForSelector(".quick-input-widget:not([style*='display: none']) .quick-input-box input"); |
| 167 | 172 | await win.keyboard.type(cmd); |
| 168 | | - await sleep(500); |
| 173 | + // The palette filters as it's typed: run the command only once it's the one highlighted. |
| 174 | + await until(`"${cmd}" highlighted in the Command Palette`, async () => (await win.textContent(".quick-input-list .monaco-list-row.focused").catch(() => ""))?.includes(cmd), 10_000); |
| 169 | 175 | await win.keyboard.press("Enter"); |
| 170 | 176 | }; |
| 171 | 177 | const quickItems = async () => (await win.$$eval(".quick-input-list .monaco-list-row", (rows) => rows.map((r) => r.textContent?.trim() ?? ""))).filter(Boolean); |
| 172 | 178 | |
| 179 | +// 0. Signing in with a pasted token, in a fresh editor, goes through the editor's own sign-in: the editor asks once, |
| 180 | +// up front, and the clone picker then lists the account's repositories without asking again. Then sign out again. |
| 181 | +await palette("Diffinery: Sign In with an Access Token"); |
| 182 | +const allowToken = await until("the sign-in prompt", async () => win.$(".monaco-dialog-box .monaco-button:has-text('Allow')"), 30_000); |
| 183 | +await shot(win, "0-allow-token"); |
| 184 | +await allowToken.click(); |
| 185 | +await win.waitForSelector(".quick-input-widget:not([style*='display: none']) .quick-input-box input"); |
| 186 | +await sleep(300); |
| 187 | +await win.fill(".quick-input-widget:not([style*='display: none']) .quick-input-box input", CHARLES_TOKEN); |
| 188 | +await win.keyboard.press("Enter"); |
| 189 | +await until("the signed-in notice", async () => (await win.textContent(".notifications-toasts").catch(() => ""))?.includes("Signed in to Diffinery as charles"), 30_000); |
| 190 | +await palette("Git: Clone"); |
| 191 | +await until("Clone from Diffinery in the picker", async () => (await quickItems()).some((t) => t.includes("Clone from Diffinery")), 30_000); |
| 192 | +await win.keyboard.type("Clone from Diffinery"); |
| 193 | +await sleep(300); |
| 194 | +await win.keyboard.press("Enter"); |
| 195 | +await until("charles's repositories", async () => { |
| 196 | + if (await win.$(".monaco-dialog-box")) throw new Error("the editor asked again to allow the sign-in"); |
| 197 | + return (await quickItems()).some((t) => t.startsWith("charles/engine-drawings")); |
| 198 | +}, 30_000); |
| 199 | +await win.keyboard.press("Escape"); |
| 200 | +console.log("token sign-in: charles's repositories listed, no second prompt"); |
| 201 | +await sleep(500); |
| 202 | +await palette("Diffinery: Sign Out"); |
| 203 | +await until("the signed-out notice", async () => (await win.textContent(".notifications-toasts").catch(() => ""))?.includes("Signed out of Diffinery"), 20_000); |
| 204 | +await win.keyboard.press("Escape"); |
| 205 | + |
| 173 | 206 | // 1. The clone picker lists Diffinery as a remote source. |
| 174 | 207 | await palette("Git: Clone"); |
| 175 | 208 | await until("Clone from Diffinery in the picker", async () => (await quickItems()).some((t) => t.includes("Clone from Diffinery")), 30_000); |
| | @@ -218,6 +251,13 @@ |
| 218 | 251 | assert.match(await page.textContent("body"), /You're signed in to Diffinery/); |
| 219 | 252 | if (SHOTS) await page.screenshot({ path: join(SHOTS, "3b-signed-in.png") }); |
| 220 | 253 | console.log("browser: signed in page shown"); |
| 254 | +// The repository page offers the editors' own clone links. |
| 255 | +await page.goto(`${base}/ada/hello-editor`); |
| 256 | +const cloneUrl = encodeURIComponent(`${base}/ada/hello-editor.git`); |
| 257 | +for (const scheme of ["vscode", "cursor", "windsurf"]) assert.ok(await page.$(`a[href="${scheme}://vscode.git/clone?url=${cloneUrl}"]`), `Open in ${scheme} link`); |
| 258 | +assert.ok(await page.$(`a[href^="jetbrains://idea/checkout/git?"]`), "Open in IntelliJ IDEA link"); |
| 259 | +if (SHOTS) await page.screenshot({ path: join(SHOTS, "3c-repo-page.png"), fullPage: true }); |
| 260 | +console.log("repository page: Open in VS Code, Cursor, Windsurf and IntelliJ IDEA links"); |
| 221 | 261 | await browser.close(); |
| 222 | 262 | |
| 223 | 263 | // 3. Signed in: the picker lists ada's repositories; search narrows it. |
| | @@ -282,6 +322,7 @@ |
| 282 | 322 | assert.ok(grants.length === 1 && grants[0].revoked_at, "the connection is revoked"); |
| 283 | 323 | console.log("signed out: credential removed, connection revoked"); |
| 284 | 324 | |
| 325 | + |
| 285 | 326 | await app.close(); |
| 286 | 327 | cleanup(); |
| 287 | 328 | if (process.env.KEEP !== "1") rmSync(work, { recursive: true, force: true }); |