test/client-connection-blink.test.js
Rama main · réplica de lectura del repositorio original.
var test = require("node:test");
var assert = require("node:assert/strict");
var fs = require("node:fs");
var path = require("node:path");
var root = path.join(__dirname, "..");
var connection = fs.readFileSync(path.join(root, "lib/public/modules/app-connection.js"), "utf8");
var watchdog = fs.readFileSync(path.join(root, "lib/public/modules/websocket-watchdog.js"), "utf8");
var notes = fs.readFileSync(path.join(root, "lib/public/modules/sticky-notes.js"), "utf8");
var notesCss = fs.readFileSync(path.join(root, "lib/public/css/sticky-notes.css"), "utf8");
var sessions = fs.readFileSync(path.join(root, "lib/public/modules/sidebar-sessions.js"), "utf8");
var globalWs = fs.readFileSync(path.join(root, "lib/server-global-ws.js"), "utf8");
function slice(source, from, to) {
var start = source.indexOf(from);
assert.notEqual(start, -1, "anchor not found: " + from);
var end = to ? source.indexOf(to, start) : source.length;
assert.notEqual(end, -1, "end anchor not found: " + to);
return source.slice(start, end);
}
// --- Overlay grace --------------------------------------------------------
test("the startup overlay still appears immediately on the first connection", function () {
var branch = slice(connection, "if (state.connected !== prev.connected)", "// Processing state changed");
assert.match(branch, /if \(!hasConnectedOnce\) \{[\s\S]*showOverlayNow\(\);/,
"before the first successful connection the overlay is revealed without delay");
// The delayed path is only for reconnects.
assert.match(branch, /\} else \{\s*\n\s*overlayGraceTimer = setTimeout\(/);
});
test("a brief reconnect never reveals the opaque overlay", function () {
assert.match(connection, /var OVERLAY_GRACE_MS = (\d+);/);
var graceMs = Number(/var OVERLAY_GRACE_MS = (\d+);/.exec(connection)[1]);
assert.ok(graceMs >= 750 && graceMs <= 1000, "grace delay is in the agreed 750-1000ms range, got " + graceMs);
// The scheduled reveal re-checks live state before showing anything.
var scheduled = slice(connection, "overlayGraceTimer = setTimeout(", "OVERLAY_GRACE_MS);");
assert.match(scheduled, /overlayGraceTimer = null;/);
assert.match(scheduled, /if \(!store\.get\('connected'\)\) showOverlayNow\(\);/,
"the overlay is shown only if still disconnected when the delay elapses");
// Reconnecting cancels the pending reveal and hides immediately.
var connectedBranch = slice(connection, "if (state.connected) {", "} else {");
assert.match(connectedBranch, /clearOverlayGrace\(\);/);
assert.match(connectedBranch, /connectOverlay\.classList\.add\("hidden"\)/);
assert.ok(connectedBranch.indexOf("clearOverlayGrace()") < connectedBranch.indexOf('classList.add("hidden")'),
"the pending reveal is cancelled before hiding");
// The disconnect path clears any earlier timer so timers cannot stack.
var disconnectedBranch = slice(connection, "if (sendBtn) sendBtn.disabled = true;", "// Processing state changed");
assert.match(disconnectedBranch, /clearOverlayGrace\(\);/);
assert.match(connection, /function clearOverlayGrace\(\) \{[\s\S]*clearTimeout\(overlayGraceTimer\);[\s\S]*overlayGraceTimer = null;/);
});
test("the status dot still reports the state during the grace period", function () {
// The dot branch is keyed on connected/processing and is untouched by the
// grace delay, so a reconnect is always visible without covering the screen.
var dotBranch = slice(connection, "// Status dot (depends on both connected and processing)", "// Connected state changed");
assert.match(dotBranch, /state\.connected !== prev\.connected \|\| state\.processing !== prev\.processing/);
assert.equal(dotBranch.indexOf("overlayGraceTimer"), -1, "the dot is not gated behind the overlay delay");
// The composer stays disabled: there is no outbound queue, so enabling it
// during a reconnect would silently drop the message.
var disconnectedBranch = slice(connection, "if (sendBtn) sendBtn.disabled = true;", "clearOverlayGrace();");
assert.match(disconnectedBranch, /sendBtn\.disabled = true/);
});
// --- Heartbeat ------------------------------------------------------------
test("exactly one heartbeat timer exists per live socket", function () {
assert.match(connection, /var HEARTBEAT_MS = (\d+);/);
var beat = Number(/var HEARTBEAT_MS = (\d+);/.exec(connection)[1]);
assert.ok(beat > 0 && beat < 60000, "heartbeat is shorter than a typical 60s proxy idle timeout, got " + beat);
// Starting always clears first, so a restart cannot leave two timers.
var start = slice(watchdog, "function startHeartbeat(socket, epoch)", "function probe(socket, epoch)");
assert.ok(start.indexOf("stopHeartbeat();") < start.indexOf("heartbeatTimer = setInterval("),
"startHeartbeat clears any existing timer before creating one");
assert.match(start, /if \(suspended \|\| !socket\) return false;/);
// The timer is bound to the socket it was started for and stops itself if
// that socket is no longer the live one.
assert.match(start, /if \(!options\.isCurrent\(socket, epoch\)\) \{\s*\n\s*stopHeartbeat\(\);\s*\n\s*return;/);
assert.match(start, /options\.sendPing\(socket\);/);
// A half-open socket must be replaced when the server does not acknowledge
// the heartbeat. Browser OPEN state alone does not prove delivery.
assert.match(start, /heartbeatDeadlineTimer = setTimeout\([\s\S]*options\.isCurrent\(socket, epoch\)[\s\S]*options\.onFailure\("heartbeat_timeout"/,
"a missed pong directly replaces the stale socket through the bounded retry path");
assert.match(watchdog, /if \(heartbeatSocket === socket && heartbeatEpoch === epoch\) clearHeartbeatDeadline\(\);/,
"a pong cancels the reconnect deadline");
});
test("the heartbeat is cleared on close and before a new socket is created", function () {
var onclose = slice(connection, "newWs.onclose = function (e)", "newWs.onerror");
assert.match(onclose, /stopHeartbeat\(\);/);
// connect() tears the previous timer down before replacing the socket.
var connectFn = slice(connection, "export function connect()", "newWs.onopen");
assert.ok(connectFn.indexOf("stopHeartbeat();") < connectFn.indexOf("new WebSocket("),
"the old heartbeat stops before a new socket is opened");
var onopen = slice(connection, "newWs.onopen = function ()", "newWs.onclose");
assert.match(onopen, /startHeartbeat\(newWs, attemptEpoch\);/);
assert.match(connection, /function stopHeartbeat\(\) \{\s*\n\s*watchdog\.stopHeartbeat\(\);/);
assert.match(watchdog, /function stopHeartbeat\(\) \{[\s\S]*clearInterval\(heartbeatTimer\);[\s\S]*heartbeatTimer = null;/);
});
test("connection callbacks are epoch-bound and resume replaces stale health deadlines", function () {
var onmessage = slice(connection, "newWs.onmessage = function (event)", "\n}");
assert.match(onmessage, /if \(getWs\(\) !== newWs \|\| !lifecycle\.current\(attemptEpoch\)\) return;/);
assert.match(connection, /lifecycle\.setOffline\(true\);\s*\n\s*suspendSocketForOffline\(\);/);
assert.match(connection, /function suspendSocketForOffline\(\) \{[\s\S]*socket\.onmessage = null;[\s\S]*setWs\(null\);/);
assert.match(connection, /if \(document\.hidden\) return;\s*\n\s*resumeSocketWatchdogs\("online"\)/);
assert.match(connection, /if \(document\.hidden\) \{[\s\S]*suspendSocketWatchdogs\(\);\s*\n\s*\} else resumeSocketWatchdogs\("visible"\)/);
assert.match(connection, /document\.addEventListener\("freeze"[\s\S]*suspendSocketWatchdogs\(\);/);
assert.match(watchdog, /function suspend\(\) \{[\s\S]*stopHeartbeat\(\);[\s\S]*clearProbe\(\);/);
assert.match(connection, /if \(lifecycle\.isOffline\(\) \|\| !lifecycle\.current\(lifecycle\.getEpoch\(\)\)\) return;/);
assert.match(connection, /var socket = getWs\(\);\s*\n\s*if \(socket && socket\.readyState === 0\) return;/);
assert.match(connection, /recordConnectionDiagnostic\("error", 0, null\);\s*\n\s*forceReplaceSocket\("error", 0, attemptEpoch\);/);
assert.match(connection, /var authFinished = lifecycle\.finishAuth\(authEpoch, authToken\);\s*\n\s*if \(authFinished && lifecycle\.canProceed\(authEpoch\)\) connect\(\);/);
});
test("the heartbeat uses the protocol the server already speaks", function () {
// The global socket answers ping with pong; the client must not invent a
// new message type.
assert.match(globalWs, /if \(msg\.type === "ping"\) \{\s*\n\s*sendTo\(ws, \{ type: "pong" \}\);/);
});
// --- Diagnostics ----------------------------------------------------------
test("diagnostics are bounded structured records without close payloads", function () {
var onclose = slice(connection, "newWs.onclose = function (e)", "newWs.onerror");
assert.match(onclose, /recordConnectionDiagnostic\("close", e && e\.code/);
assert.doesNotMatch(onclose, /e\.reason/);
assert.match(connection, /latencyMs: typeof latency === "number" \? Math\.max\(0, Math\.min\(latency, 600000\)\) : null/);
var onopen = slice(connection, "newWs.onopen = function ()", "newWs.onclose");
assert.match(onopen, /reconnected after " \+ \(Date\.now\(\) - disconnectedAt\) \+ "ms"/);
assert.match(onopen, /if \(hasConnectedOnce && disconnectedAt\)/, "the first connection is not logged as a reconnect");
// No per-message logging.
var onmessage = slice(connection, "newWs.onmessage = function (event)", "\n}");
assert.equal(/console\.(log|warn|info)/.test(onmessage), false, "the message path stays silent");
});
// --- Sticky notes reconciliation -----------------------------------------
test("handleNotesList reconciles by id instead of clearing the container", function () {
var handler = slice(notes, "export function handleNotesList(msg)", "export function handleNoteCreated");
assert.equal(handler.indexOf('container.innerHTML = ""'), -1, "the container is never wiped");
assert.equal(handler.indexOf("notes.clear()"), -1, "the keyed map is never dropped wholesale");
// Existing nodes are patched in place; only missing ones are built.
assert.match(handler, /var entry = notes\.get\(data\.id\);/);
assert.match(handler, /if \(entry && entry\.el && entry\.el\.parentNode === container\) \{\s*\n\s*patchNote\(entry, data\);\s*\n\s*continue;/);
assert.match(handler, /var el = renderNote\(data\);[\s\S]*container\.appendChild\(el\);[\s\S]*created = true;/);
// Stale nodes are removed rather than left behind.
assert.match(handler, /if \(!seen\[id\]\) staleIds\.push\(id\);/);
assert.match(handler, /stale\.el\.parentNode\.removeChild\(stale\.el\);/);
assert.match(handler, /notes\.delete\(staleIds\[i\]\);/);
// A pure patch pass must not trigger a document-wide Lucide scan.
assert.match(handler, /if \(created\) refreshIcons\(\);/);
});
test("an unchanged note keeps its rendered markdown, focus, and draft", function () {
var patch = slice(notes, "function patchNote(entry, next)", "export function handleNoteUpdated");
// Reconciliation owns drafts; the editor subscribes without rewriting unchanged text.
assert.match(patch, /reconcileNote\(next\)/);
assert.doesNotMatch(patch, /rendered\.innerHTML|textarea\.value/);
var state = fs.readFileSync(path.join(root, "lib/public/modules/sticky-notes-state.js"), "utf8");
var editor = fs.readFileSync(path.join(root, "lib/public/modules/sticky-notes-editor.js"), "utf8");
assert.match(state, /ui\.draft && ui\.text !== \(data\.text \|\| ''\)/);
assert.match(editor, /next\.text !== undefined && next\.text !== sourceText/);
assert.match(editor, /if \(changed && !mdMode\)/);
// Geometry, z-order, colour, and opacity still apply unconditionally.
assert.match(patch, /el\.style\.zIndex = 100 \+ \(next\.zIndex \|\| 0\);/);
assert.match(patch, /el\.style\.left = next\.x \+ "px";/);
assert.match(patch, /el\.dataset\.color = next\.color \|\| "purple";/);
assert.match(patch, /el\.style\.setProperty\("--note-opacity", next\.opacity\);/);
// Icons are only re-scanned when the minimize button icon actually swapped.
assert.match(patch, /var iconsReplaced = false;/);
assert.match(patch, /if \(minBtn && !wasMinimized\)/);
assert.match(patch, /if \(minBtn && wasMinimized\)/);
assert.match(patch, /return iconsReplaced;/);
var updated = slice(notes, "export function handleNoteUpdated(msg)", "export function handleNoteWritten");
assert.match(updated, /if \(patchNote\(entry, msg\.note\)\) refreshIcons\(\);/,
"a geometry-only update does not refresh icons");
assert.equal(/rendered\.innerHTML = renderMiniMarkdown/.test(updated), false,
"handleNoteUpdated no longer rewrites markdown directly");
});
test("floating notes isolate live blur and never blink repeatedly", function () {
var noteRule = slice(notesCss, ".sticky-note {", ".sticky-note:hover");
assert.match(noteRule, /backdrop-filter: blur\(7px\)/,
"translucent notes retain the depth cue from their live backdrop");
assert.match(noteRule, /contain: layout style;/,
"note layout changes stay inside the floating surface without clipping its shadow");
assert.match(noteRule, /will-change: transform, backdrop-filter;/,
"the browser can keep each moving blur on a stable compositor layer");
assert.match(notesCss, /\.sticky-note\.sticky-note-attention \{\s*animation: sticky-note-attention-pulse 1\.1s ease-out 1;/,
"a real note update gets one gentle cue rather than three flashes");
var pulse = slice(notesCss, "@keyframes sticky-note-attention-pulse", ".sticky-note.dragging");
assert.match(pulse, /0% \{[\s\S]*100% \{/);
assert.equal(/50%/.test(pulse), false, "the cue only fades out and never oscillates");
});
// --- Session list dirty guard --------------------------------------------
test("an identical session_list frame does not tear down the sidebar", function () {
assert.match(sessions, /function sessionRenderFingerprint\(sessions\)/);
// The fingerprint covers the whole payload plus every other store value the
// render reads, so nothing semantic can be missed by cherry-picking fields.
var fingerprint = slice(sessions, "function sessionRenderFingerprint(sessions)", "export function renderSessionList");
assert.match(fingerprint, /JSON\.stringify\(\[sessions, store\.get\('splitGroups'\) \|\| \[\], store\.get\('splitPanes'\) \|\| null\]\)/);
assert.match(fingerprint, /catch \(e\) \{\s*\n\s*return null;/, "a non-serializable payload falls back to rendering");
// Only an argument-carrying call can be skipped; a null re-render is driven
// by other state and must always run.
var guard = slice(sessions, "export function renderSessionList(sessions)", "// If mobile chat sheet is open");
assert.match(guard, /if \(sessions\) \{/);
assert.match(guard, /if \(incoming !== null && incoming === lastRenderFingerprint\) return;/);
assert.match(guard, /cachedSessions = sessions;/);
assert.ok(guard.indexOf("if (incoming !== null") < guard.lastIndexOf("cachedSessions = sessions;"),
"the guard runs before the cache is replaced");
// The fingerprint is recorded from what was actually rendered.
assert.match(sessions, /lastRenderFingerprint = sessionRenderFingerprint\(cachedSessions\);/);
var tail = slice(sessions, " if (updatePageTitle) updatePageTitle();", "// --- Search results ---");
assert.match(tail, /lastRenderFingerprint = sessionRenderFingerprint\(cachedSessions\);/,
"every completed render refreshes the fingerprint, including null-argument renders");
});
// --- Project rules --------------------------------------------------------
test("the edited client modules follow the project style rules", function () {
[["app-connection.js", connection], ["websocket-watchdog.js", watchdog], ["sticky-notes.js", notes], ["sidebar-sessions.js", sessions]].forEach(function (pair) {
assert.equal(/=>/.test(pair[1]), false, "no arrow functions in " + pair[0]);
assert.equal(/^\s*(const|let)\s/m.test(pair[1]), false, "var only in " + pair[0]);
});
assert.equal(/localStorage/.test(notes), false, "sticky notes store no settings client-side");
});