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.
This commit is contained in:
parent
1413e73015
commit
542a545c0d
17 changed files with 445 additions and 73 deletions
123
tests/run-update-installer-test.mjs
Normal file
123
tests/run-update-installer-test.mjs
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
// 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.");
|
||||
Loading…
Add table
Add a link
Reference in a new issue