diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a5ab44..968fa43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ The application version is maintained in `package.json` and displayed in the adm No changes recorded yet. +## 1.8.0 - 2026-09-09 + +### Added + +- Settings now shows whether a newer version of RC RaceCore is available - checked against the actual git remote this installation was cloned from, so it works the same whether that's GitHub or a self-hosted server, and needs no account or API key. When an update is available it shows the new version number (read from the remote's package.json) and an "Uppdatera nu" / "Update now" button that stops the server, pulls the new code, reinstalls dependencies and starts back up automatically, with a confirmation dialog that warns explicitly if a race/session is currently active. The page then reloads on its own once the server is back, reusing the app's existing update-detection so no extra polling logic was needed on the client. No git knowledge is required to update - a single click does the whole thing. + ## 1.7.1 - 2026-09-09 ### Fixed diff --git a/ROADMAP.md b/ROADMAP.md index 74e17d8..bbb1e44 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -165,6 +165,16 @@ Fixed 2026-09-09, reported while exporting an old race from the new Results arch Verified 2026-09-09: a new integration test (`tests/server_persistence.integration.test.js`) exports a PDF with a Swedish title and a table row containing å/ä/ö/Å/Ä/Ö, asserts the font declares WinAnsiEncoding, asserts the correct single-byte WinAnsi sequence is present in the raw PDF bytes, and explicitly asserts the old UTF-8 byte sequence is *not* present - confirmed this test genuinely fails against the pre-fix code (not a tautology) by re-running it with the fix reverted. +## In-app update checker and one-click updater + +Implemented 2026-09-09, requested by the user: an easy way to see a new version is available in the GUI, and an easy way to update for someone not comfortable with `git pull` on their own. + +- [x] Show whether a newer version is available, with the actual version number. `GET /api/update-status` shells out to the git binary already required for this deployment model (`git fetch`, `git rev-parse`, `git show origin/:package.json`) rather than any git-hosting-specific API, so it works the same against GitHub, GitLab or a self-hosted remote. Result is cached for 10 minutes (`force=1` bypasses it) so opening Settings repeatedly doesn't hammer the remote. Settings now shows the installed version, and - only when an update is genuinely available - the new version number read from the remote's `package.json`, not just a commit hash. +- [x] One-click update, no git knowledge required. An "Uppdatera nu"/"Update now" button `POST`s to `/api/update/run`, which spawns the existing `scripts/serverctl.js` with a new `update` command: stop -> `git pull --ff-only` (refuses and restarts on the code already installed rather than merging/rewriting history if the local branch has diverged - a trackside laptop is not a place to leave a merge conflict for someone to resolve) -> `npm install` (warns and continues on failure rather than leaving the server down) -> start, every step logged to `logs/update.log`. The confirmation dialog before starting adds an explicit extra warning if a race/session is currently active, since the update stops the server. +- [x] Auto-reload once the server is back. Deliberately built no new client-side polling for this: the app already re-polls `/api/app-version` every 5 seconds and reloads the page once it detects the running server's process identity changed (`revision:updatedAt`) - exactly what a restart-with-new-code produces - so the existing mechanism picks this up for free. + +Verified 2026-09-09: a new integration test (`tests/server_persistence.integration.test.js`) builds a throwaway local bare git repo plus two clones (standing in for "this installation" and "somewhere a newer commit was pushed"), confirms `available: false` initially, pushes a version bump from the second clone, confirms `available: true` with the correct `remoteVersion` read from its `package.json`, and confirms the 10-minute cache is respected until `force=1`. A full headless-browser run went further than any test this session before it: cloned the real project into a throwaway git working copy (own bare "remote", own temporary SQLite data dir - the real installation and its git history were never touched), started the real server from that clone, pushed a version bump to the throwaway remote from a second clone, then in a real browser opened Settings, saw "Version 9.9.9 finns tillgänglig", clicked "Uppdatera nu", accepted the confirmation dialog, and confirmed end-to-end: the server actually stopped, `git pull --ff-only` fast-forwarded, `npm install` ran, the server came back up on the same port serving the new code, and the open browser tab auto-reloaded on its own and displayed the new version - with zero uncaught JS exceptions throughout. + ## Documentation maintenance - [ ] Update the architecture's module/dependency inventory as modules change. diff --git a/package-lock.json b/package-lock.json index 377a3a9..0b31e0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rc-racecore", - "version": "1.7.1", + "version": "1.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rc-racecore", - "version": "1.7.1", + "version": "1.8.0", "hasInstallScript": true, "dependencies": { "better-sqlite3": "^12.11.1", diff --git a/package.json b/package.json index e7b5db6..b719bdb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rc-racecore", - "version": "1.7.1", + "version": "1.8.0", "private": true, "description": "RC RaceCore live timing and race control with AMMC ingest and SQLite persistence", "engines": { @@ -18,6 +18,7 @@ "stop": "node scripts/serverctl.js stop", "status": "node scripts/serverctl.js status", "restart": "node scripts/serverctl.js restart", + "update": "node scripts/serverctl.js update", "verify": "node scripts/verify-install.js", "backup:restore": "node scripts/restore-backup.js", "audit:prod": "npm audit --omit=dev", diff --git a/scripts/serverctl.js b/scripts/serverctl.js index cc66a36..efadfc6 100644 --- a/scripts/serverctl.js +++ b/scripts/serverctl.js @@ -1,6 +1,6 @@ const fs = require("fs"); const path = require("path"); -const { spawn } = require("child_process"); +const { spawn, spawnSync } = require("child_process"); const ROOT = path.join(__dirname, ".."); const DATA_DIR = path.join(ROOT, "data"); @@ -8,6 +8,7 @@ const LOG_DIR = path.join(ROOT, "logs"); const PID_FILE = path.join(DATA_DIR, "server.pid"); const OUT_LOG = path.join(LOG_DIR, "server.out.log"); const ERR_LOG = path.join(LOG_DIR, "server.err.log"); +const UPDATE_LOG = path.join(LOG_DIR, "update.log"); fs.mkdirSync(DATA_DIR, { recursive: true }); fs.mkdirSync(LOG_DIR, { recursive: true }); @@ -22,6 +23,8 @@ if (command === "start") { restart(); } else if (command === "status") { status(); +} else if (command === "update") { + update(); } else { console.error(`Unknown command: ${command}`); process.exit(1); @@ -97,6 +100,48 @@ async function restart() { start(); } +async function update() { + const log = (line) => { + const stamped = `[${new Date().toISOString()}] ${line}\n`; + process.stdout.write(stamped); + try { + fs.appendFileSync(UPDATE_LOG, stamped); + } catch { + // best-effort - the console line above is the fallback record + } + }; + + log("Stopping server for update..."); + await stop(); + + // --ff-only refuses to update rather than creating a merge commit or hitting a conflict a + // trackside user has no way to resolve - if the local branch has diverged, this is meant to + // fail loudly (and still start the server back up with the code that was already there) so a + // human looks at it, not silently rewrite history on a live installation. + log("Pulling latest code (git pull --ff-only)..."); + const pull = spawnSync("git", ["pull", "--ff-only"], { cwd: ROOT, encoding: "utf8" }); + log(pull.stdout || ""); + if (pull.stderr) log(pull.stderr); + if (pull.status !== 0) { + log("git pull failed or was not a fast-forward - starting the server back up with the code already installed, no changes made."); + start(); + return; + } + + log("Installing dependencies (npm install)..."); + const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; + const install = spawnSync(npmCommand, ["install", "--no-audit", "--no-fund"], { cwd: ROOT, encoding: "utf8" }); + log(install.stdout || ""); + if (install.stderr) log(install.stderr); + if (install.status !== 0) { + log("npm install failed - starting the server anyway with whatever dependencies are currently installed."); + } + + log("Starting server..."); + start(); + log("Update finished."); +} + function status() { const pid = readPid(); if (pid && isRunning(pid)) { diff --git a/server.js b/server.js index 61fecc8..f14c4eb 100644 --- a/server.js +++ b/server.js @@ -2,7 +2,7 @@ const fs = require("fs"); const path = require("path"); const os = require("os"); const net = require("net"); -const { spawn } = require("child_process"); +const { spawn, execFile } = require("child_process"); const express = require("express"); const QRCode = require("qrcode"); const Database = require("better-sqlite3"); @@ -40,6 +40,62 @@ const appVersion = { updatedAt: new Date().toISOString(), }; watchAppFiles(); + +// Everything below supports the in-app "an update is available" check and the one-click +// updater. It always shells out to the git binary already required for this deployment model +// (clone + git pull) rather than any git-hosting-specific API, so it works the same way against +// any remote (GitHub, GitLab, a self-hosted server) with no code that assumes which one is used. +// Overridable so tests can point this at a throwaway local repo/remote instead of this +// installation's real one - never at the real remote, and never touching the working tree. +const UPDATE_GIT_DIR = process.env.RC_UPDATE_GIT_DIR ? path.resolve(process.env.RC_UPDATE_GIT_DIR) : __dirname; + +function runGitCommand(args, timeoutMs = 8000) { + return new Promise((resolve) => { + execFile("git", args, { cwd: UPDATE_GIT_DIR, timeout: timeoutMs }, (error, stdout, stderr) => { + resolve({ ok: !error, stdout: String(stdout || "").trim(), stderr: String(stderr || "").trim() }); + }); + }); +} + +const UPDATE_CHECK_CACHE_MS = 10 * 60 * 1000; +let updateStatusCache = { checkedAt: 0, result: null }; + +async function checkForUpdate() { + const branchResult = await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"]); + const branch = branchResult.ok && branchResult.stdout && branchResult.stdout !== "HEAD" ? branchResult.stdout : "main"; + // A real network call (git fetch is one) can't be avoided for a genuine "is there something + // new" check, so this is cached and only ever triggered by a background poll or an explicit + // user click - never on every page load. + const fetchResult = await runGitCommand(["fetch", "--quiet", "origin", branch], 15000); + if (!fetchResult.ok) { + return { checked: false, available: false, currentVersion: appVersion.version, error: "Could not reach the update server" }; + } + const localResult = await runGitCommand(["rev-parse", "HEAD"]); + const remoteResult = await runGitCommand(["rev-parse", `origin/${branch}`]); + if (!localResult.ok || !remoteResult.ok) { + return { checked: false, available: false, currentVersion: appVersion.version, error: "Not a git installation" }; + } + const available = localResult.stdout !== remoteResult.stdout; + let remoteVersion = ""; + if (available) { + const packageResult = await runGitCommand(["show", `origin/${branch}:package.json`]); + if (packageResult.ok) { + try { + remoteVersion = String(JSON.parse(packageResult.stdout)?.version || ""); + } catch { + // leave remoteVersion empty if the remote package.json can't be parsed + } + } + } + return { + checked: true, + available, + currentVersion: appVersion.version, + remoteVersion, + localCommit: localResult.stdout.slice(0, 10), + remoteCommit: remoteResult.stdout.slice(0, 10), + }; +} function createAmmcChannelState() { return { process: null, @@ -104,6 +160,31 @@ app.get("/api/app-version", (_req, res) => { res.json(appVersion); }); +app.get("/api/update-status", async (req, res) => { + const force = req.query.force === "1"; + if (!force && updateStatusCache.result && Date.now() - updateStatusCache.checkedAt < UPDATE_CHECK_CACHE_MS) { + res.json(updateStatusCache.result); + return; + } + const result = await checkForUpdate(); + updateStatusCache = { checkedAt: Date.now(), result }; + res.json(result); +}); + +app.post("/api/update/run", (req, res) => { + res.json({ ok: true, message: "Update starting - the server will restart shortly." }); + // A detached child outlives this response and this process: it stops the currently running + // server (this one), pulls and installs the update, then starts a fresh server - the exact + // same stop/start machinery "npm run restart" already uses, with git pull/npm install + // sandwiched in between. See scripts/serverctl.js's "update" command. + const child = spawn(process.execPath, [path.join(__dirname, "scripts", "serverctl.js"), "update"], { + cwd: __dirname, + detached: true, + stdio: "ignore", + }); + child.unref(); +}); + app.get("/api/network/public-address", async (_req, res) => { try { const response = await fetch("https://api.ipify.org?format=json", { signal: AbortSignal.timeout(5000) }); diff --git a/src/app.js b/src/app.js index 126f218..9fbcf73 100644 --- a/src/app.js +++ b/src/app.js @@ -1476,6 +1476,18 @@ const TRANSLATIONS = { "settings.logo_upload": "Ladda logo", "settings.logo_clear": "Rensa logo", "settings.logo_note": "Logotypen visas i huvudmenyn, overlay och publik träningsprofil. PDF-export försöker bädda in den via backend.", + "settings.update_title": "Uppdateringar", + "settings.update_current_version": "Installerad version", + "settings.update_checking": "Kontrollerar om det finns en ny version...", + "settings.update_up_to_date": "Du har den senaste versionen.", + "settings.update_available": "Version {version} finns tillgänglig.", + "settings.update_error": "Kunde inte kontrollera version: {msg}", + "settings.update_check_now": "Kontrollera nu", + "settings.update_run_now": "Uppdatera nu", + "settings.update_confirm": "Uppdatera RC RaceCore till version {version}?\n\nServern stoppas, ny kod hämtas och startas om automatiskt. Det tar vanligtvis under en minut och sidan laddas om av sig själv när servern är tillbaka.", + "settings.update_confirm_active_session": "OBS: Ett aktivt race/session pågår just nu. En uppdatering stoppar servern under uppdateringen och avbryter tidtagningen. Uppdatera bara mellan heat.", + "settings.update_started": "Uppdatering startad. Servern startar om - sidan laddas om automatiskt när den är tillbaka.", + "settings.update_failed": "Uppdatering misslyckades: {msg}", "settings.storage": "Lagring", "settings.server_backups": "SQLite-backuper på servern", "settings.server_backups_hint": "En backup skapas automatiskt före varje sessionstart. De 30 senaste sparas i data/backups.", @@ -2954,6 +2966,18 @@ const TRANSLATIONS = { "settings.logo_upload": "Upload logo", "settings.logo_clear": "Clear logo", "settings.logo_note": "The logo is shown in the main menu, overlays, and public training profiles. PDF export attempts to embed it via the backend.", + "settings.update_title": "Updates", + "settings.update_current_version": "Installed version", + "settings.update_checking": "Checking for a new version...", + "settings.update_up_to_date": "You have the latest version.", + "settings.update_available": "Version {version} is available.", + "settings.update_error": "Could not check for updates: {msg}", + "settings.update_check_now": "Check now", + "settings.update_run_now": "Update now", + "settings.update_confirm": "Update RC RaceCore to version {version}?\n\nThe server will stop, pull the new code and restart automatically. This usually takes under a minute, and the page reloads on its own once the server is back.", + "settings.update_confirm_active_session": "Note: a race/session is currently active. Updating stops the server during the update and interrupts timing. Only update between heats.", + "settings.update_started": "Update started. The server is restarting - the page will reload automatically once it's back.", + "settings.update_failed": "Update failed: {msg}", "settings.storage": "Storage", "settings.server_backups": "Server SQLite backups", "settings.server_backups_hint": "A backup is created automatically before every session start. The latest 30 are retained in data/backups.", @@ -3566,6 +3590,11 @@ let settingsStorageNoticeIsError = false; let settingsDraftActive = false; let serverBackups = []; let serverBackupsLoaded = false; +let updateStatus = null; +let updateStatusLoaded = false; +let updateCheckPending = false; +let updateNotice = ""; +let updateNoticeIsError = false; const backend = { available: false, lastSyncAt: null, @@ -3639,8 +3668,9 @@ async function init() { updateSyncStatus(); }); startAppVersionPolling(); + setInterval(() => refreshUpdateStatus({ render: currentView === "settings" }), 20 * 60 * 1000); - Promise.allSettled([hydrateFromBackend(), loadAmmcConfigFromBackend(), refreshServerBackups({ render: false })]).finally(() => { + Promise.allSettled([hydrateFromBackend(), loadAmmcConfigFromBackend(), refreshServerBackups({ render: false }), refreshUpdateStatus({ render: false })]).finally(() => { applyTheme(); renderNav(); renderViewFromBackground(); @@ -4507,6 +4537,28 @@ async function refreshServerBackups({ render = true } = {}) { return serverBackups; } +async function refreshUpdateStatus({ render = true, force = false } = {}) { + updateCheckPending = true; + try { + const response = await fetch(`${getBackendUrl()}/api/update-status${force ? "?force=1" : ""}`, { cache: "no-store" }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + updateStatus = await response.json(); + } catch (error) { + updateStatus = { checked: false, available: false, error: error instanceof Error ? error.message : String(error) }; + } + updateStatusLoaded = true; + updateCheckPending = false; + if (render && currentView === "settings") renderView(); + return updateStatus; +} + +async function runUpdateNow() { + const response = await fetch(`${getBackendUrl()}/api/update/run`, { method: "POST" }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`); + return payload; +} + async function createServerBackup(reason = "manual", session = null) { await syncStateToBackend(); if (!backend.available) throw new Error(backend.lastError || "Backend offline"); @@ -4708,6 +4760,14 @@ function renderView() { createManualServerBackup: () => createServerBackup("manual"), createSafetyServerBackup: () => createServerBackup("pre_change"), refreshServerBackups, + updateStatus, + updateStatusLoaded, + updateCheckPending, + updateNotice, + updateNoticeIsError, + refreshUpdateStatus, + runUpdateNow, + setUpdateNotice: (message, isError = false) => { updateNotice = message; updateNoticeIsError = isError; }, }); break; case "guide": diff --git a/src/settings.js b/src/settings.js index f601995..c8a47e2 100644 --- a/src/settings.js +++ b/src/settings.js @@ -59,6 +59,14 @@ export function renderSettings(deps) { createManualServerBackup, createSafetyServerBackup, refreshServerBackups, + updateStatus, + updateStatusLoaded, + updateCheckPending, + updateNotice, + updateNoticeIsError, + refreshUpdateStatus, + runUpdateNow, + setUpdateNotice, } = deps; const runWithSafetyBackup = async (action) => { try { @@ -438,6 +446,25 @@ export function renderSettings(deps) { +
+

