Folio/tests/run-update-installer-test.mjs

149 lines
6.3 KiB
JavaScript

// Sandbox execution test for the updater handoff script (buildInstallerScript).
// Runs the generated /bin/sh script against fake install dirs in os.tmpdir() —
// never the real ~/.local/share/Folio — proving the swap works and failures
// always relaunch the OLD version.
// Run with: node tests/run-update-installer-test.mjs
import { execFileSync, spawn } from "child_process";
import fs from "fs";
import os from "os";
import path from "path";
import url from "url";
import { bundleForTest } from "./helpers/bundle.mjs";
const { mod: u, cleanupBundle } = await bundleForTest({
name: "folio-update-script",
entryPoints: ["src/main/update.ts"],
});
let fails = 0;
function ok(cond, msg) {
if (cond) console.log(" ok -", msg);
else { fails++; console.error("FAIL -", msg); }
}
function sleepPid(ms) {
// Short-lived stand-in for the app process the script waits on.
const p = spawn("sleep", [String(ms / 1000)], { stdio: "ignore" });
return p.pid;
}
// A pid that is dead AND reaped (kill -0 fails), so the script proceeds
// without waiting. NOTE: a Node-spawned child that just exited stays a
// zombie while our event loop is blocked inside execFileSync — and kill -0
// succeeds on zombies — so the pid must be observed dead before running the
// script, not merely spawned with a short sleep.
async function deadPid() {
const p = spawn("sleep", ["0.01"], { stdio: "ignore" });
const pid = p.pid;
p.on("error", () => {});
for (let i = 0; i < 200; i++) {
try {
process.kill(pid, 0);
} catch {
return pid;
}
await new Promise((r) => setTimeout(r, 10));
}
throw new Error("helper process would not die");
}
function runScript(script, timeoutMs = 30000) {
execFileSync("/bin/sh", [script], { timeout: timeoutMs, stdio: "ignore" });
}
// Fake "installed" app tree: an executable `folio` stub + a data file.
function makeInstall(box, versionMarker) {
const dir = path.join(box, "install");
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, "folio"), `#!/bin/sh\necho ${versionMarker}\n`, { mode: 0o755 });
fs.writeFileSync(path.join(dir, "other.txt"), "keepme");
return dir;
}
// Fake release tarball with an electron-builder-style nested top-level dir.
function makeTarball(box, versionMarker) {
const staged = path.join(box, "staged", "Folio-linux-x64");
fs.mkdirSync(staged, { recursive: true });
fs.writeFileSync(path.join(staged, "folio"), `#!/bin/sh\necho ${versionMarker}\n`, { mode: 0o755 });
fs.writeFileSync(path.join(staged, "extra.txt"), "newfile");
const tarball = path.join(box, "release.tar.gz");
execFileSync("tar", ["-czf", tarball, "-C", path.join(box, "staged"), "Folio-linux-x64"]);
return tarball;
}
// Relaunch stub records that it was invoked.
function makeRelaunch(box) {
const marker = path.join(box, "relaunched");
const launcher = path.join(box, "relaunch.sh");
fs.writeFileSync(launcher, `#!/bin/sh\ntouch ${JSON.stringify(marker)}\n`, { mode: 0o755 });
return { launcher, marker };
}
function writeScript(box, spec) {
const script = path.join(box, "installer.sh");
fs.writeFileSync(script, u.buildInstallerScript(spec), { mode: 0o700 });
return script;
}
// --- success path: valid tarball replaces the install, relaunches ---
{
const box = fs.mkdtempSync(path.join(os.tmpdir(), "folio-upd-ok-"));
const install = makeInstall(box, "OLD");
const tarball = makeTarball(box, "NEW");
const { launcher, marker } = makeRelaunch(box);
const pid = await deadPid();
runScript(writeScript(box, { pid, tarball, installDir: install, relaunch: launcher }));
await new Promise((r) => setTimeout(r, 800)); // relaunched in background (&)
ok(fs.readFileSync(path.join(install, "folio"), "utf-8").includes("NEW"), "install swapped to new version");
ok(fs.readFileSync(path.join(install, "extra.txt"), "utf-8") === "newfile", "new files land in install");
ok(fs.existsSync(marker), "relaunch invoked after swap");
ok(!fs.existsSync(tarball), "tarball cleaned up after success");
ok(!fs.readdirSync(path.dirname(install)).some((e) => e.includes(".old.")), "old tree pruned after success");
fs.rmSync(box, { recursive: true, force: true });
}
// --- failure path: corrupt tarball keeps the old install, still relaunches ---
{
const box = fs.mkdtempSync(path.join(os.tmpdir(), "folio-upd-bad-"));
const install = makeInstall(box, "OLD");
const tarball = path.join(box, "release.tar.gz");
fs.writeFileSync(tarball, "this is not a gzip stream");
const { launcher, marker } = makeRelaunch(box);
const pid = await deadPid();
runScript(writeScript(box, { pid, tarball, installDir: install, relaunch: launcher }));
await new Promise((r) => setTimeout(r, 800));
ok(fs.readFileSync(path.join(install, "folio"), "utf-8").includes("OLD"), "old install intact after bad tarball");
ok(fs.existsSync(marker), "relaunch still invoked after failure");
ok(!fs.readdirSync(path.dirname(install)).some((e) => e.includes(".old.")), "no .old residue after failure");
fs.rmSync(box, { recursive: true, force: true });
}
// --- archive integrity: verifyArchiveGzip + looksTruncated ---
{
const box = fs.mkdtempSync(path.join(os.tmpdir(), "folio-upd-gz-"));
const good = path.join(box, "good.tar.gz");
const zlib = await import("zlib");
fs.writeFileSync(good, zlib.gzipSync(Buffer.from("hello folio updater ".repeat(1000))));
ok(await u.verifyArchiveGzip(good), "complete gzip verifies");
// Truncate the good archive mid-stream: magic bytes present, payload short.
const full = fs.readFileSync(good);
const trunc = path.join(box, "trunc.tar.gz");
fs.writeFileSync(trunc, full.subarray(0, full.length - 50));
ok(!(await u.verifyArchiveGzip(trunc)), "truncated gzip REJECTED (the 0.1.2/0.1.3 bug)");
const notgz = path.join(box, "notgz.tar.gz");
fs.writeFileSync(notgz, "<html>error page</html>");
ok(!(await u.verifyArchiveGzip(notgz)), "non-gzip content rejected");
ok(!(await u.verifyArchiveGzip(path.join(box, "missing.tar.gz"))), "missing file rejected");
ok(u.looksTruncated(50, 100), "short byte count flagged");
ok(!u.looksTruncated(100, 100), "exact byte count passes");
ok(!u.looksTruncated(120, 100), "over-long passes");
ok(!u.looksTruncated(7, 0), "unknown total not flagged");
fs.rmSync(box, { recursive: true, force: true });
}
cleanupBundle();
if (fails) { console.error(`${fails} installer check(s) FAILED`); process.exit(1); }
console.log("All installer checks passed.");