Add in-app update checker and one-click updater

Settings now shows when a newer version is available, with the real
version number read from the remote's package.json (via the git
binary already required for this deployment model, so it works
against any remote, not just GitHub), and an "Uppdatera nu" button
that stops the server, pulls, reinstalls dependencies and restarts
automatically - warning explicitly if a race is currently active.
The existing app-version poller already reloads the page once a
restart is detected, so no new client-side polling was needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
larssand
2026-09-09 22:05:13 +02:00
parent 2bbbd6e995
commit 4c463485e0
9 changed files with 335 additions and 8 deletions

View File

@@ -1,7 +1,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
import { spawn, spawnSync } from "node:child_process";
import fs, { mkdtempSync, rmSync, readFileSync } from "node:fs";
import { createServer } from "node:net";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
@@ -406,6 +406,82 @@ test("a stale full-state save from a second browser cannot revert a session's ju
}
});
function runGit(args, cwd) {
const result = spawnSync("git", args, { cwd, encoding: "utf8" });
if (result.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr}`);
return result.stdout.trim();
}
test("update check compares against a real (throwaway, local) git remote and extracts its package.json version", { timeout: 20000 }, async () => {
const workDir = mkdtempSync(join(tmpdir(), "live-rc-test-update-"));
const bareRemote = join(workDir, "origin.git");
const installDir = join(workDir, "install");
const otherClone = join(workDir, "other-clone");
fs.mkdirSync(bareRemote, { recursive: true });
fs.mkdirSync(installDir, { recursive: true });
runGit(["init", "--bare"], bareRemote);
runGit(["init", "-b", "main"], installDir);
runGit(["config", "user.email", "test@example.com"], installDir);
runGit(["config", "user.name", "Test"], installDir);
fs.writeFileSync(join(installDir, "package.json"), JSON.stringify({ name: "fake", version: "1.0.0" }));
runGit(["add", "."], installDir);
runGit(["commit", "-m", "initial"], installDir);
runGit(["remote", "add", "origin", bareRemote], installDir);
runGit(["push", "-u", "origin", "main"], installDir);
const dataDir = mkdtempSync(join(tmpdir(), "live-rc-test-update-data-"));
const port = await reservePort();
const baseUrl = `http://127.0.0.1:${port}`;
const child = spawn(process.execPath, [join(projectRoot, "server.js")], {
cwd: projectRoot,
env: { ...process.env, HOST: "127.0.0.1", PORT: String(port), RC_TIMING_DATA_DIR: dataDir, RC_TIMING_PID_FILE: "", RC_UPDATE_GIT_DIR: installDir },
stdio: ["ignore", "pipe", "pipe"],
});
try {
await waitForServer(child);
const upToDate = await (await fetch(`${baseUrl}/api/update-status?force=1`)).json();
assert.equal(upToDate.checked, true);
assert.equal(upToDate.available, false, "no commit has been pushed beyond what the install already has");
assert.equal(upToDate.currentVersion, JSON.parse(readFileSync(join(projectRoot, "package.json"), "utf8")).version, "currentVersion reflects the real running server, not the fake install dir");
// Simulate someone else pushing a new release to the remote.
fs.mkdirSync(otherClone, { recursive: true });
runGit(["clone", bareRemote, otherClone], workDir);
// The bare remote's own HEAD symref still points at whatever git's default branch name was
// at `git init --bare` time (e.g. "master"), even though "main" is the only branch that
// actually has commits - check it out explicitly rather than relying on the clone's HEAD.
runGit(["checkout", "main"], otherClone);
runGit(["config", "user.email", "test@example.com"], otherClone);
runGit(["config", "user.name", "Test"], otherClone);
fs.writeFileSync(join(otherClone, "package.json"), JSON.stringify({ name: "fake", version: "1.1.0" }));
runGit(["commit", "-am", "bump version"], otherClone);
runGit(["push", "origin", "main"], otherClone);
const outdated = await (await fetch(`${baseUrl}/api/update-status?force=1`)).json();
assert.equal(outdated.checked, true);
assert.equal(outdated.available, true, "the remote now has a commit the install doesn't");
assert.equal(outdated.remoteVersion, "1.1.0", "the new version must be read from the remote's package.json, not guessed");
assert.notEqual(outdated.remoteCommit, outdated.localCommit);
// Without force, the endpoint must serve the cached result instantly rather than doing
// another real git fetch - confirmed by pushing yet another change and checking it is
// *not* reflected without force, only the previously cached "available: true" is.
fs.writeFileSync(join(otherClone, "package.json"), JSON.stringify({ name: "fake", version: "1.2.0" }));
runGit(["commit", "-am", "bump version again"], otherClone);
runGit(["push", "origin", "main"], otherClone);
const cached = await (await fetch(`${baseUrl}/api/update-status`)).json();
assert.equal(cached.available, true, "reuses the cached result from the previous forced check");
assert.equal(cached.remoteVersion, "1.1.0", "must still be the cached 1.1.0, not the just-pushed 1.2.0 - proving it did not re-check");
} finally {
await stopServer(child);
rmSync(dataDir, { recursive: true, force: true });
rmSync(workDir, { recursive: true, force: true });
}
});
test("PDF export renders Swedish åäö/ÅÄÖ correctly instead of mojibake", { timeout: 20000 }, async () => {
const dataDir = mkdtempSync(join(tmpdir(), "live-rc-test-pdf-"));
const port = await reservePort();