${t("settings.update_title")}

+
+

${t("settings.update_current_version")}: v${escapeHtml(String(updateStatus?.currentVersion || ""))}

+ ${!updateStatusLoaded + ? `

${t("settings.update_checking")}

` + : updateStatus?.error + ? `

${escapeHtml(t("settings.update_error", { msg: updateStatus.error }))}

` + : updateStatus?.available + ? `

${escapeHtml(t("settings.update_available", { version: updateStatus.remoteVersion || "?" }))}

` + : `

${t("settings.update_up_to_date")}

`} +
+ + ${updateStatus?.available ? `` : ""} +
+ ${updateNotice ? `

${escapeHtml(updateNotice)}

` : ""} +
+
+

${t("settings.storage")}

@@ -752,6 +779,27 @@ Erik Eriksson;654321;4WD;Associated;; }); document.getElementById("refreshServerBackups")?.addEventListener("click", () => refreshServerBackups()); + document.getElementById("settingsCheckUpdate")?.addEventListener("click", async () => { + setUpdateNotice(""); + await refreshUpdateStatus({ render: false, force: true }); + renderView(); + }); + document.getElementById("settingsRunUpdate")?.addEventListener("click", async (event) => { + const version = updateStatus?.remoteVersion ? `v${updateStatus.remoteVersion}` : ""; + let message = t("settings.update_confirm", { version }); + if (state.activeSessionId) message += "\n\n" + t("settings.update_confirm_active_session"); + if (!window.confirm(message)) return; + const button = event.currentTarget; + if (button instanceof HTMLButtonElement) button.disabled = true; + try { + await runUpdateNow(); + setUpdateNotice(t("settings.update_started"), false); + } catch (error) { + setUpdateNotice(t("settings.update_failed", { msg: error instanceof Error ? error.message : String(error) }), true); + } + renderView(); + }); + document.getElementById("logoUpload")?.addEventListener("change", (event) => { const input = event.currentTarget; const file = input instanceof HTMLInputElement ? input.files?.[0] : null; diff --git a/tests/server_persistence.integration.test.js b/tests/server_persistence.integration.test.js index 1f4c965..e73f578 100644 --- a/tests/server_persistence.integration.test.js +++ b/tests/server_persistence.integration.test.js @@ -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();