screentinker/server/test/v4-core-liveness.test.js
ScreenTinker 4cf156d4a0 feat(server): v4 liveness CORE pass — uniform heartbeat-ack + ack-gap + dashboard liveness + identity
Server-side keystone: the server now honors the v4 liveness contract uniformly across the MIXED
fleet (v4 + old pre-v4 + disconnected), all three clients depending on it.
- UNIFORM heartbeat-ack: emitted from the single shared device:heartbeat handler (uniform by
  construction; no per-client/per-path branch), BEFORE the auth guard so a known device's watchdog
  stays armed. Harmless to old clients (they ignore it).
- RECONNECT-WINDOW ack-gap fix (ackableHeartbeat): ack a KNOWN device (authed socket OR a device_id
  that resolves) even mid-reconnect; NOT anonymous/never-authenticated sockets (degrade-safe);
  identity-agnostic. No state mutation before requireDeviceAuth (auth surface unchanged; device_ids
  are uuidv4).
- DASHBOARD LIVENESS (deriveLiveness): server-derived, VERSION-AGNOSTIC Healthy/Degraded/Offline
  from signals every client sends (socket presence, heartbeat age, reconnect frequency); no client
  status-push.
- IDENTITY CAPTURE (capture-don't-act): client_type/client_version/platform/contract_version columns;
  degrades to legacy/unknown for old clients; NEVER breaks register.
- A-BUCKET FIX (QA): recordReconnect + persistIdentity gated on !isPlaylistRefresh (a ~45-60s refresh
  is not a reconnect/new identity — matches #134), and the identity write is change-detected — closing
  the WAL write-amplification (A1) and the benign-refresh -> false-"Degraded" (A2) regressions.
New lib/liveness.js (pure helpers, unit-tested). 30 new tests (uniform ack, ack-gap, mixed fleet,
identity capture, cross-client conformance, refresh-gate reproduce-then-prove); 366/366 total.
OTA artifact-availability is a separate concern (out of scope).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 11:18:55 -05:00

191 lines
14 KiB
JavaScript

// v4 CORE PASS — server honors the liveness contract uniformly across the MIXED fleet (v4 + old
// pre-v4 + disconnected). Validates: uniform device:heartbeat-ack, the reconnect-window ack-gap fix,
// server-derived liveness, identity capture (degrades on missing), cross-client conformance.
const { test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const ioClient = require('../node_modules/socket.io-client');
const path = require('node:path'); const os = require('node:os'); const fs = require('node:fs'); const crypto = require('node:crypto');
const liveness = require('../lib/liveness');
// ============================ PURE UNIT TESTS (no server) ============================
test('ackableHeartbeat: authed socket is acked', () => {
assert.equal(liveness.ackableHeartbeat('dev1', 'dev1', () => false), true); // authed -> known regardless
});
test('ackableHeartbeat: KNOWN device mid-reconnect (not-yet-authed socket, resolvable id) is acked — the ack-gap fix', () => {
assert.equal(liveness.ackableHeartbeat(null, 'devKnown', (id) => id === 'devKnown'), true);
});
test('ackableHeartbeat: anonymous (no device_id) NOT acked — degrade-safe', () => {
assert.equal(liveness.ackableHeartbeat(null, undefined, () => true), false);
});
test('ackableHeartbeat: unknown device_id NOT acked', () => {
assert.equal(liveness.ackableHeartbeat(null, 'ghost', () => false), false);
});
test('ackableHeartbeat: BOTH identity paths acked identically (id-agnostic — device_id resolves)', () => {
const exists = (id) => id === 'viaToken' || id === 'viaFingerprint';
assert.equal(liveness.ackableHeartbeat(null, 'viaToken', exists), true);
assert.equal(liveness.ackableHeartbeat(null, 'viaFingerprint', exists), true);
});
test('deriveLiveness: disconnected device -> offline (normal state, not an error)', () => {
assert.equal(liveness.deriveLiveness({ connected: false, lastHeartbeatAgeMs: 999999, recentReconnects: 9 }), 'offline');
});
test('deriveLiveness: OLD client (connected + heartbeating, NO v4 signals) -> healthy (version-agnostic)', () => {
assert.equal(liveness.deriveLiveness({ connected: true, lastHeartbeatAgeMs: 5000, recentReconnects: 0 }), 'healthy');
});
test('deriveLiveness: connected but reconnect-churn -> degraded', () => {
assert.equal(liveness.deriveLiveness({ connected: true, lastHeartbeatAgeMs: 5000, recentReconnects: 3 }), 'degraded');
});
test('deriveLiveness: connected but silent past window -> degraded', () => {
assert.equal(liveness.deriveLiveness({ connected: true, lastHeartbeatAgeMs: 40000, recentReconnects: 0 }), 'degraded');
});
test('captureIdentity: full v4 block captured verbatim', () => {
assert.deepEqual(liveness.captureIdentity({ client_type: 'wgt', client_version: '1.9.2', platform: 'Tizen 6.5', contract_version: 'v4' }),
{ client_type: 'wgt', client_version: '1.9.2', platform: 'Tizen 6.5', contract_version: 'v4' });
});
test('captureIdentity: OLD client (no block) -> legacy/unknown defaults, NEVER fails', () => {
assert.deepEqual(liveness.captureIdentity({}), { client_type: 'legacy', client_version: 'unknown', platform: 'unknown', contract_version: 'legacy' });
assert.deepEqual(liveness.captureIdentity(undefined), { client_type: 'legacy', client_version: 'unknown', platform: 'unknown', contract_version: 'legacy' });
});
test('captureIdentity: PARTIAL block degrades per-field', () => {
assert.deepEqual(liveness.captureIdentity({ client_type: 'apk' }), { client_type: 'apk', client_version: 'unknown', platform: 'unknown', contract_version: 'legacy' });
});
// ============================ CROSS-CLIENT CONFORMANCE (source diff) ============================
test('cross-client conformance: threshold + arm + identity IDENTICAL across APK/.wgt/player', () => {
const root = path.join(__dirname, '..', '..');
const wgt = fs.readFileSync(path.join(root, 'tizen/js/app.js'), 'utf8');
const player = fs.readFileSync(path.join(root, 'server/player/index.html'), 'utf8');
const apk = fs.readFileSync(path.join(root, 'android/app/src/main/java/com/remotedisplay/player/service/LivenessWatchdog.kt'), 'utf8');
// threshold 45000 ± 10000 — identical formula constants in all three
assert.match(wgt, /THRESHOLD_BASE_MS = 45000, THRESHOLD_JITTER_MS = 10000/);
assert.match(player, /V4_THRESHOLD_BASE_MS = 45000, V4_THRESHOLD_JITTER_MS = 10000/);
assert.match(apk, /THRESHOLD_BASE_MS = 45_000L/); assert.match(apk, /THRESHOLD_JITTER_MS = 10_000L/);
// arm event name — identical
for (const s of [wgt, player, apk]) assert.match(s, /device:heartbeat-ack/);
// watchdog backoff params — .wgt/player io opts AND APK LivenessWatchdog all 1000/30000/0.2
assert.match(wgt, /reconnectionDelay: 1000/); assert.match(wgt, /reconnectionDelayMax: 30000/); assert.match(wgt, /randomizationFactor: 0.2/);
assert.match(player, /reconnectionDelay: 1000/); assert.match(player, /reconnectionDelayMax: 30000/); assert.match(player, /randomizationFactor: 0.2/);
assert.match(apk, /BACKOFF_BASE_MS = 1_000L/); assert.match(apk, /BACKOFF_CAP_MS = 30_000L/);
});
test('cross-client conformance FINDING: APK socket.io TRANSPORT backoff diverges (60s/0.5 vs 30s/0.2)', () => {
const apkWs = fs.readFileSync(path.join(__dirname, '..', '..', 'android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt'), 'utf8');
// Documented divergence (from the /player QA pass): the APK's IO.Options transport backoff is
// 60000/0.5, not the canonical 30000/0.2 the .wgt/player use. Assert it so the finding is tracked.
assert.match(apkWs, /reconnectionDelayMax = 60_000/);
assert.match(apkWs, /randomizationFactor = 0.5/);
});
// ============================ E2E: MIXED FLEET against the real server ============================
const PORT = 3968;
const BASE = `http://127.0.0.1:${PORT}`;
const DATA_DIR = path.join(os.tmpdir(), 'st-v4core-' + crypto.randomBytes(4).toString('hex'));
const LOG = path.join(os.tmpdir(), 'st-v4core.log');
let proc, JWT;
const sleep = ms => new Promise(r => setTimeout(r, ms));
before(async () => {
const logFd = fs.openSync(LOG, 'w');
proc = spawn('node', ['server.js'], { cwd: path.join(__dirname, '..'), env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test' }, stdio: ['ignore', logFd, logFd] });
let up = false;
for (let i = 0; i < 80; i++) { try { const r = await fetch(BASE + '/api/status'); if (r.ok) { up = true; break; } } catch { /* */ } await sleep(250); }
if (!up) throw new Error('server did not boot:\n' + fs.readFileSync(LOG, 'utf8').slice(-2000));
JWT = (await (await fetch(BASE + '/api/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'op@test.local', password: 'test12345', name: 'Op' }) })).json()).token;
});
after(() => { try { proc.kill('SIGKILL'); } catch { /* */ } });
// open a socket, register with regMsg, resolve {sock, data} on device:registered (socket stays OPEN)
function openAndRegister(regMsg) {
return new Promise((resolve, reject) => {
const sock = ioClient(`${BASE}/device`, { transports: ['websocket'], reconnection: false, forceNew: true });
sock.on('connect', () => sock.emit('device:register', regMsg));
sock.on('device:registered', (d) => resolve({ sock, data: d }));
setTimeout(() => reject(new Error('register timeout')), 4000);
});
}
// emit a heartbeat, resolve true if device:heartbeat-ack arrives within `ms`, else false
function ackWithin(sock, hbMsg, ms = 1200) {
return new Promise((resolve) => {
let done = false; const fin = v => { if (!done) { done = true; resolve(v); } };
sock.once('device:heartbeat-ack', () => fin(true));
sock.emit('device:heartbeat', hbMsg);
setTimeout(() => fin(false), ms);
});
}
test('PRIMARY: uniform ack — a v4 device (pairing path) is acked from the shared handler', async () => {
const { sock, data } = await openAndRegister({ pairing_code: '111111', fingerprint: 'fp-v4a', device_info: {}, client_type: 'wgt', client_version: '1.9.2', platform: 'Tizen 6.5', contract_version: 'v4' });
assert.ok(await ackWithin(sock, { device_id: data.device_id, telemetry: {} }), 'v4 device heartbeat should be acked');
sock.close();
});
test('PRIMARY: uniform ack — the reconnect path (device_id+token) is acked identically', async () => {
const first = await openAndRegister({ pairing_code: '222222', fingerprint: 'fp-v4b', device_info: {} });
const creds = { id: first.data.device_id, token: first.data.device_token }; first.sock.close(); await sleep(300);
const { sock } = await openAndRegister({ device_id: creds.id, device_token: creds.token, fingerprint: 'fp-v4b', device_info: {}, client_type: 'apk', contract_version: 'v4' });
assert.ok(await ackWithin(sock, { device_id: creds.id, telemetry: {} }), 'reconnected device heartbeat should be acked');
sock.close();
});
test('FIX 1 ack-gap: a KNOWN device mid-reconnect (heartbeat BEFORE re-register) is acked', async () => {
const first = await openAndRegister({ pairing_code: '333333', fingerprint: 'fp-gap', device_info: {} });
const knownId = first.data.device_id; first.sock.close(); await sleep(300);
// fresh socket, NOT registered — send a heartbeat carrying the KNOWN device_id
const raw = ioClient(`${BASE}/device`, { transports: ['websocket'], reconnection: false, forceNew: true });
await new Promise(r => raw.on('connect', r));
assert.ok(await ackWithin(raw, { device_id: knownId, telemetry: {} }), 'known device mid-reconnect must be acked so its watchdog stays armed');
raw.close();
});
test('FIX 1 ack-gap: anonymous / unknown socket is NOT acked (degrade-safe)', async () => {
const raw = ioClient(`${BASE}/device`, { transports: ['websocket'], reconnection: false, forceNew: true });
await new Promise(r => raw.on('connect', r));
assert.equal(await ackWithin(raw, { telemetry: {} }, 900), false, 'no device_id -> not acked');
assert.equal(await ackWithin(raw, { device_id: 'ghost-' + crypto.randomBytes(4).toString('hex'), telemetry: {} }, 900), false, 'unknown device_id -> not acked');
raw.close();
});
test('MIXED FLEET: v4 + OLD (no identity block) + anonymous simultaneously — nothing errors, acks correct', async () => {
// v4 client (identity block) and OLD client (NO identity block, no ack consumption) both register+ack.
const v4 = await openAndRegister({ pairing_code: '444444', fingerprint: 'fp-mixv4', device_info: {}, client_type: 'player', client_version: '1.1.0-web', platform: 'Chrome 120', contract_version: 'v4' });
const old = await openAndRegister({ pairing_code: '555555', fingerprint: 'fp-mixold', device_info: { app_version: 'legacy-apk-1.0' } }); // NO identity block
assert.ok(v4.data.device_id && old.data.device_id, 'both v4 and OLD clients registered WITHOUT error on missing identity');
assert.ok(await ackWithin(v4.sock, { device_id: v4.data.device_id, telemetry: {} }), 'v4 acked');
assert.ok(await ackWithin(old.sock, { device_id: old.data.device_id, telemetry: {} }), 'OLD client acked too (harmless — it ignores the ack)');
// anonymous present at the same time -> not acked, server unbothered
const anon = ioClient(`${BASE}/device`, { transports: ['websocket'], reconnection: false, forceNew: true });
await new Promise(r => anon.on('connect', r));
assert.equal(await ackWithin(anon, { telemetry: {} }, 900), false, 'anonymous not acked');
v4.sock.close(); old.sock.close(); anon.close();
// server still healthy after the mixed load
assert.equal((await fetch(BASE + '/api/status')).ok, true, 'server unbroken by the mixed fleet');
});
test('FIX 3 identity capture: v4 -> stored verbatim; OLD -> legacy/unknown (verified via device API)', async () => {
// v4 device, paired, then read back
const v4 = await openAndRegister({ pairing_code: '666666', fingerprint: 'fp-idv4', device_info: {}, client_type: 'wgt', client_version: '1.9.2', platform: 'Tizen 6.5', contract_version: 'v4' });
v4.sock.close();
await fetch(BASE + '/api/provision/pair', { method: 'POST', headers: { Authorization: 'Bearer ' + JWT, 'Content-Type': 'application/json' }, body: JSON.stringify({ pairing_code: '666666', name: 'v4dev' }) });
const v4row = await (await fetch(BASE + '/api/devices/' + v4.data.device_id, { headers: { Authorization: 'Bearer ' + JWT } })).json();
assert.equal(v4row.client_type, 'wgt'); assert.equal(v4row.contract_version, 'v4'); assert.equal(v4row.platform, 'Tizen 6.5');
// OLD device (no identity block), paired, read back -> legacy/unknown
const old = await openAndRegister({ pairing_code: '777777', fingerprint: 'fp-idold', device_info: {} });
old.sock.close();
await fetch(BASE + '/api/provision/pair', { method: 'POST', headers: { Authorization: 'Bearer ' + JWT, 'Content-Type': 'application/json' }, body: JSON.stringify({ pairing_code: '777777', name: 'olddev' }) });
const oldrow = await (await fetch(BASE + '/api/devices/' + old.data.device_id, { headers: { Authorization: 'Bearer ' + JWT } })).json();
assert.equal(oldrow.client_type, 'legacy'); assert.equal(oldrow.contract_version, 'legacy'); assert.equal(oldrow.client_version, 'unknown');
});
test('#148 + degrade-safe hold: a device reconnect yields ONE connection, ack still works', async () => {
const first = await openAndRegister({ pairing_code: '888888', fingerprint: 'fp-148', device_info: {} });
const creds = { id: first.data.device_id, token: first.data.device_token };
// reconnect on a NEW socket (old still open) -> server evicts the old, one connection remains
const second = await openAndRegister({ device_id: creds.id, device_token: creds.token, fingerprint: 'fp-148', device_info: {} });
await sleep(400);
assert.ok(await ackWithin(second.sock, { device_id: creds.id, telemetry: {} }), 'the surviving socket is acked');
const connected = (await (await fetch(BASE + '/api/status')).json()).devices_connected;
assert.ok(connected >= 1, 'device present; #148 single-socket not broken by the ack');
try { first.sock.close(); } catch {} second.sock.close();
});