diff --git a/Taskfile.yml b/Taskfile.yml index bb7d751..ff86165 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -78,11 +78,12 @@ tasks: The packaged VSIX and Spinbox Remote in a real VS Code stable, its proposed API enabled by argv.json, opening the workspaces of a spin e2e stack as vscode-remote://spin+ (test/spin-remote/run.mjs): running, stopped, a connection cut under the window and back - with the same terminal, and a refused key said once. Needs the stack - SPIN_STACK= after its `task e2e:up` and `task e2e:workspaces` - bwrap, and SPINBOX_REMOTE_VSIX=, so no workflow runs it. CASES=moved - adds a move to another host, after spin's `task e2e:hosts:up`. Under xvfb-run when it is - installed. + adds a move to another host, after spin's `task e2e:hosts:up`; it fails until spin#1225. + Under xvfb-run when it is installed. vars: XVFB: sh: command -v xvfb-run || true diff --git a/test/lane/lane.mjs b/test/lane/lane.mjs index 8c85454..6e3e7c4 100644 --- a/test/lane/lane.mjs +++ b/test/lane/lane.mjs @@ -167,6 +167,9 @@ function spinWrapper(dir, spin) { return { wrapper, calls } } +// How long VS Code may take to quit when the probe asks it to. +const QUIT_MS = 15_000 + export const SETTINGS = { "spinbox.telemetry": false, "telemetry.telemetryLevel": "off", @@ -176,8 +179,11 @@ export const SETTINGS = { "workbench.startupEditor": "none", "security.workspace.trust.enabled": false, // Every case opens the same workspaces, whose server keeps the terminals earlier windows left: - // a new window would revive them all, and its own terminal was seen to exit among them. + // a new window would revive them all, every earlier case's probe terminal in this one's. "terminal.integrated.enablePersistentSessions": false, + // VS Code asks once whether an extension may open a vscode:// link, in a modal nobody answers + // here: the person who said yes, as they would to the dashboard's first link. + "extensions.confirmedUriHandlerExtensionIds": ["spin-stack.spinbox"], } // openCase starts VS Code on folder in a home of the case's own and waits up to waitMs until @@ -206,6 +212,8 @@ export async function openCase(lane, name, o) { probe: join(dir, "probe.jsonl"), dismiss: join(dir, "dismiss"), again: join(dir, "again"), + quit: join(dir, "quit"), + userData, } const started = Date.now() const home = homedir() @@ -249,6 +257,7 @@ export async function openCase(lane, name, o) { SPINBOX_PROBE_OUT: files.probe, SPINBOX_PROBE_DISMISS: files.dismiss, SPINBOX_PROBE_AGAIN: files.again, + SPINBOX_PROBE_QUIT: files.quit, }, }, ) @@ -277,10 +286,20 @@ export async function openCase(lane, name, o) { s = seen() } say(`${name}: ${why}`) - try { - process.kill(-code.pid, "SIGTERM") - } catch {} - await sleep(2_000) + // Quit as a person does, so VS Code tells the workspace's server the window is gone and its + // extension host ends: killed, the server keeps it for the reconnection's grace, three hours, + // and a few cases filled the workspace's memory until it could start no process. + if (!why.startsWith("VS Code exited")) { + writeFileSync(files.quit, "") + const quit = await Promise.race([exited, sleep(QUIT_MS).then(() => undefined)]) + if (quit === undefined) { + say(`${name}: VS Code did not quit within ${QUIT_MS / 1000} s; ended`) + try { + process.kill(-code.pid, "SIGTERM") + } catch {} + await sleep(2_000) + } + } return seen() } diff --git a/test/lane/testdata/probe/extension.cjs b/test/lane/testdata/probe/extension.cjs index 1381e4b..d7230a6 100644 --- a/test/lane/testdata/probe/extension.cjs +++ b/test/lane/testdata/probe/extension.cjs @@ -8,6 +8,8 @@ // read back over the remote's file system; // - again: the same terminal asked for another command, when the lane creates // SPINBOX_PROBE_AGAIN - after a reconnect, whether the terminal outlived it; +// - quit: VS Code quits when the lane creates SPINBOX_PROBE_QUIT, as a person quits it, so the +// workspace's server is told each window went; // - dismissed: the notifications closed when the lane asked, by creating SPINBOX_PROBE_DISMISS, // as a person closes one: a notification nobody answers holds its caller forever. const fs = require("node:fs") @@ -16,6 +18,7 @@ const vscode = require("vscode") const out = process.env.SPINBOX_PROBE_OUT const dismiss = process.env.SPINBOX_PROBE_DISMISS const again = process.env.SPINBOX_PROBE_AGAIN +const quit = process.env.SPINBOX_PROBE_QUIT const REMOTES = ["ssh-remote", "spin"] function say(event, fields = {}) { @@ -30,6 +33,7 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) exports.activate = () => { const folder = vscode.workspace.workspaceFolders?.[0]?.uri say("activate", { remoteName: vscode.env.remoteName ?? null, folder: folder?.toString() ?? null }) + if (quit) void whenAsked(quit, () => vscode.commands.executeCommand("workbench.action.quit")) if (dismiss) void whenAsked(dismiss, async () => { await vscode.commands.executeCommand("notifications.clearAll") diff --git a/test/spin-remote/run.mjs b/test/spin-remote/run.mjs index adeb3de..0b08cfa 100644 --- a/test/spin-remote/run.mjs +++ b/test/spin-remote/run.mjs @@ -14,17 +14,21 @@ // it had is the same shell; // - unenrolled: with a key the account does not have, the resolver refuses with // key_not_enrolled, says it once, and VS Code neither retries nor connects; +// - reuse: the dashboard's link for the workspace open in the window, by its id, handed to the +// running VS Code: Spinbox opens it, and VS Code brings that window rather than another +// (spin#1119: the dashboard learns nothing back, so the window has to be found here); // - moved (only when CASES names it, with spin's `task e2e:hosts:up` run first): the workspace // moved to another host under a connected window: VS Code resolves again, to the new host's -// key, and the terminal is the same shell. +// key, and the terminal is the same shell. It fails until spin#1225: the reconnect's start +// resumes the workspace where it was, and the move aborts. // // SPIN_STACK names the spin checkout whose stack is up (test/lane/lane.mjs says what it runs each // case in); SPINBOX_REMOTE_VSIX, Spinbox Remote's VSIX (its `task package`, or a release's). -// CASES=running,stopped,reconnect,unenrolled picks some. `task test:spin-remote` runs it. -import { spawnSync } from "node:child_process" -import { appendFileSync } from "node:fs" -import { resolve } from "node:path" -import { lane, openCase, root } from "../lane/lane.mjs" +// CASES=running,stopped,reconnect,unenrolled,reuse picks some. `task test:spin-remote` runs it. +import { spawn, spawnSync } from "node:child_process" +import { appendFileSync, existsSync, openSync, readdirSync } from "node:fs" +import { join, resolve } from "node:path" +import { lane, logOf, openCase, root } from "../lane/lane.mjs" const RESOLVER = "spin-stack.spinbox-remote" const vsix = process.env.SPINBOX_VSIX ?? resolve(root, "spinbox.vsix") @@ -117,8 +121,8 @@ async function connects(name, workspace) { } // A connected window has its connection cut by cut(seen), then is asked for another line from its -// terminal once the resolver has opened the workspace again. -async function survives(name, workspace, cut) { +// terminal once the resolver has opened the workspace again, which it claims within backMs. +async function survives(name, workspace, cut, backMs = RECONNECT_MS) { let cutAt let askedAt const s = await open(name, workspace, { @@ -136,7 +140,7 @@ async function survives(name, workspace, cut) { } // Not back in the time the case claims, or back without the terminal answering soon // after: failed, and waiting longer would only make it slower to say so. - if (askedAt === undefined) return Date.now() - cutAt > RECONNECT_MS + if (askedAt === undefined) return Date.now() - cutAt > backMs return terminal(s, "again")?.output != null || Date.now() - askedAt > ANSWER_MS }, }) @@ -149,8 +153,8 @@ async function survives(name, workspace, cut) { [...resolving(s), ...reopened], ) check( - `${name}: the window was back within ${RECONNECT_MS / 1000} s`, - askedAt !== undefined && askedAt - cutAt <= RECONNECT_MS, + `${name}: the window was back within ${backMs / 1000} s`, + askedAt !== undefined && askedAt - cutAt <= backMs, `cut at ${cutAt}, opened again at ${askedAt}: ${askedAt - cutAt} ms`, ) check( @@ -222,20 +226,111 @@ const cases = { }, } -// A move needs a second host (spin's task e2e:hosts:up), so it runs when asked for. +// The windows VS Code opened in a case: each has a log directory of its own. +function windowsOf(userData) { + const logs = join(userData, "logs") + if (!existsSync(logs)) return [] + return readdirSync(logs).flatMap((session) => + readdirSync(join(logs, session)).filter((d) => d.startsWith("window")), + ) +} + +// The dashboard's link for a workspace, handed to the VS Code that is running, as the OS hands it. +function openLink(userData, workspace) { + const out = openSync(join(userData, "open-url.log"), "a") + spawn( + lab.vscode.exe, + [ + "--no-sandbox", + "--disable-gpu", + `--user-data-dir=${userData}`, + `--extensions-dir=${lab.vscode.extensions}`, + "--open-url", + `vscode://spin-stack.spinbox/open?workspace=${workspace}`, + ], + { detached: true, stdio: ["ignore", out, out], env: process.env }, + ).unref() +} + +// How long the reuse case watches for a second window after the link. +const LINK_MS = 30_000 + +cases.reuse = async () => { + // By its id, as the dashboard's link names it: the window was opened by its name. + const id = JSON.parse(asPerson("-o", "json", "workspace", "get", "e2e-running").out).id + let sentAt + let userData + const s = await open("reuse", "e2e-running", { + until: (s, files) => { + if (!answered(s)) return false + if (!connected(s)) return true + if (sentAt === undefined) { + sentAt = Date.now() + userData = files.userData + openLink(userData, id) + } + return windowsOf(userData).length > 1 || Date.now() - sentAt > LINK_MS + }, + }) + const handled = s.spinbox.filter((l) => l.includes("spin code")) + check( + "reuse: Spinbox handled the dashboard's link", + s.calls.some((l) => l.includes(` end 0 code ${id} `) && Number(l.split(" ")[0]) >= sentAt), + [...s.calls.filter((l) => / code /.test(l)), ...handled], + ) + const windows = windowsOf(userData) + // VS Code's own word that the folder asked for was the open window's. + const reused = logOf(userData, "main.log").filter((l) => + l.includes("doOpenFilesInExistingWindow"), + ) + check( + "reuse: the link brought the window already open to e2e-running, and opened no other", + windows.length === 1 && reused.length > 0, + [...windows, ...reused.map((l) => l.slice(0, 100))], + ) +} + +// A move needs a second host (spin's task e2e:hosts:up), so it runs when asked for. It pauses the +// workspace while its memory is stored and its disk published, which takes minutes. +const MOVE_MS = Number(process.env.MOVE_MS ?? 600_000) if (process.env.CASES?.split(",").includes("moved")) { cases.moved = async () => { - const hostOf = () => JSON.parse(asPerson("-o", "json", "workspace", "get", "e2e-running").out) + const hostOf = () => + JSON.parse(asPerson("-o", "json", "workspace", "get", "e2e-running").out).hostId const before = hostOf() - await survives("moved", "e2e-running", () => { - const r = asPerson("workspace", "move", "e2e-running") - if (!r.ok) console.log(` spin workspace move: ${r.err}`) - }) + let moved + await survives( + "moved", + "e2e-running", + () => { + // Not waited for here: the window is watched while the workspace moves under it. + moved = new Promise((done) => { + const p = spawn(lab.spin, ["workspace", "move", "e2e-running"], { + env: { + ...process.env, + HOME: lab.person, + SSL_CERT_DIR: `/etc/ssl/certs:${lab.person}/.trust`, + }, + }) + let said = "" + p.stdout.on("data", (d) => { + said += d + }) + p.stderr.on("data", (d) => { + said += d + }) + p.on("close", (code) => done({ code, said: said.trim(), at: Date.now() })) + }) + }, + MOVE_MS, + ) + const m = await moved + check("moved: spin workspace move succeeded", m?.code === 0, m?.said) const after = hostOf() check( "moved: e2e-running runs on another host", - before.host_id !== undefined && after.host_id !== before.host_id, - `${before.host_id} -> ${after.host_id}`, + before !== undefined && after !== undefined && after !== before, + `${before} -> ${after}`, ) } }