screentinker/server/test/v4-exit-signal-phase3.test.js
screentinker 938a43a466
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
Group sync: clock/schedule synchronized playback (offline-native) + double-buffer (#167)
* feat(group-sync): synchronized playback per group (server + Android) [stage 1]

Play a group's shared playlist in lockstep across its displays — start/end items
together — by reusing the video-wall sync primitive with the spatial transform
removed and keyed on group_id instead of wall_id.

Server:
- device_groups += sync_enabled + leader_device_id (optional pin).
- buildPlaylistPayload emits a group_sync:{group_id, is_leader} block ONLY for a
  member whose playlist matches the group's shared playlist (playlist-match guard —
  a mismatched member is ignored, never synced). Membership via device_group_members.
- Leader auto-election: pinned-if-online -> first online matching member -> stable
  fallback; computed (never persisted, so the operator's pin is preserved).
- group:sync / group:sync-request relay among eligible members (mirrors wall:sync,
  guarded on membership + playlist match).
- Self-heal: re-push payloads to group members on (re)connect so is_leader refreshes.
- PUT /api/device-groups/:id accepts sync_enabled + leader_device_id and re-pushes.

Android:
- WallController is now mode-aware. WALL = transform + object-fit:fill + forced
  follower-mute (UNCHANGED — every wall branch is byte-equivalent when isGroup=false).
  GROUP = same leader/follower timing incl. the full video drift controller, but
  full-screen (no transform), normal fit, and per-item mute honored (no forced mute).
- WebSocketService: emitGroupSync/emitGroupSyncRequest + onGroupSync/onGroupSyncRequest.
- MainActivity: dispatch emit by mode, parseGroupConfig, onPlaylistUpdate group hook.

Wall path is provably unchanged (additive mode, defaults to WALL). Kotlin compiles;
server suite 407/407.

Stage 2 (follow-up): web + Tizen parity, dashboard sync toggle + leader picker,
offline-sweep leader promotion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(group-sync): web + Tizen player parity [stage 2]

Mirror the Android group-sync generalization in the two web-based players so a group
with a shared playlist syncs across mixed player types, not just Android.

Web player (server/player/index.html):
- group:sync / group:sync-request handlers (index jump + latency-compensated video
  drift, same maths as wall:sync) — no audio policy here, per-item mute honored.
- emitGroupSync() + applyGroupSync() (4Hz leader broadcast; follower sync-request).
  NO CSS transform, NO forced mute (unlike applyWallMode).
- isFollower now also true for a group follower (suppresses self-advance).
- handlePlaylistUpdate handles data.group_sync (mutually exclusive with wall).

Tizen (tizen/js/player.js WallController + app.js):
- WallController is mode-aware: WALL styles the slice + forced follower semantics
  (UNCHANGED when mode !== 'group'); GROUP clears any wall styling (full-screen) and
  drives leader/follower timing only. syncId()/clearStageStyle() helpers; emitSync/
  onSync/onSyncRequest key the event + id off the mode.
- app.js: group:sync/request socket handlers; onPlaylist enters group sync on a
  group_sync block, else exits — content renders through the normal single-zone path.

Realigned the v4-exit-signal-phase3 TIZEN slice (682->696) shifted by the app.js edits.
Wall path is provably unchanged in both (additive group mode). Server suite 407/407;
both players' JS parse.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(group-sync): dashboard UI — sync toggle + leader picker [stage 3]

On each group's header row:
- "Sync" checkbox -> PUT /api/groups/:id { sync_enabled } enables synchronized
  playback; server re-pushes to members so they enter/exit sync mode. A hint notes
  it needs a group playlist and that a display on a different playlist is ignored.
- When on, a "Leader: Auto / <display>" picker -> { leader_device_id } (null = auto-
  elect, which self-heals; or pin a specific member to always lead when online).

Adds api.updateGroup(id, data) and the en i18n strings (en-only, mirroring the
existing per-group UI keys; the i18n parity test is apitoken-scoped).

Frontend parses (ESM); server suite 407/407.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(group-sync): rework to clock/schedule sync + double-buffer + polish

Replace the leader/follower relay model with clock/schedule sync. Every
same-playlist member derives the identical (index, position) locally from a
server-disciplined clock + the deterministic playlist schedule, so sync:
  - needs no server at play-time (offline-native), and
  - has no leader role to double-elect (kills the split-brain class the leaked
    WallController tick produced).

Server
  - heartbeat-ack now carries server_ms + echoes client_ms for NTP-style clock
    discipline; the client caches the offset (survives an outage).
  - POST /groups/:id/resync -> group:resync (manual "Resync now").
  - (kept: group_sync payload; leader machinery is now vestigial/ignored.)

Clients (web / Tizen / Android)
  - Clock offset disciplined over the heartbeat, cached (localStorage / prefs).
  - Schedule engine: pos = (syncedNow mod Sum(duration_sec)) with a CANONICAL
    slot formula identical across platforms so mixed-platform groups can't drift.
  - Snap-on-load: a fresh clip hard-seeks ONCE to the exact position (was ~5s of
    gentle nudge to eat a ~0.3s load offset); steady-state keeps the gentle nudge.
  - Double buffer: warm the next clip a few s before the boundary -> instant
    switch, no black hold. Android pre-decodes on a throwaway surface so the swap
    doesn't flash one wrong-aspect (landscape-stretched) frame.
  - In-place duration edits: duration_sec dropped from the change signature and
    applied in place, so a duration edit re-anchors the schedule WITHOUT a restart.
  - Live-log shows discrete corrections (jump/align/seek) immediately; only the
    steady-state line is throttled.

Android
  - Fix leaked WallController leader tick: onDestroy() now stops it (Handler on the
    main looper outlived the Activity -> zombie broadcaster / split-brain).

Dashboard
  - Group leader picker -> "Resync now" button.

Tests: heartbeat-ack clock fields + resync route; exit-signal .wgt slice realigned.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 14:24:31 -05:00

130 lines
9.6 KiB
JavaScript

// Exit-signal PHASE 3 — proof. (A) socket/#148/liveness safety; (B) per-category classification with
// NOTHING misclassified. The JS-client handlers are proven by EXECUTING THE REAL SOURCE blocks (sliced
// out of index.html / app.js) against shimmed window/navigator/socket and firing synthetic death events.
const path = require('node:path'); const os = require('node:os'); const fs = require('node:fs'); const crypto = require('node:crypto');
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 sleep = ms => new Promise(r => setTimeout(r, ms));
// ---- harness: run a real client exit-block against shims, capturing what it sends ----
function harness(file, startLine, endLine) {
const src = fs.readFileSync(file, 'utf8').split('\n').slice(startLine - 1, endLine).join('\n');
const beacons = [], socketSends = [], handlers = {};
const windowShim = { addEventListener: (ev, fn) => { (handlers[ev] = handlers[ev] || []).push(fn); }, location: { origin: 'http://srv' } };
const navShim = { sendBeacon: (url, blob) => { beacons.push({ url, ...JSON.parse(blob.__body) }); return true; } };
class BlobShim { constructor(parts, o) { this.__body = parts[0]; this.type = o && o.type; } }
const socketShim = { connected: true, emit: (ev, payload) => { socketSends.push({ ev, ...payload }); } };
const config = { deviceId: 'D1', deviceToken: 'T1', serverUrl: 'http://srv' }; // /player reads config.*
const fn = new Function('window', 'navigator', 'Blob', 'fetch', 'config', 'deviceId', 'deviceToken', 'serverUrl', 'socket', 'JSON', src);
fn(windowShim, navShim, BlobShim, () => Promise.resolve(), config, 'D1', 'T1', 'http://srv', socketShim, JSON);
return { beacons, socketSends, fire: (ev, e) => (handlers[ev] || []).forEach(f => f(e)), handlers };
}
const WINDOW = 'window-target'; // sentinel for ev.target === window (real error event on window)
// ============ PART B — /player classification (real source, lines 349-385) ============
const PLAYER = path.join(__dirname, '../player/index.html');
test('B/player CRASH: uncaught error + unhandledrejection -> crashed (via sendBeacon, NOT the socket)', () => {
let h = harness(PLAYER, 349, 385); h.fire('error', { error: { message: 'boom' }, target: undefined });
assert.equal(h.beacons.length, 1); assert.equal(h.beacons[0].reason, 'crashed');
assert.equal(h.socketSends.length, 0, '/player uses the beacon channel only — no send over the dying socket');
h = harness(PLAYER, 349, 385); h.fire('unhandledrejection', { reason: { message: 'rej' } });
assert.equal(h.beacons[0].reason, 'crashed');
});
test('B/player NO-MISCLASSIFY: a RESOURCE load error (img/script) is NOT a crash', () => {
const h = harness(PLAYER, 349, 385); h.fire('error', { target: { src: 'https://x/img.png' } });
assert.equal(h.beacons.length, 0, 'resource error must not emit crashed');
});
test('B/player CLEAN-CLOSE: pagehide(persisted=false) -> clean_exit', () => {
const h = harness(PLAYER, 349, 385); h.fire('pagehide', { persisted: false });
assert.equal(h.beacons[0].reason, 'clean_exit');
});
test('B/player BACKGROUNDING: pagehide(persisted=true) bfcache suspend -> NO exit (not a death)', () => {
const h = harness(PLAYER, 349, 385); h.fire('pagehide', { persisted: true });
assert.equal(h.beacons.length, 0, 'a suspend must NOT emit clean_exit');
assert.equal((h.handlers['visibilitychange'] || []).length, 0, 'exit block wires NO visibilitychange -> hidden never emits exit');
});
test('B/player IDEMPOTENT: crash then pagehide -> only crashed (crash not relabelled clean_exit)', () => {
const h = harness(PLAYER, 349, 385); h.fire('error', { error: { message: 'boom' } }); h.fire('pagehide', { persisted: false });
assert.equal(h.beacons.length, 1); assert.equal(h.beacons[0].reason, 'crashed');
});
// ============ PART B — .wgt classification (real source, lines 723-751) ============
const TIZEN = path.join(__dirname, '../../tizen/js/app.js');
test('B/wgt CRASH: error/rejection -> crashed (socket AND beacon; server dedups)', () => {
const h = harness(TIZEN, 723, 751); h.fire('error', { error: { message: 'boom' } });
assert.equal(h.beacons[0].reason, 'crashed');
assert.equal(h.socketSends[0].reason, 'crashed'); assert.equal(h.socketSends[0].ev, 'device:exit');
});
test('B/wgt NO-MISCLASSIFY: resource error is not a crash', () => {
const h = harness(TIZEN, 723, 751); h.fire('error', { target: { src: 'x.png' } });
assert.equal(h.beacons.length, 0); assert.equal(h.socketSends.length, 0);
});
test('B/wgt CLEAN-CLOSE: pagehide(false) -> clean_exit; BACKGROUNDING pagehide(true) -> NO exit', () => {
let h = harness(TIZEN, 723, 751); h.fire('pagehide', { persisted: false });
assert.equal(h.beacons[0].reason, 'clean_exit');
h = harness(TIZEN, 723, 751); h.fire('pagehide', { persisted: true });
assert.equal(h.beacons.length, 0, 'suspend must NOT emit clean_exit');
assert.equal((h.handlers['visibilitychange'] || []).length, 0, 'no visibilitychange in the exit block');
});
// ============ PART A — server-side socket / #148 / reconnect-vs-exit safety ============
const PORT = 3975; const BASE = `http://127.0.0.1:${PORT}`;
const DATA_DIR = path.join(os.tmpdir(), 'st-exit3-' + crypto.randomBytes(4).toString('hex'));
let proc, JWT;
before(async () => {
const logFd = fs.openSync(path.join(os.tmpdir(), 'st-exit3.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 { if ((await fetch(BASE + '/api/status')).ok) { up = true; break; } } catch {} await sleep(250); }
if (!up) throw new Error('boot fail');
JWT = (await (await fetch(BASE + '/api/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'op@t.local', password: 'test12345', name: 'Op' }) })).json()).token;
});
after(() => { try { proc.kill('SIGKILL'); } catch {} });
const connect = () => ioClient(`${BASE}/device`, { transports: ['websocket'], reconnection: false, forceNew: true });
const reg = (s, m) => new Promise((res, rej) => { s.once('device:registered', d => res(d)); s.emit('device:register', m); setTimeout(() => rej(new Error('to')), 5000); });
const pair = (c) => fetch(BASE + '/api/provision/pair', { method: 'POST', headers: { Authorization: 'Bearer ' + JWT, 'Content-Type': 'application/json' }, body: JSON.stringify({ pairing_code: c, name: 't' }) });
const row = async (id) => (await (await fetch(`${BASE}/api/devices/${id}`, { headers: { Authorization: 'Bearer ' + JWT } })).json());
const ackWithin = (s, hb, ms = 1500) => new Promise(r => { let d = false; const f = v => { if (!d) { d = true; r(v); } }; s.once('device:heartbeat-ack', () => f(true)); s.emit('device:heartbeat', hb); setTimeout(() => f(false), ms); });
test('A: crash-emit then teardown -> device goes Offline normally (no orphan/half-open) + reason kept', async () => {
const s = connect(); await new Promise(r => s.on('connect', r));
const d = await reg(s, { pairing_code: '820001', fingerprint: 'f1', device_info: {}, client_type: 'apk', contract_version: 'v4' });
await pair('820001'); await sleep(150);
s.emit('device:exit', { reason: 'crashed', detail: 'boom' }); // emit AS the socket is about to die
s.close(); // teardown immediately after
await sleep(5800);
const r = await row(d.device_id);
assert.equal(r.status, 'offline', 'normal offline transition still fired (teardown not disturbed)');
assert.equal(r.offline_reason, 'crashed');
});
test('A: RECONNECT is NOT an exit, and an exit does not block reconnect — #148 one socket, reason cleared', async () => {
const s1 = connect(); await new Promise(r => s1.on('connect', r));
const d = await reg(s1, { pairing_code: '820002', fingerprint: 'f2', device_info: {}, client_type: 'apk', contract_version: 'v4' });
await pair('820002'); await sleep(150);
s1.emit('device:exit', { reason: 'crashed' }); await sleep(150);
assert.equal((await row(d.device_id)).offline_reason, 'crashed');
s1.close(); await sleep(300);
// genuine reconnect (new socket) — must NOT emit an exit, and must clear the stale reason
const s2 = connect(); await new Promise(r => s2.on('connect', r));
await reg(s2, { device_id: d.device_id, device_token: d.device_token, fingerprint: 'f2', device_info: {}, client_type: 'apk', contract_version: 'v4' });
await sleep(200);
assert.equal((await row(d.device_id)).offline_reason, null, 'reconnect cleared the reason (reconnect != exit)');
assert.equal(await ackWithin(s2, { device_id: d.device_id, telemetry: {} }), true, 'the single reconnected socket is healthy (#148 intact)');
assert.equal((await row(d.device_id)).status, 'online');
s2.close();
});
test('A: VIOLENT kill (abrupt drop, NO device:exit) -> silent, never crashed/clean_exit (Bold-critical)', async () => {
const s = connect(); await new Promise(r => s.on('connect', r));
const d = await reg(s, { pairing_code: '820003', fingerprint: 'f3', device_info: {}, client_type: 'apk', contract_version: 'v4' });
await pair('820003'); await sleep(150);
s.io.engine.close(); // hard transport drop — no clean disconnect, no exit signal (force-stop/power/MDM)
await sleep(5800);
const r = await row(d.device_id);
assert.equal(r.status, 'offline');
assert.equal(r.offline_reason, 'silent', 'external/violent death reads as silent');
assert.notEqual(r.offline_reason, 'clean_exit'); assert.notEqual(r.offline_reason, 'crashed');
});