From ec44aae194fe2dfa9dc6df9873b91cd1a7162fd7 Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Sat, 14 Nov 2026 03:13:18 -0300 Subject: [PATCH 1/2] spinbox.cliPath read in every window: application scope spinbox.cliPath is a path on the machine the extension runs on, the window's (extensionKind ui), never the workspace's. A machine-scoped setting is not read from the person's settings once a window is remote: Spinbox Remote's remote.spin.cliPath, scoped the same, came back empty while VS Code reconnected a window, and the reconnect ran another spin (spin-stack/open-remote-ssh#17). Application scope is read from the person's settings in every window; the manifest test holds it. Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- test/{remote-ssh => lane}/testdata/probe/extension.cjs | 0 test/{remote-ssh => lane}/testdata/probe/package.json | 0 test/unit/manifest.test.ts | 10 ++++++++++ 4 files changed, 11 insertions(+), 1 deletion(-) rename test/{remote-ssh => lane}/testdata/probe/extension.cjs (100%) rename test/{remote-ssh => lane}/testdata/probe/package.json (100%) diff --git a/package.json b/package.json index 7213f97..661728e 100644 --- a/package.json +++ b/package.json @@ -199,7 +199,7 @@ "spinbox.cliPath": { "type": "string", "default": "", - "scope": "machine", + "scope": "application", "description": "The spin CLI to run. Empty runs the one the extension carries." } } diff --git a/test/remote-ssh/testdata/probe/extension.cjs b/test/lane/testdata/probe/extension.cjs similarity index 100% rename from test/remote-ssh/testdata/probe/extension.cjs rename to test/lane/testdata/probe/extension.cjs diff --git a/test/remote-ssh/testdata/probe/package.json b/test/lane/testdata/probe/package.json similarity index 100% rename from test/remote-ssh/testdata/probe/package.json rename to test/lane/testdata/probe/package.json diff --git a/test/unit/manifest.test.ts b/test/unit/manifest.test.ts index 9591e1e..4813453 100644 --- a/test/unit/manifest.test.ts +++ b/test/unit/manifest.test.ts @@ -17,4 +17,14 @@ describe("package.json", () => { it("activates when a window resolves over Remote-SSH", () => { expect(manifest.activationEvents).toContain("onResolveRemoteAuthority:ssh-remote") }) + + // spinbox.cliPath is a path on the machine the extension runs on, the window's (extensionKind + // ui), never the workspace's. A machine-scoped setting is not read from the person's settings + // once the window is remote: Spinbox Remote's own read empty while VS Code reconnected a + // window (test/spin-remote/run.mjs). Application scope is read from them in every window. + it("reads spinbox.cliPath from the person's settings in every window", () => { + expect(manifest.contributes.configuration.properties["spinbox.cliPath"].scope).toBe( + "application", + ) + }) }) From 30e2aff001e0f36a8ba72759056b925e481aaf3c Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Sat, 14 Nov 2026 03:13:18 -0300 Subject: [PATCH 2/2] test: spin+ in a real VS Code against a spin e2e stack task test:spin-remote installs the packaged VSIX and Spinbox Remote into VS Code stable, the resolvers proposal enabled by argv.json in the person's home as a person enables it, no Remote-SSH, and opens vscode-remote://spin+/workspace. What it asserts is what the person sees, from VS Code's logs, the probe and the stack: - running and stopped: Spinbox Remote activates on onResolveRemoteAuthority:spin and runs spin code (--start), ssh-session and tunnel; the window is a spin remote; a command typed in its terminal runs in the workspace, read back with spin exec; - reconnect: the tunnel killed under a connected window, as a network that drops: VS Code resolves again, the window is back within 60 s (1 s here), and its terminal is the same shell; - unenrolled: key_not_enrolled, resolved once and not retried, no tunnel, no terminal; - moved, when CASES names it, after spin's task e2e:hosts:up: spin workspace move under a connected window, the same checks as reconnect, and another host. Not run yet. What both lanes share moves to test/lane/lane.mjs: VS Code stable, a home per case in bwrap, the stamping spin wrapper, the logs read back. Three things the lanes did not see before: - the lane's directory is bound into bwrap: under the /tmp it empties, VS Code saw neither the installed extensions nor the case's spin wrapper; - the probe is installed as any extension is, not with --extensionDevelopmentPath: that makes the window an Extension Development Host, which VS Code closes when an extension host goes away, so a dropped connection closed the window; it opens its terminal once the remote's file system answers, since activated by the resolution it ran before the remote was there; - a case ends as soon as what it claims has failed, or VS Code has exited, rather than after six minutes. The reconnect case found the scope of remote.spin.cliPath (open-remote-ssh#17); with it, the lane passes 18/18 against spin's e2e stack (f15122137) in VS Code 1.146.0, and test:remote-ssh 14/14 on the shared module. Co-Authored-By: Claude Opus 5.5 --- Taskfile.yml | 17 ++ test/lane/lane.mjs | 311 +++++++++++++++++++++++++ test/lane/testdata/probe/extension.cjs | 78 +++++-- test/lane/testdata/probe/package.json | 9 +- test/remote-ssh/run.mjs | 254 ++------------------ test/spin-remote/run.mjs | 243 +++++++++++++++++++ 6 files changed, 649 insertions(+), 263 deletions(-) create mode 100644 test/lane/lane.mjs create mode 100644 test/spin-remote/run.mjs diff --git a/Taskfile.yml b/Taskfile.yml index 48790a6..bb7d751 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -73,6 +73,23 @@ tasks: - task: package - '{{if .XVFB}}xvfb-run -a {{end}}node test/remote-ssh/run.mjs' + test:spin-remote: + desc: >- + 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. + vars: + XVFB: + sh: command -v xvfb-run || true + cmds: + - task: package + - '{{if .XVFB}}xvfb-run -a {{end}}node test/spin-remote/run.mjs' + package: desc: Package the extension into spinbox.vsix with vsce, without the CLI - what CI's gate checks packages. package:all makes the ones that are published. cmds: diff --git a/test/lane/lane.mjs b/test/lane/lane.mjs new file mode 100644 index 0000000..8c85454 --- /dev/null +++ b/test/lane/lane.mjs @@ -0,0 +1,311 @@ +// What the lanes that open a spin e2e stack's workspaces in a real VS Code share +// (test/remote-ssh/run.mjs, test/spin-remote/run.mjs): VS Code stable, a person's home per case, +// a wrapper around spin that stamps each call, VS Code started in bubblewrap on a remote folder, +// and what each side logs, read back while the lane waits for what it is looking for. +// +// SPIN_STACK names the spin checkout whose stack is up (`task e2e:up` and `task e2e:workspaces`): +// its bin/spin, and the fixture user's home hack/e2estack keeps. Each case runs in bubblewrap with +// a home of its own bound over $HOME, since ssh finds ~/.ssh from the password database and not +// from HOME, so neither the CLI's ssh config nor the stack's session ever touches the real one. +import { spawn, spawnSync } from "node:child_process" +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + writeFileSync, +} from "node:fs" +import { homedir, tmpdir } from "node:os" +import { dirname, join, resolve } from "node:path" +import { fileURLToPath } from "node:url" +import { + downloadAndUnzipVSCode, + resolveCliArgsFromVSCodeExecutablePath, +} from "@vscode/test-electron" + +export const root = resolve(dirname(fileURLToPath(import.meta.url)), "../..") +export const probe = join(root, "test/lane/testdata/probe") +export const say = (line) => console.log(`==> ${line}`) +export const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) + +export function stackOf(env) { + const stack = env.SPIN_STACK + if (!stack) { + console.error("SPIN_STACK: the spin checkout whose e2e stack is up") + process.exit(2) + } + return { + stack: resolve(stack), + spin: resolve(stack, "bin/spin"), + person: resolve(stack, "_output/run/e2e/people/user"), + } +} + +// VS Code stable, as people have it, with the extensions given (Marketplace ids or VSIX paths) +// installed into an extensions directory of the lane's own. +// The probe, packaged as any extension is and installed beside the others. Not loaded with +// --extensionDevelopmentPath: that makes the window an Extension Development Host, which VS Code +// closes when an extension host goes away - a remote's, when its connection drops - so no +// reconnect could be seen in it. +function packageProbe(work) { + const out = join(work, "lane-probe.vsix") + const r = spawnSync( + join(root, "node_modules/.bin/vsce"), + ["package", "--no-dependencies", "--allow-missing-repository", "--skip-license", "--out", out], + { cwd: probe, encoding: "utf8" }, + ) + if (r.status !== 0) throw new Error(`packaging the probe: ${r.stderr || r.stdout}`) + return out +} + +export async function vscodeWith(work, extensions) { + const exe = await downloadAndUnzipVSCode({ + version: "stable", + cachePath: resolve(root, ".vscode-test"), + }) + const [cli] = resolveCliArgsFromVSCodeExecutablePath(exe) + const version = spawnSync(cli, ["--version"], { encoding: "utf8" }).stdout.split("\n") + const dir = join(work, "extensions") + const installed = spawnSync( + cli, + [ + `--extensions-dir=${dir}`, + `--user-data-dir=${join(work, "install")}`, + ...[...extensions, packageProbe(work)].flatMap((e) => ["--install-extension", e]), + ], + { encoding: "utf8" }, + ) + if (installed.status !== 0) throw new Error(`installing the extensions: ${installed.stderr}`) + const listed = readdirSync(dir).filter((d) => d.includes(".")) + say(`VS Code ${version[0]} (${version[1]}), ${listed.join(", ")}`) + return { exe, extensions: dir, version: version[0], commit: version[1] } +} + +// The person's home for one case: their session and the stack's root, and their enrolled key, or +// a key of nobody's. +const KEPT = [ + ".spin/config.yaml", + ".trust/stack-root.pem", + ".ssh/id_ed25519", + ".ssh/id_ed25519.pub", +] + +export function homeFor(person, dir, enrolled) { + for (const f of KEPT) { + mkdirSync(dirname(join(dir, f)), { recursive: true, mode: 0o700 }) + if (enrolled || !f.startsWith(".ssh/")) copyFileSync(join(person, f), join(dir, f)) + } + if (!enrolled) { + const key = join(dir, ".ssh/id_ed25519") + const r = spawnSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-f", key]) + if (r.status !== 0) throw new Error("ssh-keygen failed") + } +} + +// The CLI as the person, outside VS Code, for what the stack says of a workspace. +export function personCli(spin, person) { + const run = (...args) => { + const r = spawnSync(spin, args, { + encoding: "utf8", + env: { + ...process.env, + HOME: person, + SSL_CERT_DIR: `/etc/ssl/certs:${join(person, ".trust")}`, + }, + }) + return { ok: r.status === 0, out: r.stdout.trim(), err: r.stderr.trim() } + } + const stateOf = (name) => { + const r = run("-o", "json", "workspace", "list") + if (!r.ok) throw new Error(`listing workspaces: ${r.err}`) + return JSON.parse(r.out).items.find((w) => w.name === name)?.state + } + return { run, stateOf } +} + +export const lines = (body) => body.split("\n").filter((l) => l !== "") +export const read = (path) => (existsSync(path) ? lines(readFileSync(path, "utf8")) : []) + +// Every line VS Code's logs hold in the files whose names end with suffix. +export function logOf(userData, suffix) { + const found = [] + const walk = (dir) => { + for (const e of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, e.name) + if (e.isDirectory()) walk(p) + else if (e.name.endsWith(suffix)) found.push(...lines(readFileSync(p, "utf8"))) + } + } + if (existsSync(join(userData, "logs"))) walk(join(userData, "logs")) + return found +} + +// [hh:mm:ss.mmm] at the start of a Remote-SSH line, as epoch ms of the day it ran. +export function timeOf(line, day) { + const m = /^\[(\d{2}):(\d{2}):(\d{2})\.(\d{3})\]/.exec(line) + if (m === null) return undefined + const d = new Date(day) + d.setHours(Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])) + return d.getTime() +} + +// A spin around the real one that says each call with when it started and ended. The time is +// bash's own clock read, in ms: uutils' `date +%s%3N` mixed one instant's seconds with another's +// milliseconds on a loaded host, and wrote an end before its start. +function spinWrapper(dir, spin) { + const calls = join(dir, "spin-calls.log") + const wrapper = join(dir, "spin") + const stamp = (what) => `t=\${EPOCHREALTIME/./}; echo "\${t%???} ${what} $*" >> ${calls}` + writeFileSync( + wrapper, + `#!/bin/bash\n${stamp("start")}\n${spin} "$@"\nrc=$?\n${stamp("end $rc")}\nexit $rc\n`, + ) + chmodSync(wrapper, 0o755) + return { wrapper, calls } +} + +export const SETTINGS = { + "spinbox.telemetry": false, + "telemetry.telemetryLevel": "off", + "update.mode": "none", + "extensions.autoUpdate": false, + "extensions.autoCheckUpdates": false, + "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. + "terminal.integrated.enablePersistentSessions": false, +} + +// openCase starts VS Code on folder in a home of the case's own and waits up to waitMs until +// until(seen, files) holds, then ends it and answers what was seen. settings(wrapper) is the +// user's settings, given the spin wrapper; homeFiles, files laid in the case's home before start +// (argv.json, say); logs, the VS Code log files to read, by name: suffix. +export async function openCase(lane, name, o) { + const dir = join(lane.work, name) + const caseHome = join(dir, "home") + const userData = join(dir, "user-data") + homeFor(lane.person, caseHome, o.enrolled) + for (const [f, body] of Object.entries(o.homeFiles ?? {})) { + mkdirSync(dirname(join(caseHome, f)), { recursive: true }) + writeFileSync(join(caseHome, f), body) + } + // An empty system ssh config: in bubblewrap's namespace root's files are nobody's, which ssh + // refuses to read. + writeFileSync(join(dir, "ssh_config"), "") + const { wrapper, calls } = spinWrapper(dir, lane.spin) + mkdirSync(join(userData, "User"), { recursive: true }) + writeFileSync( + join(userData, "User/settings.json"), + JSON.stringify({ ...SETTINGS, ...o.settings(wrapper) }), + ) + const files = { + probe: join(dir, "probe.jsonl"), + dismiss: join(dir, "dismiss"), + again: join(dir, "again"), + } + const started = Date.now() + const home = homedir() + const bwrap = [ + ["--die-with-parent"], + ["--dev-bind", "/", "/"], + ["--bind", caseHome, home], + ["--bind", root, root], + ["--bind", lane.stack, lane.stack], + ["--tmpfs", "/tmp"], + // The lane's directory, under the /tmp just emptied: the extensions installed, and the case's + // spin wrapper, the user data VS Code logs into and the probe's files. + ["--bind", lane.work, lane.work], + ["--bind", "/tmp/.X11-unix", "/tmp/.X11-unix"], + ["--ro-bind", join(dir, "ssh_config"), "/etc/ssh/ssh_config"], + ["--tmpfs", "/etc/ssh/ssh_config.d"], + process.env.XAUTHORITY ? ["--bind", process.env.XAUTHORITY, process.env.XAUTHORITY] : [], + ].flat() + const code = spawn( + "bwrap", + [ + ...bwrap, + lane.vscode.exe, + "--no-sandbox", + "--disable-gpu", + `--user-data-dir=${userData}`, + `--extensions-dir=${lane.vscode.extensions}`, + `--folder-uri=${o.folder}`, + "--log=trace", + "--skip-welcome", + "--skip-release-notes", + "--disable-workspace-trust", + "--new-window", + ], + { + detached: true, + stdio: "ignore", + env: { + ...process.env, + SSL_CERT_DIR: `/etc/ssl/certs:${join(home, ".trust")}`, + SPINBOX_PROBE_OUT: files.probe, + SPINBOX_PROBE_DISMISS: files.dismiss, + SPINBOX_PROBE_AGAIN: files.again, + }, + }, + ) + const seen = () => { + const s = { + started, + dir, + probe: read(files.probe).map((l) => JSON.parse(l)), + calls: read(calls), + exthost: logOf(userData, "exthost.log"), + renderer: logOf(userData, "renderer.log"), + } + for (const [key, suffix] of Object.entries(o.logs)) s[key] = logOf(userData, suffix) + return s + } + let s = seen() + const end = Date.now() + o.waitMs + const exited = new Promise((r) => + code.once("exit", (c, sig) => r(`VS Code exited (${c ?? sig})`)), + ) + let why + while (why === undefined) { + if (await o.until(s, files)) why = "seen" + else if (Date.now() >= end) why = `not seen in ${o.waitMs / 1000} s` + else why = await Promise.race([sleep(1_000), exited]) + s = seen() + } + say(`${name}: ${why}`) + try { + process.kill(-code.pid, "SIGTERM") + } catch {} + await sleep(2_000) + return seen() +} + +// A lane: its stack, its VS Code, its work directory, and the checks it has made. +export async function lane(prefix, extensions) { + const { stack, spin, person } = stackOf(process.env) + const work = mkdtempSync(join(tmpdir(), prefix)) + const vscode = await vscodeWith(work, extensions) + const failures = [] + const check = (name, ok, evidence) => { + say(`${ok ? "ok " : "FAIL"} ${name}`) + for (const line of [evidence].flat().filter((l) => l !== undefined)) + console.log(` ${line}`) + if (!ok) failures.push(name) + } + const run = async (cases) => { + const only = process.env.CASES?.split(",") + for (const [name, c] of Object.entries(cases)) { + if (only === undefined || only.includes(name)) await c() + } + say(`logs in ${work}`) + if (failures.length > 0) { + console.error(`${failures.length} failed: ${failures.join("; ")}`) + process.exit(1) + } + } + return { stack, spin, person, work, vscode, check, run, cli: personCli(spin, person) } +} diff --git a/test/lane/testdata/probe/extension.cjs b/test/lane/testdata/probe/extension.cjs index faa1fb3..1381e4b 100644 --- a/test/lane/testdata/probe/extension.cjs +++ b/test/lane/testdata/probe/extension.cjs @@ -1,11 +1,13 @@ -// The lane's eyes in the window (test/remote-ssh/run.mjs): each thing it sees is a JSON line -// appended to SPINBOX_PROBE_OUT. It activates as Spinbox does, on the resolution of ssh-remote, -// so it is there while Spinbox's activation holds Remote-SSH back, and again in the window a -// closed remote leaves. +// The lanes' eyes in the window (test/lane/lane.mjs): each thing it sees is a JSON line appended +// to SPINBOX_PROBE_OUT. It activates on the resolution of either remote, as Spinbox and Spinbox +// Remote do, so it is there while a resolution is held, and again in the window a closed remote +// leaves. // // - activate: the remote the window is for, and its folder; // - terminal: in a window connected to a workspace, what a command run in its terminal wrote, // 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; // - 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") @@ -13,49 +15,75 @@ 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 REMOTES = ["ssh-remote", "spin"] function say(event, fields = {}) { if (out) fs.appendFileSync(out, `${JSON.stringify({ t: Date.now(), event, ...fields })}\n`) } +// How long a command's output may take to be read back once the remote answers. +const READ_MS = 30_000 + 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 (dismiss) void dismissWhenAsked() - if (vscode.env.remoteName === "ssh-remote" && folder !== undefined) void runInTerminal(folder) + if (dismiss) + void whenAsked(dismiss, async () => { + await vscode.commands.executeCommand("notifications.clearAll") + say("dismissed") + }) + if (REMOTES.includes(vscode.env.remoteName) && folder !== undefined) void runInTerminal(folder) } -async function dismissWhenAsked() { +async function whenAsked(file, act) { for (;;) { - if (fs.existsSync(dismiss)) { - fs.rmSync(dismiss, { force: true }) - await vscode.commands.executeCommand("notifications.clearAll") - say("dismissed") + if (fs.existsSync(file)) { + fs.rmSync(file, { force: true }) + await act() } await sleep(500) } } -// The terminal opens once the remote is connected; until then the file is not there to read. +// The terminal is opened once the remote's file system answers: activated by the resolution, the +// probe runs before the remote is there, and a terminal opened then is a local one. The shell's +// own pid goes into the file, so a second command says whether it ran in the same shell. async function runInTerminal(folder) { - const path = `/tmp/remote-ssh-probe-${process.pid}-${Date.now()}` - const terminal = vscode.window.createTerminal({ name: "remote-ssh probe" }) - terminal.sendText(`echo "$(hostname) $(id -un) $PWD" > ${path}`) - const file = folder.with({ path }) - const end = Date.now() + 300_000 + await eventually(async () => { + await vscode.workspace.fs.stat(folder) + return true + }) + const terminal = vscode.window.createTerminal({ name: "lane probe" }) + const write = async (event) => { + const path = `/tmp/lane-probe-${process.pid}-${Date.now()}` + terminal.sendText(`echo "$(hostname) $(id -un) $$ $PWD" > ${path}`) + say(event, { path, output: await readBack(folder.with({ path })) }) + } + await write("terminal") + if (again) void whenAsked(again, () => write("again")) +} + +// f's answer once it gives one, tried every second for ms; null if it never does. +async function eventually(f, ms = 300_000) { + const end = Date.now() + ms while (Date.now() < end) { try { - const body = Buffer.from(await vscode.workspace.fs.readFile(file)) - .toString("utf8") - .trim() - if (body !== "") { - say("terminal", { path, output: body }) - return - } + const answer = await f() + if (answer !== undefined) return answer } catch {} await sleep(1_000) } - say("terminal", { path, output: null }) + return null +} + +function readBack(file) { + return eventually(async () => { + const body = Buffer.from(await vscode.workspace.fs.readFile(file)) + .toString("utf8") + .trim() + return body === "" ? undefined : body + }, READ_MS) } diff --git a/test/lane/testdata/probe/package.json b/test/lane/testdata/probe/package.json index ac4922f..71ef12b 100644 --- a/test/lane/testdata/probe/package.json +++ b/test/lane/testdata/probe/package.json @@ -1,7 +1,7 @@ { - "name": "remote-ssh-probe", - "displayName": "The remote-ssh lane's probe", - "description": "Says to test/remote-ssh/run.mjs what the window is connected to, runs a command in its terminal, and dismisses the notifications the lane asks it to.", + "name": "lane-probe", + "displayName": "The VS Code lanes' probe", + "description": "Says to test/lane/lane.mjs what the window is connected to, runs commands in its terminal, and dismisses the notifications the lane asks it to.", "version": "0.0.0", "publisher": "spin-stack", "private": true, @@ -14,6 +14,7 @@ "main": "./extension.cjs", "activationEvents": [ "*", - "onResolveRemoteAuthority:ssh-remote" + "onResolveRemoteAuthority:ssh-remote", + "onResolveRemoteAuthority:spin" ] } diff --git a/test/remote-ssh/run.mjs b/test/remote-ssh/run.mjs index 8dc5c76..c248c9f 100644 --- a/test/remote-ssh/run.mjs +++ b/test/remote-ssh/run.mjs @@ -19,38 +19,11 @@ // ssh finds ~/.ssh from the password database and not from HOME, so neither spin code's ssh config // nor the stack's session ever touches the real one. CASES=running,stopped,unenrolled picks some. // Needs bwrap, ssh-keygen and a display: `task test:remote-ssh` runs it under xvfb-run. -import { spawn, spawnSync } from "node:child_process" -import { - appendFileSync, - chmodSync, - copyFileSync, - existsSync, - mkdirSync, - mkdtempSync, - readdirSync, - readFileSync, - writeFileSync, -} from "node:fs" -import { homedir, tmpdir } from "node:os" -import { dirname, join, resolve } from "node:path" -import { fileURLToPath } from "node:url" -import { - downloadAndUnzipVSCode, - resolveCliArgsFromVSCodeExecutablePath, -} from "@vscode/test-electron" +import { appendFileSync, existsSync } from "node:fs" +import { resolve } from "node:path" +import { lane, openCase, root, timeOf } from "../lane/lane.mjs" -const root = resolve(dirname(fileURLToPath(import.meta.url)), "../..") -const stack = process.env.SPIN_STACK -if (!stack) { - console.error("SPIN_STACK: the spin checkout whose e2e stack is up") - process.exit(2) -} -const spin = resolve(stack, "bin/spin") -const person = resolve(stack, "_output/run/e2e/people/user") -const home = homedir() -const work = mkdtempSync(join(tmpdir(), "spinbox-remote-ssh-")) const vsix = process.env.SPINBOX_VSIX ?? resolve(root, "spinbox.vsix") -const only = process.env.CASES?.split(",") const WAIT_MS = 360_000 // How long the unenrolled case leaves Spinbox's message up before closing it. const READ_MS = Number(process.env.READ_MS ?? 30_000) @@ -60,202 +33,22 @@ const QUICK_MS = 5_000 // the unload - 10 s on a host at load 22. Waiting out the ConnectTimeout alone took 17 s. const LEAVE_MS = 15_000 -const say = (line) => console.log(`==> ${line}`) - // VS Code stable, as Marketplace users have it, with Remote-SSH from the Marketplace beside the // VSIX: neither is a development copy. -const exe = await downloadAndUnzipVSCode({ - version: "stable", - cachePath: resolve(root, ".vscode-test"), -}) -const [cli] = resolveCliArgsFromVSCodeExecutablePath(exe) -const version = spawnSync(cli, ["--version"], { encoding: "utf8" }).stdout.split("\n") -const extensions = join(work, "extensions") -const installed = spawnSync( - cli, - [ - `--extensions-dir=${extensions}`, - `--user-data-dir=${join(work, "install")}`, - "--install-extension", - "ms-vscode-remote.remote-ssh", - "--install-extension", - vsix, - ], - { encoding: "utf8" }, -) -if (installed.status !== 0) throw new Error(`installing the extensions: ${installed.stderr}`) -const listed = readdirSync(extensions).filter((d) => d.includes(".")) -say(`VS Code ${version[0]} (${version[1]}), ${listed.join(", ")}`) - -// The person's home for one case: their session and the stack's root, and their enrolled key, or -// a key of nobody's. -const KEPT = [ - ".spin/config.yaml", - ".trust/stack-root.pem", - ".ssh/id_ed25519", - ".ssh/id_ed25519.pub", -] - -function homeFor(dir, enrolled) { - for (const f of KEPT) { - mkdirSync(dirname(join(dir, f)), { recursive: true, mode: 0o700 }) - if (enrolled || !f.startsWith(".ssh/")) copyFileSync(join(person, f), join(dir, f)) - } - if (!enrolled) { - const key = join(dir, ".ssh/id_ed25519") - const r = spawnSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-f", key]) - if (r.status !== 0) throw new Error("ssh-keygen failed") - } -} - -const trust = `/etc/ssl/certs:${join(home, ".trust")}` - -// The CLI as the person, outside VS Code, for what the stack says of a workspace. -function asPerson(...args) { - const r = spawnSync(spin, args, { - encoding: "utf8", - env: { ...process.env, HOME: person, SSL_CERT_DIR: `/etc/ssl/certs:${join(person, ".trust")}` }, - }) - return { ok: r.status === 0, out: r.stdout.trim(), err: r.stderr.trim() } -} - -function stateOf(name) { - const r = asPerson("-o", "json", "workspace", "list") - if (!r.ok) throw new Error(`listing workspaces: ${r.err}`) - return JSON.parse(r.out).items.find((w) => w.name === name)?.state -} - -const lines = (body) => body.split("\n").filter((l) => l !== "") - -// Every line VS Code's logs hold in the files whose names end with suffix. -function logOf(userData, suffix) { - const found = [] - const walk = (dir) => { - for (const e of readdirSync(dir, { withFileTypes: true })) { - const p = join(dir, e.name) - if (e.isDirectory()) walk(p) - else if (e.name.endsWith(suffix)) found.push(...lines(readFileSync(p, "utf8"))) - } - } - if (existsSync(join(userData, "logs"))) walk(join(userData, "logs")) - return found -} - -const read = (path) => (existsSync(path) ? lines(readFileSync(path, "utf8")) : []) - -// [hh:mm:ss.mmm] at the start of a Remote-SSH line, as epoch ms of the day it ran. -function timeOf(line, day) { - const m = /^\[(\d{2}):(\d{2}):(\d{2})\.(\d{3})\]/.exec(line) - if (m === null) return undefined - const d = new Date(day) - d.setHours(Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])) - return d.getTime() -} - -async function openCase(name, workspace, { enrolled, until }) { - const dir = join(work, name) - const caseHome = join(dir, "home") - const userData = join(dir, "user-data") - homeFor(caseHome, enrolled) - // An empty system ssh config: in bubblewrap's namespace root's files are nobody's, which ssh - // refuses to read. - writeFileSync(join(dir, "ssh_config"), "") - // Each spin the extension runs, said with when it started and ended, around the real one. The - // time is bash's own clock read, in ms: uutils' `date +%s%3N` mixed one instant's seconds with - // another's milliseconds on a loaded host, and wrote an end before its start. - const calls = join(dir, "spin-calls.log") - const wrapper = join(dir, "spin") - const stamp = (what) => `t=\${EPOCHREALTIME/./}; echo "\${t%???} ${what} $*" >> ${calls}` - writeFileSync( - wrapper, - `#!/bin/bash\n${stamp("start")}\n${spin} "$@"\nrc=$?\n${stamp("end $rc")}\nexit $rc\n`, - ) - chmodSync(wrapper, 0o755) - mkdirSync(join(userData, "User"), { recursive: true }) - writeFileSync( - join(userData, "User/settings.json"), - JSON.stringify({ - "spinbox.cliPath": wrapper, - "spinbox.telemetry": false, - "telemetry.telemetryLevel": "off", - "update.mode": "none", - "extensions.autoUpdate": false, - "extensions.autoCheckUpdates": false, - "workbench.startupEditor": "none", - "security.workspace.trust.enabled": false, - }), - ) - const probeOut = join(dir, "probe.jsonl") - const dismiss = join(dir, "dismiss") - const folder = `vscode-remote://ssh-remote+spin.${workspace}/workspace` - const started = Date.now() - const bwrap = [ - ["--die-with-parent"], - ["--dev-bind", "/", "/"], - ["--bind", caseHome, home], - ["--bind", root, root], - ["--bind", resolve(stack), resolve(stack)], - ["--tmpfs", "/tmp"], - ["--bind", "/tmp/.X11-unix", "/tmp/.X11-unix"], - ["--ro-bind", join(dir, "ssh_config"), "/etc/ssh/ssh_config"], - ["--tmpfs", "/etc/ssh/ssh_config.d"], - process.env.XAUTHORITY ? ["--bind", process.env.XAUTHORITY, process.env.XAUTHORITY] : [], - ].flat() - const code = spawn( - "bwrap", - [ - ...bwrap, - exe, - "--no-sandbox", - "--disable-gpu", - `--user-data-dir=${userData}`, - `--extensions-dir=${extensions}`, - `--extensionDevelopmentPath=${join(root, "test/remote-ssh/testdata/probe")}`, - `--folder-uri=${folder}`, - "--log=trace", - "--skip-welcome", - "--skip-release-notes", - "--disable-workspace-trust", - "--new-window", - ], - { - detached: true, - stdio: "ignore", - env: { - ...process.env, - SSL_CERT_DIR: trust, - SPINBOX_PROBE_OUT: probeOut, - SPINBOX_PROBE_DISMISS: dismiss, - }, - }, - ) - const seen = () => ({ - started, - probe: read(probeOut).map((l) => JSON.parse(l)), - calls: read(calls), - spinbox: logOf(userData, "Spinbox.log"), - remoteSSH: logOf(userData, "Remote - SSH.log"), - exthost: logOf(userData, "exthost.log"), - renderer: logOf(userData, "renderer.log"), +const lab = await lane("spinbox-remote-ssh-", ["ms-vscode-remote.remote-ssh", vsix]) +const { check } = lab +const stateOf = lab.cli.stateOf +const asPerson = lab.cli.run + +function open(name, workspace, { enrolled, until }) { + return openCase(lab, name, { + enrolled, + until, + waitMs: WAIT_MS, + folder: `vscode-remote://ssh-remote+spin.${workspace}/workspace`, + settings: (wrapper) => ({ "spinbox.cliPath": wrapper }), + logs: { spinbox: "Spinbox.log", remoteSSH: "Remote - SSH.log" }, }) - let s = seen() - const end = Date.now() + WAIT_MS - while (Date.now() < end && !until(s, dismiss)) { - await new Promise((r) => setTimeout(r, 1_000)) - s = seen() - } - try { - process.kill(-code.pid, "SIGTERM") - } catch {} - await new Promise((r) => setTimeout(r, 2_000)) - return seen() -} - -const failures = [] -function check(name, ok, evidence) { - say(`${ok ? "ok " : "FAIL"} ${name}`) - for (const line of [evidence].flat().filter((l) => l !== undefined)) console.log(` ${line}`) - if (!ok) failures.push(name) } // Spinbox's activation by the resolution, the `spin code ` it ran and when that ended, @@ -288,7 +81,7 @@ const errorsOf = (s) => s.remoteSSH.filter((l) => !l.includes("[server]") && GAV const connected = (s) => s.probe.some((e) => e.event === "terminal") async function connects(name, workspace) { - const s = await openCase(name, workspace, { enrolled: true, until: connected }) + const s = await open(name, workspace, { enrolled: true, until: connected }) const o = ordering(s, workspace) check( `${name}: Spinbox ran spin code --start before Remote-SSH resolved`, @@ -326,9 +119,9 @@ const cases = { }, unenrolled: async () => { let refusedAt - const s = await openCase("unenrolled", "e2e-running", { + const s = await open("unenrolled", "e2e-running", { enrolled: false, - until: (s, dismiss) => { + until: (s, { dismiss }) => { if (refusedAt === undefined && s.spinbox.some((l) => l.includes("[key_not_enrolled]"))) { refusedAt = Date.now() } @@ -394,11 +187,4 @@ const cases = { }, } -for (const [name, run] of Object.entries(cases)) { - if (only === undefined || only.includes(name)) await run() -} -say(`logs in ${work}`) -if (failures.length > 0) { - console.error(`${failures.length} failed: ${failures.join("; ")}`) - process.exit(1) -} +await lab.run(cases) diff --git a/test/spin-remote/run.mjs b/test/spin-remote/run.mjs new file mode 100644 index 0000000..adeb3de --- /dev/null +++ b/test/spin-remote/run.mjs @@ -0,0 +1,243 @@ +// Spinbox Remote (spin-stack.spinbox-remote, spin-stack/open-remote-ssh) and the Spinbox VSIX, +// installed into a real VS Code stable, opening the workspaces of a spin e2e stack as +// vscode-remote://spin+, as Spinbox opens them when the resolver is active and as a +// reconnect or a recent folder does. No Remote-SSH is installed: spin+ needs none. The proposed +// API the resolver runs on is enabled the way a person enables it, by argv.json in their home. +// What each case asserts is what the person sees, read from VS Code's logs, the probe and the stack: +// +// - running: the resolver activates on onResolveRemoteAuthority:spin, runs spin code, +// ssh-session and tunnel in that order, and a command typed into the window's terminal runs in +// the workspace; +// - stopped: the same, with the workspace started by the resolver's spin code --start; +// - reconnect: the connection's ssh and tunnel killed under a connected window, as a network that +// drops does: VS Code resolves again, the window is back within RECONNECT_MS, and the terminal +// 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; +// - 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. +// +// 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" + +const RESOLVER = "spin-stack.spinbox-remote" +const vsix = process.env.SPINBOX_VSIX ?? resolve(root, "spinbox.vsix") +const remoteVsix = process.env.SPINBOX_REMOTE_VSIX +if (!remoteVsix) { + console.error("SPINBOX_REMOTE_VSIX: Spinbox Remote's VSIX (open-remote-ssh's task package)") + process.exit(2) +} +const WAIT_MS = 360_000 +// How long a window that lost its connection may take to be back: VS Code noticing, its resolve, +// the resolver's spin code, ssh-session and login, and the server reached again. +const RECONNECT_MS = Number(process.env.RECONNECT_MS ?? 60_000) +// How long a reconnected window's terminal may take to answer. +const ANSWER_MS = 20_000 +// How long the unenrolled case watches after the refusal for a retry that must not come. +const SETTLE_MS = 15_000 + +const lab = await lane("spinbox-spin-remote-", [vsix, remoteVsix]) +const { check } = lab +const stateOf = lab.cli.stateOf +const asPerson = lab.cli.run + +function open(name, workspace, { enrolled = true, until }) { + return openCase(lab, name, { + enrolled, + until, + waitMs: WAIT_MS, + folder: `vscode-remote://spin+${workspace}/workspace`, + settings: (wrapper) => ({ "spinbox.cliPath": wrapper, "remote.spin.cliPath": wrapper }), + homeFiles: { + ".vscode/argv.json": JSON.stringify({ "enable-proposed-api": [RESOLVER] }), + }, + logs: { remote: "Spinbox Remote.log", spinbox: "Spinbox.log" }, + }) +} + +// The spin subcommands the resolver ran for workspace, in order, with each one's exit code. +function callsOf(s, workspace) { + return s.calls + .map((l) => /^(\d+) end (\d+) (\S+) (.*)$/.exec(l)) + .filter((m) => m !== null) + .map((m) => ({ t: Number(m[1]), rc: Number(m[2]), cmd: m[3], args: m[4] })) + .filter((c) => c.cmd === "tunnel" || c.args.startsWith(`${workspace} `) || c.args === workspace) +} + +const opened = (s) => s.remote.filter((l) => /open on 127\.0\.0\.1:\d+/.test(l)) +const resolving = (s) => s.remote.filter((l) => /Resolving Spinbox workspace/.test(l)) +const terminal = (s, event = "terminal") => s.probe.find((e) => e.event === event) +const connected = (s) => terminal(s)?.output != null +// The probe said what its terminal wrote, or that it could not read it back: either way, done. +const answered = (s) => terminal(s) !== undefined + +// The shell a probe line was written from: hostname, user, the shell's pid, the folder. +const shellOf = (output) => output?.split(" ")[2] + +function activatedBySpin(s) { + return s.exthost.find( + (l) => + l.includes(`_doActivateExtension ${RESOLVER},`) && + l.includes("onResolveRemoteAuthority:spin"), + ) +} + +async function connects(name, workspace) { + const s = await open(name, workspace, { until: answered }) + const activated = activatedBySpin(s) + const calls = callsOf(s, workspace) + const order = calls.map((c) => c.cmd) + check( + `${name}: Spinbox Remote activated on the spin+ authority and ran spin code, ssh-session, tunnel`, + activated !== undefined && + order.indexOf("code") === 0 && + order.indexOf("ssh-session") > 0 && + calls.find((c) => c.cmd === "code")?.args.includes("--start") && + calls.filter((c) => c.cmd !== "tunnel").every((c) => c.rc === 0) && + s.calls.some((l) => / start tunnel /.test(l)), + [activated?.slice(0, 160), ...s.calls.slice(0, 6)], + ) + const win = s.probe.find((e) => e.event === "activate") + check(`${name}: the window is a spin remote`, win?.remoteName === "spin", JSON.stringify(win)) + const t = terminal(s) + const there = t?.output ? asPerson("exec", workspace, "--no-tty", "--", "cat", t.path) : undefined + check( + `${name}: a command run in the window's terminal ran in ${workspace}`, + there?.ok === true && there.out === t.output, + [`terminal wrote ${t?.path}: ${t?.output}`, `spin exec ${workspace} cat: ${there?.out}`], + ) + const errors = s.remote.filter((l) => l.startsWith("[Error")) + check(`${name}: Spinbox Remote logged no error`, errors.length === 0, errors.slice(0, 5)) +} + +// 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) { + let cutAt + let askedAt + const s = await open(name, workspace, { + until: (s, { again }) => { + if (!answered(s)) return false + if (!connected(s)) return true + if (cutAt === undefined) { + cutAt = Date.now() + cut(s) + return false + } + if (askedAt === undefined && opened(s).length >= 2) { + askedAt = Date.now() + appendFileSync(again, "") + } + // 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 + return terminal(s, "again")?.output != null || Date.now() - askedAt > ANSWER_MS + }, + }) + const first = terminal(s) + const second = terminal(s, "again") + const reopened = opened(s) + check( + `${name}: VS Code resolved spin+${workspace} again and the resolver opened it again`, + resolving(s).length >= 2 && reopened.length >= 2, + [...resolving(s), ...reopened], + ) + check( + `${name}: the window was back within ${RECONNECT_MS / 1000} s`, + askedAt !== undefined && askedAt - cutAt <= RECONNECT_MS, + `cut at ${cutAt}, opened again at ${askedAt}: ${askedAt - cutAt} ms`, + ) + check( + `${name}: the terminal is the same shell`, + second?.output != null && shellOf(second.output) === shellOf(first?.output), + [`before: ${first?.output}`, `after: ${second?.output}`], + ) + return s +} + +const cases = { + running: async () => { + const state = stateOf("e2e-running") + check("running: e2e-running is running", state === "WORKSPACE_STATE_RUNNING", state) + await connects("running", "e2e-running") + }, + stopped: async () => { + const before = stateOf("e2e-stopped") + check("stopped: e2e-stopped is stopped", before === "WORKSPACE_STATE_STOPPED", before) + await connects("stopped", "e2e-stopped") + const after = stateOf("e2e-stopped") + check("stopped: the resolver started it", after === "WORKSPACE_STATE_RUNNING", after) + // Left as e2e:workspaces makes it, for the next run. + asPerson("workspace", "stop", "e2e-stopped", "--wait", "--timeout", "3m") + }, + reconnect: async () => { + // What a dropped network leaves: each spin tunnel the window's ssh rides on gone at once, + // the real spin with the wrapper around it, so ssh reads the end of its connection. + await survives("reconnect", "e2e-running", (s) => { + for (const l of s.calls) { + const session = / start tunnel (\S+)$/.exec(l)?.[1] + if (session) { + const who = spawnSync("pgrep", ["-a", "-f", `tunnel ${session}`], { + encoding: "utf8", + }).stdout + console.log(` cutting: ${who.trim().replaceAll("\n", " | ")}`) + spawnSync("pkill", ["-KILL", "-f", `tunnel ${session}`]) + } + } + }) + }, + unenrolled: async () => { + let refusedAt + const s = await open("unenrolled", "e2e-running", { + enrolled: false, + until: (s) => { + if (refusedAt === undefined && s.remote.some((l) => l.includes("Spinbox refused"))) { + refusedAt = Date.now() + } + return refusedAt !== undefined && Date.now() - refusedAt >= SETTLE_MS + }, + }) + const refused = s.remote.find((l) => l.includes("Spinbox refused")) + check( + "unenrolled: Spinbox Remote refused with key_not_enrolled", + refused?.includes("key_not_enrolled") === true, + refused, + ) + check( + "unenrolled: VS Code resolved once and did not try again", + resolving(s).length === 1, + resolving(s), + ) + check( + "unenrolled: no connection was made: no tunnel, no terminal", + !s.calls.some((l) => / start tunnel /.test(l)) && terminal(s) === undefined, + s.calls, + ) + }, +} + +// A move needs a second host (spin's task e2e:hosts:up), so it runs when asked for. +if (process.env.CASES?.split(",").includes("moved")) { + cases.moved = async () => { + const hostOf = () => JSON.parse(asPerson("-o", "json", "workspace", "get", "e2e-running").out) + 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}`) + }) + 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}`, + ) + } +} + +await lab.run(cases)