Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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+<workspace>
(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=<spin
with the same terminal, a refused key said once, and the dashboard's link bringing the window
already open. Needs the stack - SPIN_STACK=<spin
checkout> after its `task e2e:up` and `task e2e:workspaces` - bwrap, and
SPINBOX_REMOTE_VSIX=<open-remote-ssh's 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
Expand Down
29 changes: 24 additions & 5 deletions test/lane/lane.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
},
},
)
Expand Down Expand Up @@ -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()
}

Expand Down
4 changes: 4 additions & 0 deletions test/lane/testdata/probe/extension.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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 = {}) {
Expand All @@ -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")
Expand Down
133 changes: 114 additions & 19 deletions test/spin-remote/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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, {
Expand All @@ -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
},
})
Expand All @@ -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(
Expand Down Expand Up @@ -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}`,
)
}
}
Expand Down
Loading