Report installer preflight and cleanup failures
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -182,6 +182,7 @@ function StepLine({ step }: { step: Step }) {
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [failureMessage, setFailureMessage] = useState<string>();
|
||||
const [steps, setSteps] = useState<Step[]>([
|
||||
{ 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<ReturnType<typeof prepareGalaxyBuild>> | 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() {
|
||||
<Box marginTop={1}>
|
||||
{hasError ? (
|
||||
<Text color="red" bold>
|
||||
✗ Installation failed.
|
||||
✗ Installation failed{failureMessage ? `: ${failureMessage}` : "."}
|
||||
</Text>
|
||||
) : (
|
||||
<Text color="green" bold>
|
||||
|
||||
@@ -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")));
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user