From 4c463485e0a3fac6f1a6e3269d39474101b02b92 Mon Sep 17 00:00:00 2001 From: larssand Date: Wed, 9 Sep 2026 22:05:13 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 6 ++ ROADMAP.md | 10 +++ package-lock.json | 4 +- package.json | 3 +- scripts/serverctl.js | 47 ++++++++++- server.js | 83 +++++++++++++++++++- src/app.js | 62 ++++++++++++++- src/settings.js | 48 +++++++++++ tests/server_persistence.integration.test.js | 80 ++++++++++++++++++- 9 files changed, 335 insertions(+), 8 deletions(-) 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();