fix status and roadmap
This commit is contained in:
130
tests/browser_smoke.js
Normal file
130
tests/browser_smoke.js
Normal file
@@ -0,0 +1,130 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { createServer } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const project = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const chrome = [process.env.CHROME_PATH, "/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", ...[process.env.PROGRAMFILES, process.env["PROGRAMFILES(X86)"], process.env.LOCALAPPDATA].filter(Boolean).flatMap(base => [join(base, "Google", "Chrome", "Application", "chrome.exe"), join(base, "Microsoft", "Edge", "Application", "msedge.exe")])].find(path => path && existsSync(path));
|
||||
if (!chrome) throw new Error("Chrome/Chromium/Edge not found. Set CHROME_PATH to the browser executable.");
|
||||
const temporary = mkdtempSync(join(tmpdir(), "racecore-browser-"));
|
||||
const children = [];
|
||||
let socket;
|
||||
const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
async function waitFor(check, label, timeout = 15000) {
|
||||
const until = Date.now() + timeout;
|
||||
while (Date.now() < until) {
|
||||
try { if (await check()) return; } catch {}
|
||||
await pause(100);
|
||||
}
|
||||
throw new Error("Timed out: " + label);
|
||||
}
|
||||
async function freePort() {
|
||||
const server = createServer();
|
||||
await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); });
|
||||
const port = server.address().port;
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
return port;
|
||||
}
|
||||
function launch(command, args, options = {}) {
|
||||
const child = spawn(command, args, { cwd: project, stdio: "ignore", ...options });
|
||||
children.push(child);
|
||||
child.on("error", error => { console.error(error.message); });
|
||||
return child;
|
||||
}
|
||||
try {
|
||||
const port = await freePort();
|
||||
const debugPort = await freePort();
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
launch(process.execPath, ["server.js"], { env: { ...process.env, HOST: "127.0.0.1", PORT: String(port), RC_TIMING_DATA_DIR: join(temporary, "data"), RC_TIMING_PID_FILE: "" } });
|
||||
await waitFor(async () => (await fetch(base + "/api/health")).ok, "isolated server");
|
||||
const initial = { classes: [{ id: "test_class", name: "Test Class" }], drivers: [], cars: [], events: [], sessions: [], resultsBySession: {}, settings: { language: "en", autoReconnect: false, backendUrl: base }, training: {} };
|
||||
const seedResponse = await fetch(base + "/api/state", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(initial) });
|
||||
assert.equal(seedResponse.ok, true);
|
||||
launch(chrome, ["--headless", "--disable-gpu", "--no-first-run", "--no-default-browser-check", "--disable-background-networking", ...(process.env.RC_BROWSER_NO_SANDBOX === "1" ? ["--no-sandbox"] : []), `--user-data-dir=${join(temporary, "browser")}`, `--remote-debugging-port=${debugPort}`, "about:blank"]);
|
||||
let target;
|
||||
await waitFor(async () => { target = (await (await fetch(`http://127.0.0.1:${debugPort}/json/list`)).json()).find(page => page.type === "page"); return target; }, "headless browser");
|
||||
socket = new WebSocket(target.webSocketDebuggerUrl);
|
||||
await new Promise((resolve, reject) => { socket.addEventListener("open", resolve, { once: true }); socket.addEventListener("error", reject, { once: true }); });
|
||||
let nextId = 0;
|
||||
const requests = new Map();
|
||||
const errors = [];
|
||||
socket.addEventListener("message", event => {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.id) {
|
||||
const request = requests.get(message.id);
|
||||
requests.delete(message.id);
|
||||
if (message.error) request?.reject(new Error(message.error.message)); else request?.resolve(message.result);
|
||||
} else if (message.method === "Runtime.exceptionThrown") errors.push(message.params.exceptionDetails.text + ": " + (message.params.exceptionDetails.exception?.description || ""));
|
||||
});
|
||||
function send(method, params = {}) {
|
||||
return new Promise((resolve, reject) => { const id = ++nextId; requests.set(id, { resolve, reject }); socket.send(JSON.stringify({ id, method, params })); });
|
||||
}
|
||||
async function evaluate(expression) {
|
||||
const result = await send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true });
|
||||
if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text);
|
||||
return result.result.value;
|
||||
}
|
||||
await send("Runtime.enable");
|
||||
await send("Page.enable");
|
||||
await send("Network.enable");
|
||||
await send("Network.setBlockedURLs", { urls: ["*fonts.googleapis.com*", "*fonts.gstatic.com*"] });
|
||||
const status = () => evaluate('document.getElementById("syncStatus")?.dataset.state');
|
||||
async function navigate(view, form) {
|
||||
await send("Page.navigate", { url: base + "/?view=" + view });
|
||||
try {
|
||||
await waitFor(async () => await evaluate(`Boolean(document.getElementById(${JSON.stringify(form)})) && document.getElementById("syncStatusDetail")?.textContent.includes("Data loaded")`), view + " hydrated");
|
||||
} catch (error) {
|
||||
throw new Error(error.message + "\n" + JSON.stringify({ errors, page: await evaluate("document.body.innerText.slice(0, 1800)") }));
|
||||
}
|
||||
}
|
||||
async function fill(form, fields) {
|
||||
await evaluate(`(() => { const form = document.getElementById(${JSON.stringify(form)}); for (const [name,value] of Object.entries(${JSON.stringify(fields)})) { const field=form.elements.namedItem(name); if (!field) throw new Error("Missing field "+name); field.value=value; field.dispatchEvent(new Event("input", {bubbles:true})); } })()`);
|
||||
}
|
||||
const savedState = async () => (await (await fetch(base + "/api/state")).json()).state;
|
||||
for (const [view, prefix, collection, fields] of [
|
||||
["classes", "class", "classes", { name: "Browser Class" }],
|
||||
["drivers", "driver", "drivers", { name: "Browser Driver", classId: "test_class", transponder: "123456", club: "Test Club" }],
|
||||
["cars", "car", "cars", { name: "Browser Car", transponder: "654321", brand: "Test Brand" }],
|
||||
]) {
|
||||
await navigate(view, prefix + "Form");
|
||||
await fill(prefix + "Form", fields);
|
||||
await evaluate(`document.getElementById("${prefix}Form").requestSubmit()`);
|
||||
await waitFor(async () => (await savedState())[collection]?.some(item => item.name === fields.name), "create " + prefix);
|
||||
const item = (await savedState())[collection].find(item => item.name === fields.name);
|
||||
await evaluate(`document.getElementById(${JSON.stringify(prefix + "-edit-" + item.id)}).click()`);
|
||||
await fill(prefix + "EditForm", { name: fields.name + " Updated" });
|
||||
// Two version polls must not replace the active editor or its draft.
|
||||
await pause(10500);
|
||||
assert.equal(await evaluate(`document.getElementById("${prefix}EditForm").elements.namedItem("name").value`), fields.name + " Updated");
|
||||
await evaluate(`document.getElementById("${prefix}EditSave").click()`);
|
||||
await waitFor(async () => (await savedState())[collection]?.some(row => row.id === item.id && row.name === fields.name + " Updated"), "edit " + prefix);
|
||||
await navigate(view, prefix + "Form");
|
||||
assert.ok(await evaluate(`document.getElementById("view").textContent.includes(${JSON.stringify(fields.name + " Updated")})`));
|
||||
console.log("PASS create/edit/reload and draft retention: " + collection);
|
||||
}
|
||||
await navigate("classes", "classForm");
|
||||
await send("Network.emulateNetworkConditions", { offline: true, latency: 0, downloadThroughput: -1, uploadThroughput: -1 });
|
||||
await fill("classForm", { name: "Offline Draft" });
|
||||
await evaluate('document.getElementById("classForm").requestSubmit()');
|
||||
await waitFor(async () => (await status()) === "error", "visible save failure");
|
||||
assert.ok(!(await savedState()).classes.some(item => item.name === "Offline Draft"));
|
||||
await send("Network.emulateNetworkConditions", { offline: false, latency: 0, downloadThroughput: -1, uploadThroughput: -1 });
|
||||
await evaluate('document.getElementById("syncStatusRetry").click()');
|
||||
await waitFor(async () => (await savedState()).classes.some(item => item.name === "Offline Draft"), "retry persisted draft");
|
||||
await waitFor(async () => (await status()) === "saved", "confirmed saved status");
|
||||
assert.deepEqual(errors, []);
|
||||
console.log("PASS offline save failure, local draft retention, retry and confirmed saved status");
|
||||
} finally {
|
||||
socket?.close();
|
||||
for (const child of children.reverse()) {
|
||||
if (child.exitCode !== null || child.signalCode !== null) continue;
|
||||
const exited = new Promise(resolve => child.once("exit", resolve));
|
||||
child.kill();
|
||||
await Promise.race([exited, pause(2000)]);
|
||||
if (child.exitCode === null && child.signalCode === null) { child.kill("SIGKILL"); await Promise.race([exited, pause(1000)]); }
|
||||
}
|
||||
rmSync(temporary, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
|
||||
}
|
||||
@@ -16,3 +16,4 @@ import "./training_views.test.js";
|
||||
import "./session_replay.test.js";
|
||||
import "./public_training_refresh.test.js";
|
||||
import "./list_sorting.test.js";
|
||||
import "./sync_status.test.js";
|
||||
|
||||
39
tests/sync_status.test.js
Normal file
39
tests/sync_status.test.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { getSyncStatus } from "../src/sync_status.js";
|
||||
import { syncStateToBackendHelper } from "../src/runtime_services.js";
|
||||
|
||||
test("save status distinguishes dirty, saving, failed, connected and saved", () => {
|
||||
assert.equal(getSyncStatus({}, false).kind, "connecting");
|
||||
assert.equal(getSyncStatus({ available: true }, false).kind, "connected");
|
||||
assert.equal(getSyncStatus({ available: true, lastSavedAt: "now" }, true).kind, "pending");
|
||||
assert.equal(getSyncStatus({ pendingWrites: 1 }, true).kind, "saving");
|
||||
assert.equal(getSyncStatus({ available: true, writeError: "failed" }, true).kind, "error");
|
||||
assert.equal(getSyncStatus({ available: true, lastSavedAt: "now" }, false).kind, "saved");
|
||||
});
|
||||
|
||||
test("offline and stale contact are not displayed as a successful live connection", () => {
|
||||
assert.equal(getSyncStatus({ connectionChecked: true }, false).kind, "offline");
|
||||
assert.equal(getSyncStatus({ available: true, lastContactAt: 1000 }, false, 17000).kind, "stale");
|
||||
assert.equal(getSyncStatus({ available: true, lastContactAt: 1000 }, false, 2000).kind, "connected");
|
||||
});
|
||||
|
||||
test("failed writes retain dirty state and a successful retry clears the error", async () => {
|
||||
const previousFetch = globalThis.fetch;
|
||||
const backend = {};
|
||||
let dirty = true;
|
||||
const deps = { backend, t: key => key, getBackendUrl: () => "http://test", buildPersistableState: () => ({}), getLocalStateVersion: () => 1, getSyncedStateVersion: () => 0, setSyncedStateVersion: () => {}, setLocalStateDirty: value => { dirty = value; } };
|
||||
try {
|
||||
globalThis.fetch = async () => { throw new Error("offline"); };
|
||||
await syncStateToBackendHelper(deps);
|
||||
assert.equal(dirty, true);
|
||||
assert.equal(backend.pendingWrites, 0);
|
||||
assert.ok(backend.writeError);
|
||||
globalThis.fetch = async () => ({ ok: true });
|
||||
await syncStateToBackendHelper(deps);
|
||||
assert.equal(dirty, false);
|
||||
assert.equal(backend.writeError, "");
|
||||
assert.ok(backend.lastSavedAt);
|
||||
assert.equal(backend.pendingWrites, 0);
|
||||
} finally { globalThis.fetch = previousFetch; }
|
||||
});
|
||||
Reference in New Issue
Block a user