Folio/tests/run-update-installer-test.mjs
avi 542a545c0d feat: spellcheck menu, typewriter mode, file associations, updater hardening
English spellcheck suggestions with Add-to-dictionary in the native context menu plus a Settings toggle. Typewriter caret-centering mode. OS file associations for .md/.txt and Open File wording. Updater: release URL surfaced, single progress listener, two-byte tarball check, installer wait guard. Search/installer test suites registered; chapter-list layout polish; ignore KDE .directory files.
2026-09-19 16:38:17 -05:00

123 lines
5.1 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 });
}
cleanupBundle();
if (fails) { console.error(`${fails} installer check(s) FAILED`); process.exit(1); }
console.log("All installer checks passed.");