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>
532 lines
24 KiB
JavaScript
532 lines
24 KiB
JavaScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
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";
|
|
import { fileURLToPath } from "node:url";
|
|
import QRCode from "qrcode";
|
|
|
|
const projectRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
|
|
function reservePort() {
|
|
return new Promise((resolve, reject) => {
|
|
const server = createServer();
|
|
server.once("error", reject);
|
|
server.listen(0, "127.0.0.1", () => {
|
|
const address = server.address();
|
|
const port = typeof address === "object" && address ? address.port : 0;
|
|
server.close((error) => error ? reject(error) : resolve(port));
|
|
});
|
|
});
|
|
}
|
|
|
|
function waitForServer(child, timeoutMs = 10000) {
|
|
return new Promise((resolve, reject) => {
|
|
let output = "";
|
|
const timeout = setTimeout(() => {
|
|
reject(new Error(`Server startup timed out. Output:
|
|
${output}`));
|
|
}, timeoutMs);
|
|
const onData = (chunk) => {
|
|
output += chunk.toString();
|
|
if (output.includes("RC Timing server listening")) {
|
|
clearTimeout(timeout);
|
|
child.stdout.off("data", onData);
|
|
resolve();
|
|
}
|
|
};
|
|
child.stdout.on("data", onData);
|
|
child.stderr.on("data", (chunk) => {
|
|
output += chunk.toString();
|
|
});
|
|
child.once("exit", (code, signal) => {
|
|
clearTimeout(timeout);
|
|
reject(new Error(`Server exited before startup (code=${code}, signal=${signal}). Output:
|
|
${output}`));
|
|
});
|
|
});
|
|
}
|
|
|
|
function stopServer(child) {
|
|
if (child.exitCode !== null || child.signalCode !== null) {
|
|
return Promise.resolve();
|
|
}
|
|
return new Promise((resolve) => {
|
|
const timeout = setTimeout(() => {
|
|
child.kill("SIGKILL");
|
|
}, 3000);
|
|
child.once("exit", () => {
|
|
clearTimeout(timeout);
|
|
resolve();
|
|
});
|
|
child.kill("SIGTERM");
|
|
});
|
|
}
|
|
|
|
test("SQLite API persists state, passings, and overlay session results", { timeout: 20000 }, async () => {
|
|
const dataDir = mkdtempSync(join(tmpdir(), "live-rc-test-"));
|
|
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_ALLOWED_ORIGINS: "https://trusted.example",
|
|
},
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
|
|
try {
|
|
await waitForServer(child);
|
|
|
|
const healthResponse = await fetch(`${baseUrl}/api/health`);
|
|
const health = await healthResponse.json();
|
|
assert.equal(healthResponse.status, 200);
|
|
assert.equal(health.ok, true);
|
|
assert.equal(health.dbPath, join(dataDir, "rc_timing.sqlite"));
|
|
|
|
const versionResponse = await fetch(`${baseUrl}/api/app-version`);
|
|
assert.equal(versionResponse.status, 200);
|
|
const appVersion = await versionResponse.json();
|
|
assert.equal(appVersion.version, JSON.parse(readFileSync(join(projectRoot, "package.json"), "utf8")).version);
|
|
assert.equal(typeof appVersion.revision, "number");
|
|
assert.ok(appVersion.updatedAt);
|
|
|
|
const sameOriginResponse = await fetch(`${baseUrl}/api/health`, { headers: { Origin: baseUrl } });
|
|
assert.equal(sameOriginResponse.status, 200);
|
|
assert.equal(sameOriginResponse.headers.get("access-control-allow-origin"), baseUrl);
|
|
|
|
const allowedOriginResponse = await fetch(`${baseUrl}/api/health`, { headers: { Origin: "https://trusted.example" } });
|
|
assert.equal(allowedOriginResponse.status, 200);
|
|
assert.equal(allowedOriginResponse.headers.get("access-control-allow-origin"), "https://trusted.example");
|
|
|
|
const rejectedOriginResponse = await fetch(`${baseUrl}/api/ammc/start`, {
|
|
method: "POST",
|
|
headers: { Origin: "https://untrusted.example" },
|
|
});
|
|
assert.equal(rejectedOriginResponse.status, 403);
|
|
|
|
const soundManifestResponse = await fetch(`${baseUrl}/race-sounds/sound_manifest.json`);
|
|
const soundManifest = await soundManifestResponse.json();
|
|
assert.equal(soundManifestResponse.status, 200);
|
|
assert.equal(soundManifest.events.start_tone.effect, "effects/start_beep.wav");
|
|
assert.equal(soundManifest.voice_events.sv.track_call, "Stopp på banan. Stopp på banan.");
|
|
|
|
const ammcConfigResponse = await fetch(`${baseUrl}/api/ammc/config`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
managedEnabled: true,
|
|
decoderHost: "192.168.1.10",
|
|
wsPort: 9000,
|
|
splitEnabled: true,
|
|
splitDecoderHost: "192.168.1.11",
|
|
splitWsPort: 9001,
|
|
pitEnabled: true,
|
|
pitEntryDecoderHost: "192.168.1.12",
|
|
pitEntryWsPort: 9002,
|
|
pitExitDecoderHost: "192.168.1.13",
|
|
pitExitWsPort: 9003,
|
|
}),
|
|
});
|
|
const ammcConfigPayload = await ammcConfigResponse.json();
|
|
assert.equal(ammcConfigResponse.status, 200);
|
|
assert.equal(ammcConfigPayload.config.splitWsPort, 9001);
|
|
assert.equal(ammcConfigPayload.config.pitExitDecoderHost, "192.168.1.13");
|
|
assert.equal(ammcConfigPayload.status.role, "main");
|
|
assert.equal(ammcConfigPayload.status.split.role, "split");
|
|
assert.equal(ammcConfigPayload.status.pitEntry.wsPort, 9002);
|
|
|
|
const duplicatePortResponse = await fetch(`${baseUrl}/api/ammc/config`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ splitEnabled: true, splitWsPort: 9000 }),
|
|
});
|
|
const duplicatePortPayload = await duplicatePortResponse.json();
|
|
assert.equal(duplicatePortResponse.status, 400);
|
|
assert.match(duplicatePortPayload.error, /unique WebSocket port/);
|
|
|
|
const emptyResponse = await fetch(`${baseUrl}/api/state`);
|
|
assert.deepEqual(await emptyResponse.json(), { state: null, updatedAt: null });
|
|
|
|
const initialState = {
|
|
events: [{ id: "event_1", name: "Club race" }],
|
|
classes: [{ id: "class_1", name: "Buggy" }],
|
|
drivers: [{ id: "driver_1", name: "Driver One", classId: "class_1" }],
|
|
sessions: [{ id: "session_1", eventId: "event_1", name: "A final" }],
|
|
resultsBySession: {},
|
|
settings: { publicBaseUrl: "https://racecore.example.org" },
|
|
training: {
|
|
shares: [{ driverId: "driver_1", token: "test-share-token-1234567890", enabled: true }],
|
|
},
|
|
};
|
|
const saveResponse = await fetch(`${baseUrl}/api/state`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(initialState),
|
|
});
|
|
const savePayload = await saveResponse.json();
|
|
assert.equal(saveResponse.status, 200);
|
|
assert.equal(savePayload.ok, true);
|
|
assert.equal(typeof savePayload.updatedAt, "string");
|
|
|
|
const shareToken = initialState.training.shares[0].token;
|
|
const publicQrResponse = await fetch(
|
|
`${baseUrl}/public-training/qr/${shareToken}?lang=en&baseUrl=${encodeURIComponent("https://public.example.test")}`,
|
|
);
|
|
const publicQrSvg = await publicQrResponse.text();
|
|
const expectedPublicQrSvg = await QRCode.toString(
|
|
`https://public.example.test/public-training/${shareToken}?lang=en`,
|
|
{ type: "svg", errorCorrectionLevel: "M", margin: 2, width: 320 },
|
|
);
|
|
assert.equal(publicQrResponse.status, 200);
|
|
assert.match(publicQrResponse.headers.get("content-type"), /^image\/svg\+xml/);
|
|
assert.equal(publicQrSvg, expectedPublicQrSvg);
|
|
|
|
const localQrResponse = await fetch(`${baseUrl}/public-training/qr/${shareToken}?lang=sv&baseUrl=local`);
|
|
const localQrSvg = await localQrResponse.text();
|
|
assert.equal(localQrResponse.status, 200);
|
|
assert.match(localQrResponse.headers.get("content-type"), /^image\/svg\+xml/);
|
|
assert.notEqual(localQrSvg, publicQrSvg);
|
|
|
|
const missingQrResponse = await fetch(`${baseUrl}/public-training/qr/unknown-share-token-123456?baseUrl=local`);
|
|
assert.equal(missingQrResponse.status, 404);
|
|
|
|
const backupResponse = await fetch(`${baseUrl}/api/backups`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ reason: "pre_start", sessionId: "session_1", eventId: "event_1" }),
|
|
});
|
|
const backupPayload = await backupResponse.json();
|
|
assert.equal(backupResponse.status, 201);
|
|
assert.equal(backupPayload.ok, true);
|
|
assert.equal(backupPayload.backup.reason, "pre_start");
|
|
assert.equal(backupPayload.backup.sessionId, "session_1");
|
|
assert.ok(backupPayload.backup.sizeBytes > 0);
|
|
|
|
const backupsResponse = await fetch(`${baseUrl}/api/backups`);
|
|
const backupsPayload = await backupsResponse.json();
|
|
assert.equal(backupsResponse.status, 200);
|
|
assert.equal(backupsPayload.backups.length, 1);
|
|
assert.equal(backupsPayload.backups[0].fileName, backupPayload.backup.fileName);
|
|
|
|
const storedResponse = await fetch(`${baseUrl}/api/state`);
|
|
const storedPayload = await storedResponse.json();
|
|
assert.deepEqual(storedPayload.state, initialState);
|
|
|
|
const markerResponse = await fetch(baseUrl + "/public-training/api/" + shareToken + "/markers", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ classId: "class_1", type: "car", note: "Ride height change" }),
|
|
});
|
|
const markerPayload = await markerResponse.json();
|
|
assert.equal(markerResponse.status, 201);
|
|
assert.equal(markerPayload.marker.type, "car");
|
|
const markerStateResponse = await fetch(baseUrl + "/api/state");
|
|
const markerState = await markerStateResponse.json();
|
|
assert.equal(markerState.state.training.markers.length, 1);
|
|
assert.equal(markerState.state.training.markers[0].driverId, "driver_1");
|
|
assert.equal(markerState.state.training.markers[0].classId, "class_1");
|
|
assert.equal(markerState.state.training.markers[0].note, "Ride height change");
|
|
|
|
const invalidMarkerResponse = await fetch(baseUrl + "/public-training/api/" + shareToken + "/markers", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ classId: "unknown_class", type: "car" }),
|
|
});
|
|
assert.equal(invalidMarkerResponse.status, 404);
|
|
|
|
const sessionResult = {
|
|
competitors: {
|
|
driver_1: { key: "driver_1", driverName: "Driver One", laps: 1, bestLapMs: 16500 },
|
|
},
|
|
passings: [{ competitorKey: "driver_1", timestamp: 17500, lapMs: 16500, validLap: true }],
|
|
adjustments: [],
|
|
};
|
|
const passing = {
|
|
timestamp: 17500,
|
|
transponder: "1234567",
|
|
driverId: "driver_1",
|
|
driverName: "Driver One",
|
|
carId: "car_1",
|
|
carName: "Buggy",
|
|
strength: -52.5,
|
|
loopId: "loop_1",
|
|
resend: false,
|
|
};
|
|
const passingResponse = await fetch(`${baseUrl}/api/passings`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ sessionId: "session_1", passing, sessionResult }),
|
|
});
|
|
assert.deepEqual(await passingResponse.json(), { ok: true });
|
|
|
|
const hotStateResponse = await fetch(`${baseUrl}/api/state`);
|
|
const hotState = await hotStateResponse.json();
|
|
assert.deepEqual(hotState.state.resultsBySession.session_1, sessionResult);
|
|
assert.deepEqual(hotState.state.events, initialState.events);
|
|
|
|
const rowsResponse = await fetch(`${baseUrl}/api/passings?sessionId=session_1&limit=10`);
|
|
const rowsPayload = await rowsResponse.json();
|
|
assert.equal(rowsPayload.rows.length, 1);
|
|
assert.equal(rowsPayload.rows[0].session_id, "session_1");
|
|
assert.equal(rowsPayload.rows[0].timestamp_ms, 17500);
|
|
assert.equal(rowsPayload.rows[0].transponder, "1234567");
|
|
assert.equal(rowsPayload.rows[0].driver_id, "driver_1");
|
|
assert.equal(rowsPayload.rows[0].car_id, "car_1");
|
|
assert.equal(rowsPayload.rows[0].strength, -52.5);
|
|
assert.deepEqual(JSON.parse(rowsPayload.rows[0].raw_json), passing);
|
|
|
|
// A second, unrelated session's passing must survive purging session_1's rows.
|
|
await fetch(`${baseUrl}/api/passings`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ sessionId: "session_other", passing: { ...passing, timestamp: 18000 } }),
|
|
});
|
|
const purgeResponse = await fetch(`${baseUrl}/api/passings/purge`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ sessionIds: ["session_1"] }),
|
|
});
|
|
const purgePayload = await purgeResponse.json();
|
|
assert.equal(purgeResponse.status, 200);
|
|
assert.equal(purgePayload.ok, true);
|
|
assert.equal(purgePayload.deleted, 1);
|
|
|
|
const afterPurgeSession1 = await (await fetch(`${baseUrl}/api/passings?sessionId=session_1&limit=10`)).json();
|
|
assert.equal(afterPurgeSession1.rows.length, 0, "the deleted session's rows are gone");
|
|
const afterPurgeOtherSession = await (await fetch(`${baseUrl}/api/passings?sessionId=session_other&limit=10`)).json();
|
|
assert.equal(afterPurgeOtherSession.rows.length, 1, "an unrelated session's rows must be untouched");
|
|
|
|
const emptyPurgeResponse = await fetch(`${baseUrl}/api/passings/purge`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ sessionIds: [] }),
|
|
});
|
|
assert.deepEqual(await emptyPurgeResponse.json(), { ok: true, deleted: 0 });
|
|
} finally {
|
|
await stopServer(child);
|
|
rmSync(dataDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("a stale full-state save from a second browser cannot revert a session's just-arrived live results", { timeout: 20000 }, async () => {
|
|
const dataDir = mkdtempSync(join(tmpdir(), "live-rc-test-concurrent-"));
|
|
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_RESULT_PROTECTION_WINDOW_MS: "300",
|
|
},
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
|
|
try {
|
|
await waitForServer(child);
|
|
|
|
const staleSessionResult = { competitors: {}, passings: [], adjustments: [] };
|
|
const baseState = {
|
|
events: [{ id: "event_1", name: "Club race" }],
|
|
classes: [{ id: "class_1", name: "Buggy" }],
|
|
drivers: [{ id: "driver_1", name: "Driver One", classId: "class_1" }],
|
|
sessions: [{ id: "session_1", eventId: "event_1", name: "A final" }],
|
|
resultsBySession: { session_1: staleSessionResult },
|
|
settings: {},
|
|
training: {},
|
|
};
|
|
await fetch(`${baseUrl}/api/state`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(baseState),
|
|
});
|
|
|
|
// Browser A is actively timing: a live passing arrives and updates session_1's result.
|
|
const liveSessionResult = {
|
|
competitors: { driver_1: { key: "driver_1", driverName: "Driver One", laps: 1, bestLapMs: 16000 } },
|
|
passings: [{ competitorKey: "driver_1", timestamp: 5000, lapMs: 16000, validLap: true }],
|
|
adjustments: [],
|
|
};
|
|
await fetch(`${baseUrl}/api/passings`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
sessionId: "session_1",
|
|
passing: { timestamp: 5000, transponder: "1234567", driverId: "driver_1" },
|
|
sessionResult: liveSessionResult,
|
|
}),
|
|
});
|
|
|
|
// Browser B has been idle since before the race and still has driver_1's old name and the
|
|
// stale (empty) session_1 result in memory. It now saves an unrelated edit (renaming the
|
|
// driver) and pushes its entire stale snapshot along with it.
|
|
const staleBrowserState = {
|
|
...baseState,
|
|
drivers: [{ id: "driver_1", name: "Driver One (renamed)", classId: "class_1" }],
|
|
};
|
|
const staleSaveResponse = await fetch(`${baseUrl}/api/state`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(staleBrowserState),
|
|
});
|
|
assert.equal(staleSaveResponse.status, 200);
|
|
|
|
const afterStaleSave = await (await fetch(`${baseUrl}/api/state`)).json();
|
|
// The unrelated edit went through...
|
|
assert.equal(afterStaleSave.state.drivers[0].name, "Driver One (renamed)");
|
|
// ...but the live race result survived the stale overwrite instead of reverting to empty.
|
|
assert.deepEqual(afterStaleSave.state.resultsBySession.session_1, liveSessionResult);
|
|
|
|
// The protection is time-bounded, not permanent: once the window passes, a save is trusted
|
|
// again, so a legitimate later change to that same session's results (e.g. an admin
|
|
// deleting the session) still takes effect.
|
|
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
const afterWindowState = { ...staleBrowserState, resultsBySession: {} };
|
|
await fetch(`${baseUrl}/api/state`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(afterWindowState),
|
|
});
|
|
const afterWindow = await (await fetch(`${baseUrl}/api/state`)).json();
|
|
assert.deepEqual(afterWindow.state.resultsBySession, {});
|
|
} finally {
|
|
await stopServer(child);
|
|
rmSync(dataDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
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();
|
|
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: "" },
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
|
|
try {
|
|
await waitForServer(child);
|
|
|
|
const response = await fetch(`${baseUrl}/api/export/pdf`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
title: "Test åäö ÅÄÖ",
|
|
filename: "swedish_test.pdf",
|
|
sections: [{ title: "Förare", headers: ["Namn", "Klass"], rows: [["Örjan Åström", "2WD"]] }],
|
|
}),
|
|
});
|
|
assert.equal(response.status, 200);
|
|
assert.equal(response.headers.get("content-type"), "application/pdf");
|
|
const pdfBuffer = Buffer.from(await response.arrayBuffer());
|
|
|
|
// The font must declare WinAnsiEncoding - without it a PDF viewer falls back to a standard
|
|
// font's built-in encoding, which has no å/ä/ö glyphs at all regardless of the bytes sent.
|
|
assert.ok(pdfBuffer.toString("latin1").includes("/Encoding /WinAnsiEncoding"), "the font must declare WinAnsiEncoding");
|
|
|
|
// "åäö ÅÄÖ" must appear as single WinAnsi/Latin-1 bytes (0xE5 0xE4 0xF6 0x20 0xC5 0xC4 0xD6),
|
|
// not as UTF-8 multi-byte sequences (which a WinAnsi-encoded viewer would render as garbage
|
|
// like "åäö").
|
|
const winAnsiBytes = Buffer.from([0xe5, 0xe4, 0xf6, 0x20, 0xc5, 0xc4, 0xd6]);
|
|
assert.ok(pdfBuffer.includes(winAnsiBytes), "åäö ÅÄÖ must be present as single WinAnsi bytes in the content stream");
|
|
|
|
const utf8Bytes = Buffer.from("åäö ÅÄÖ", "utf8");
|
|
assert.ok(!pdfBuffer.includes(utf8Bytes), "the raw UTF-8 byte sequence must not appear - that is the mojibake bug");
|
|
|
|
// A name with å from a table row must also be encoded correctly, not just the title.
|
|
const orjanWinAnsi = Buffer.from([0xd6, 0x72, 0x6a, 0x61, 0x6e]); // "Örjan" - Ö = 0xD6
|
|
assert.ok(pdfBuffer.includes(orjanWinAnsi), "a Swedish name inside a table row must also use WinAnsi bytes");
|
|
} finally {
|
|
await stopServer(child);
|
|
rmSync(dataDir, { recursive: true, force: true });
|
|
}
|
|
});
|