The installer now fetches one male and one female Piper voice per language (Swedish NST/Lisa, English Ryan/Lessac), with genders confirmed by measuring each voice's pitch rather than guessed from the name. Settings -> Audio gets a voice selector per language with its own Test button that always plays the Piper voice. Male is preselected for both. The integration test now runs its server with an empty home directory, so voices installed on the developer's own machine can't leak into it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
636 lines
30 KiB
JavaScript
636 lines
30 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 });
|
|
}
|
|
});
|
|
|
|
test("an admin PIN blocks unauthenticated writes, and the login cookie lets them through", { timeout: 20000 }, async () => {
|
|
const dataDir = mkdtempSync(join(tmpdir(), "live-rc-test-auth-"));
|
|
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"],
|
|
});
|
|
const post = (path, body, cookie = "") => fetch(`${baseUrl}${path}`, { method: "POST", headers: { "Content-Type": "application/json", ...(cookie ? { Cookie: cookie } : {}) }, body: JSON.stringify(body) });
|
|
|
|
try {
|
|
await waitForServer(child);
|
|
assert.equal((await post("/api/state", { classes: [] })).status, 200, "auth is off by default, so writes work as before");
|
|
|
|
const setPin = await post("/api/auth/pin", { newPin: "4711" });
|
|
assert.equal(setPin.status, 200);
|
|
const cookie = setPin.headers.get("set-cookie").split(";")[0];
|
|
|
|
assert.equal((await post("/api/state", { classes: [] })).status, 401, "without the cookie a write is rejected");
|
|
assert.equal((await post("/api/update/run", {})).status, 401, "the update/restart trigger is protected too");
|
|
assert.equal((await fetch(`${baseUrl}/api/state`)).status, 200, "reads stay open for the public overlay");
|
|
assert.equal((await post("/api/auth/pin", { newPin: "9999" })).status, 401, "the PIN cannot be changed without logging in");
|
|
assert.equal((await post("/api/state", { classes: [] }, cookie)).status, 200, "the cookie from setting the PIN authorizes writes");
|
|
|
|
assert.equal((await post("/api/auth/login", { pin: "0000" })).status, 401);
|
|
const login = await post("/api/auth/login", { pin: "4711" });
|
|
assert.equal(login.status, 200);
|
|
assert.ok(login.headers.get("set-cookie").includes("HttpOnly"));
|
|
|
|
const status = await (await fetch(`${baseUrl}/api/auth/status`, { headers: { Cookie: cookie } })).json();
|
|
assert.deepEqual(status, { enabled: true, authenticated: true, ipAllowed: true });
|
|
const stateBody = await (await fetch(`${baseUrl}/api/state`)).text();
|
|
assert.ok(!stateBody.includes("pinHash"), "the PIN hash never appears in the state payload");
|
|
|
|
assert.equal((await post("/api/auth/disable", {}, cookie)).status, 200);
|
|
assert.equal((await post("/api/state", { classes: [] })).status, 200, "disabling restores open writes");
|
|
} finally {
|
|
await stopServer(child);
|
|
rmSync(dataDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("local Piper speech is served as WAV, cached, and falls under the admin PIN", { timeout: 20000, skip: process.platform === "win32" }, async () => {
|
|
const dataDir = mkdtempSync(join(tmpdir(), "live-rc-test-tts-"));
|
|
const voicesDir = join(dataDir, "voices");
|
|
fs.mkdirSync(voicesDir);
|
|
for (const name of ["sv_SE-test-medium", "sv_SE-other-medium", "en_US-test-medium"]) {
|
|
fs.writeFileSync(join(voicesDir, `${name}.onnx`), "");
|
|
fs.writeFileSync(join(voicesDir, `${name}.onnx.json`), "{}");
|
|
}
|
|
// HOME points at the temp dir so voices installed on the developer's own machine
|
|
// (~/.local/share/piper-voices) can't leak into the test.
|
|
// Fake Piper: counts invocations and writes the text it was given into the output file.
|
|
const fakePiper = join(dataDir, "fake-piper");
|
|
const counter = join(dataDir, "piper-calls");
|
|
fs.writeFileSync(fakePiper, `#!/usr/bin/env node
|
|
const fs = require("fs");
|
|
const out = process.argv[process.argv.indexOf("-f") + 1];
|
|
const model = require("path").basename(process.argv[process.argv.indexOf("-m") + 1], ".onnx");
|
|
let text = "";
|
|
process.stdin.on("data", (chunk) => { text += chunk; });
|
|
process.stdin.on("end", () => { fs.appendFileSync(${JSON.stringify(counter)}, "x"); fs.writeFileSync(out, "RIFF:" + model + ":" + text); });
|
|
`);
|
|
fs.chmodSync(fakePiper, 0o755);
|
|
|
|
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_PIPER_BIN: fakePiper, RC_PIPER_VOICES_DIR: voicesDir, HOME: dataDir, USERPROFILE: dataDir },
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
const speech = (body, cookie = "") => fetch(`${baseUrl}/api/tts/speech`, { method: "POST", headers: { "Content-Type": "application/json", ...(cookie ? { Cookie: cookie } : {}) }, body: JSON.stringify(body) });
|
|
|
|
try {
|
|
await waitForServer(child);
|
|
const status = await (await fetch(`${baseUrl}/api/tts/status`)).json();
|
|
assert.equal(status.available, true);
|
|
assert.equal(status.binary, fakePiper);
|
|
assert.ok(["sv", "en"].every((language) => status.voices.some((voice) => voice.language === language)), "both Swedish and English voices are found");
|
|
|
|
const first = await speech({ text: "Heat ett startar.", language: "sv", rate: 1 });
|
|
assert.equal(first.status, 200);
|
|
assert.match(first.headers.get("content-type"), /audio\/wav/);
|
|
assert.equal(await first.text(), "RIFF:sv_SE-other-medium:Heat ett startar.\n", "with no choice and no catalog default installed, the first medium Swedish voice is used");
|
|
assert.equal((await speech({ text: "Heat ett startar.", language: "sv", rate: 1 })).status, 200);
|
|
assert.equal(fs.readFileSync(counter, "utf8").length, 1, "the second request is served from the cache");
|
|
const chosen = await speech({ text: "Heat ett startar.", language: "sv", rate: 1, voice: "sv_SE-test-medium" });
|
|
assert.equal(await chosen.text(), "RIFF:sv_SE-test-medium:Heat ett startar.\n", "the voice chosen in Settings reaches Piper");
|
|
|
|
assert.equal((await speech({ text: "", language: "sv" })).status, 400);
|
|
assert.equal((await speech({ text: "Moi", language: "fi" })).status, 503, "no voice for the language -> the browser falls back");
|
|
|
|
const setPin = await fetch(`${baseUrl}/api/auth/pin`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ newPin: "4711" }) });
|
|
const cookie = setPin.headers.get("set-cookie").split(";")[0];
|
|
assert.equal((await speech({ text: "Hej", language: "sv" })).status, 401, "generating audio needs the PIN once protection is on");
|
|
assert.equal((await speech({ text: "Hej", language: "sv" }, cookie)).status, 200);
|
|
} finally {
|
|
await stopServer(child);
|
|
rmSync(dataDir, { recursive: true, force: true });
|
|
}
|
|
});
|