From 7ce0236825e4d1630debab71c84043f02ee01ae2 Mon Sep 17 00:00:00 2001 From: Ryan Ward Date: Mon, 26 Oct 2026 07:07:45 +0000 Subject: [PATCH] Report installer preflight and cleanup failures --- .../package.json | 3 +- .../src/index.tsx | 40 ++++--- .../test/preflight.test.mjs | 100 ++++++++++++++++++ script/lib/prepare-galaxy-build.mjs | 3 +- 4 files changed, 131 insertions(+), 15 deletions(-) create mode 100644 script/build-and-install-to-applications/test/preflight.test.mjs diff --git a/script/build-and-install-to-applications/package.json b/script/build-and-install-to-applications/package.json index 1ca7439a..1d915d26 100644 --- a/script/build-and-install-to-applications/package.json +++ b/script/build-and-install-to-applications/package.json @@ -5,7 +5,8 @@ "packageManager": "yarn@1.22.22", "type": "module", "scripts": { - "start": "tsx src/index.tsx" + "start": "tsx src/index.tsx", + "test": "node --test test/*.test.mjs" }, "dependencies": { "ink": "^5.1.0", diff --git a/script/build-and-install-to-applications/src/index.tsx b/script/build-and-install-to-applications/src/index.tsx index 7f109924..b719299a 100644 --- a/script/build-and-install-to-applications/src/index.tsx +++ b/script/build-and-install-to-applications/src/index.tsx @@ -182,6 +182,7 @@ function StepLine({ step }: { step: Step }) { } function App() { + const [failureMessage, setFailureMessage] = useState(); const [steps, setSteps] = useState([ { label: "Check working directory", status: "pending" }, { label: "Pull current branch", status: "pending" }, @@ -205,6 +206,22 @@ function App() { ); }, []); + const reportFailure = useCallback((error: unknown, context?: string) => { + const detail = error instanceof Error ? error.message : String(error); + const message = context ? `${context}: ${detail}` : detail; + process.exitCode = 1; + // Preserve the diagnostic even if the process exits before Ink redraws. + process.stderr.write(`Installation failed: ${message}\n`); + setFailureMessage((previous) => previous ?? message); + setSteps((previous) => + previous.map((step) => + step.status === "running" + ? { ...step, status: "error" as StepStatus, detail: message } + : step + ) + ); + }, []); + useEffect(() => { (async () => { let prepared: Awaited> | undefined; @@ -298,22 +315,19 @@ function App() { WORKSPACE_ROOT, (line) => appendLog(5, line) ); updateStep(5, { status: "done" }); - } catch (err: any) { - process.exitCode = 1; - setSteps((prev) => - prev.map((s) => - s.status === "running" - ? { ...s, status: "error" as StepStatus, detail: err.message } - : s - ) - ); + } catch (error) { + reportFailure(error); } finally { - await prepared?.release(); + try { + await prepared?.release(); + } catch (error) { + reportFailure(error, "Failed to remove temporary build files"); + } } })(); - }, []); + }, [reportFailure]); - const hasError = steps.some((s) => s.status === "error"); + const hasError = failureMessage !== undefined || steps.some((s) => s.status === "error"); const allDone = hasError || steps.every((s) => s.status === "done"); return ( @@ -332,7 +346,7 @@ function App() { {hasError ? ( - ✗ Installation failed. + ✗ Installation failed{failureMessage ? `: ${failureMessage}` : "."} ) : ( diff --git a/script/build-and-install-to-applications/test/preflight.test.mjs b/script/build-and-install-to-applications/test/preflight.test.mjs new file mode 100644 index 00000000..0eb49df9 --- /dev/null +++ b/script/build-and-install-to-applications/test/preflight.test.mjs @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import * as fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, test } from "node:test"; +import { fileURLToPath } from "node:url"; + +const project = fileURLToPath(new URL("../", import.meta.url)); +const fixtures = []; + +afterEach(async () => { + for (const root of fixtures.splice(0)) { + await fs.rm(root, { recursive: true, force: true }); + } +}); + +async function fixture() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "galaxy-install-preflight-")); + fixtures.push(root); + const app = path.join(root, "script/build-and-install-to-applications"); + const lib = path.join(root, "script/lib"); + const bin = path.join(root, "bin"); + await fs.mkdir(path.join(app, "src"), { recursive: true }); + await fs.mkdir(lib, { recursive: true }); + await fs.mkdir(bin); + await fs.copyFile(path.join(project, "src/index.tsx"), path.join(app, "src/index.tsx")); + await fs.copyFile(path.join(project, "package.json"), path.join(app, "package.json")); + await fs.copyFile(path.join(project, "../lib/prepare-galaxy-build.mjs"), path.join(lib, "prepare-galaxy-build.mjs")); + await fs.symlink(path.join(project, "node_modules"), path.join(app, "node_modules"), "dir"); + // A regression must never progress into a real Git, build, or install operation. + const gitSentinel = path.join(root, "unexpected-git-command"); + await fs.writeFile(path.join(bin, "git"), "#!/bin/sh\ntouch unexpected-git-command\nexit 97\n", { mode: 0o755 }); + const run = () => { + const result = spawnSync(process.execPath, [path.join(project, "node_modules/tsx/dist/cli.mjs"), path.join(app, "src/index.tsx")], { + cwd: root, + env: { ...process.env, PATH: `${bin}${path.delimiter}${process.env.PATH || ""}`, CI: "true", FORCE_COLOR: "0" }, + encoding: "utf8", + timeout: 15000, + }); + assert.equal(result.error, undefined); + assert.equal(result.status, 1); + assert.match(result.stdout, /Installation failed/); + return result; + }; + return { root, lib, run, gitSentinel }; +} + +test("cache initialization errors are visible before any normal step starts", async () => { + const { root, run, gitSentinel } = await fixture(); + await fs.mkdir(path.join(root, "target")); + await fs.writeFile(path.join(root, "target/galaxy-build-cache"), "not a directory"); + const result = run(); + assert.match(result.stderr, /Installation failed:.*EEXIST/); + assert.match(result.stderr, /galaxy-build-cache/); + await assert.rejects(fs.access(gitSentinel)); +}); + +test("stale locks report the exact lock path and retain the lock for manual recovery", async () => { + const { root, run, gitSentinel } = await fixture(); + const lock = path.join(root, "target/galaxy-build-cache/prepare.lock"); + await fs.mkdir(path.dirname(lock), { recursive: true }); + await fs.symlink(JSON.stringify({ pid: 99999999, start: "old process", token: "abandoned" }), lock); + const result = run(); + assert.match(result.stderr, /Installation failed: Stale Galaxy build lock/); + assert.ok(result.stderr.includes(lock)); + assert.ok((await fs.lstat(lock)).isSymbolicLink()); + await assert.rejects(fs.access(gitSentinel)); +}); + +test("failures without an active step still appear in the final output", async () => { + const { lib, run, gitSentinel } = await fixture(); + await fs.writeFile(path.join(lib, "prepare-galaxy-build.mjs"), ` + export async function prepareGalaxyBuild() { + throw new Error("fixture early failure"); + } + `); + const result = run(); + assert.match(result.stderr, /Installation failed: fixture early failure/); + assert.match(result.stdout, /Installation failed: fixture early failure/); + await assert.rejects(fs.access(gitSentinel)); +}); + +test("cleanup failures are reported without hiding the original failure", async () => { + const { root, lib, run, gitSentinel } = await fixture(); + await fs.writeFile(path.join(lib, "prepare-galaxy-build.mjs"), ` + export async function prepareGalaxyBuild(root) { + return { + appPath: root + "/missing/Galaxy.app", + release: async () => { throw new Error("fixture cleanup failure"); }, + }; + } + `); + const result = run(); + assert.match(result.stderr, /Installation failed: Built app not found/); + assert.match(result.stderr, /Failed to remove temporary build files: fixture cleanup failure/); + assert.match(result.stdout, /Installation failed:\s*Built app not found/); + await assert.rejects(fs.access(gitSentinel)); + await assert.rejects(fs.access(path.join(root, "target"))); +}); diff --git a/script/lib/prepare-galaxy-build.mjs b/script/lib/prepare-galaxy-build.mjs index 6a460d7e..d5a69735 100644 --- a/script/lib/prepare-galaxy-build.mjs +++ b/script/lib/prepare-galaxy-build.mjs @@ -165,9 +165,10 @@ export async function prepareGalaxyBuild(workspaceRoot, observer = {}, options = const update = typeof observer === 'function' ? observer : observer.updateStep?.bind(observer) || (() => {}); const log = typeof observer === 'function' ? (() => {}) : observer.appendLog?.bind(observer) || observer.log?.bind(observer) || (() => {}); const command = (name, args, step) => run(name, args, { cwd: root, env, capture: !(name === 'cargo' && args[0] === 'bundle' && args.includes('--release')), onLog: (line) => log(step, line) }); + update(0, { status: 'running', detail: 'Acquiring build lock' }); const unlock = await acquireLock(cache); try { - update(0, { status: 'running' }); + update(0, { status: 'running', detail: undefined }); const changes = await command('git', ['status', '--porcelain', '--untracked-files=all'], 0); if (changes.trim()) { for (const line of changes.split('\n')) log(0, line);