buildSimplePdf's font declared no /Encoding at all, so PDF viewers fell back to a standard font's built-in encoding, which has no å/ä/ö glyphs - and separately, the content stream was written as UTF-8 instead of a single-byte encoding, so even a correctly-encoded font would receive the wrong bytes (a multi-byte UTF-8 sequence where PDF expects exactly one byte per character): the classic "åäö" mojibake pattern. Fixed by declaring /Encoding /WinAnsiEncoding and writing the content stream through a proper WinAnsi (~CP1252) byte mapper instead of UTF-8. This is the single shared renderer behind every PDF export (race results, start lists, heat sheets, team reports, the results archive), so the fix applies everywhere at once, matching what was reported. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
456 lines
19 KiB
JavaScript
456 lines
19 KiB
JavaScript
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 { 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 });
|
|
}
|
|
});
|
|
|
|
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 });
|
|
}
|
|
});
|