screentinker/server/test/device-events.test.js
screentinker 9c70fcc790
feat(diagnostics): device incident log — offline cause, network-vs-reboot, display-sleep (#175)
* feat(diagnostics): device incident log — why a screen went offline/black, with device-attested cause

Field request (Bold/s_t_r_o_b_e): "screens go offline randomly — let us see the cause." Answers it
across the fleet with a unified incident log, and — the key insight — lets the DEVICE disambiguate
the cause the server can't: if the app process survived the gap it was a NETWORK problem (not a
reboot), and it can even tell a dropped Wi-Fi/Ethernet link from a link-up-but-server-unreachable
(router/upstream) failure.

Schema:
- device_status_log gains reason + detail (WHY each offline transition happened).
- NEW device_events table (unified incident feed): type (offline/online/display_off/display_on/
  crash/reboot/network/app_error) + reason + detail, indexed, age-pruned + per-device capped.

Server:
- Capture the socket.io disconnect REASON (transport_close/ping_timeout/transport_error) instead of
  discarding it — recorded in the offline-cause log. devices.offline_reason stays on the EXIT-SIGNAL
  contract (crashed/clean_exit/silent) — a separate axis, preserved (violent death = 'silent').
- device:event handler (typed incidents) + device:connectivity-report handler (device-attested).
  lib/incident-classify.js (pure, unit-tested) composes reason+detail: cold_start->reboot;
  link_lost->network "Wi-Fi/Ethernet link lost"; else network "LAN up, server unreachable
  (router/upstream)"; appends SSID / weak-signal (rssi<-75) / IP-changed. On a report it upgrades the
  most-recent offline row from the server's guess to the device's ground truth.
- heartbeat timeout -> 'heartbeat_timeout'; retention/cap for device_events.
- Device-detail API returns statusLog.reason/detail + the last 50 device_events.

Device (Android WebSocketService): ConnectivityManager default-network callback (link-lost during a
gap) + Wi-Fi SSID/RSSI + IP snapshot -> device:connectivity-report on reconnect (app survived =>
network); ACTION_SCREEN_ON/OFF receiver -> device:event display_on/off ("screen went black"). All
guarded/feature-detected; no manifest change; compiles clean.

Web + Tizen players: reconnect connectivity-report (link_lost from navigator.onLine during the gap)
+ visibilitychange -> display_off/on. Best-effort (no wifi detail in a browser). Tizen exit-signal
marker slice untouched.

CMS (device-detail): the offline cause on the uptime-timeline hover + a new "Recent incidents" panel
(merged offline periods + typed events, friendly labels, detail, relative time + down-duration).

Built as a 4-way parallel agent fan-out over disjoint domains against a locked contract, then
integrated. Verified: full server suite 443/443 (incl. the seam fix keeping the exit-signal contract
intact), Android compileDebugKotlin clean, all players + CMS node -c clean.

Refs #170.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(diagnostics): internet-reachability probe — split "our server down" from "no internet" (#170)

Follow-up to the incident log: when a device's link is UP but it's offline, "router/upstream" was a
catch-all. The device now probes a public host (1.1.1.1 / 8.8.8.8 :443) DURING the gap, so the cause
pinpoints blame:
  - link_lost=true                     -> Wi‑Fi/Ethernet link lost (device's own link)
  - link up, internet_ok=true          -> server_down: internet reachable, OUR server was unreachable
  - link up, internet_ok=false         -> no_internet: router/ISP down
  - link up, no probe result           -> generic router/upstream (unchanged fallback)

- Android WebSocketService: fire a short daemon-thread TCP probe (443, either host) at disconnect;
  the result rides the connectivity-report as internet_ok (omitted if the gap ends before it finishes).
- lib/incident-classify.js: 3-way split on internet_ok; new reasons server_down / no_internet.
- Frontend i18n: device.event.server_down / .no_internet labels.
- Tests: +3 classify cases (server_down, no_internet, link_lost wins over internet_ok). 12/12.

Verified: classify 12/12, Android compileDebugKotlin clean, node -c clean. Refs #170.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(diagnostics): log an 'upgrade' incident (old → new app_version) — server-side (#170)

When a device reports an app_version different from the stored one, applyDeviceInfo logs an
'upgrade' device_events row (detail 'old → new'). Server-side, so it covers Android/Tizen/web with
no client change; a fresh pair (no prior version) isn't counted. Adds device.event.upgrade label.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 11:26:04 -05:00

155 lines
7.1 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
// Offline-cause / incident-log unit tests. Two layers, no socket server needed:
// 1. The pure classifier (lib/incident-classify) — the actual rules the live
// device:connectivity-report + disconnect handlers apply. Testing the extracted
// helper guarantees the handler and these assertions agree on the exact strings.
// 2. A tiny in-memory better-sqlite3 exercising the same INSERT/UPDATE the handlers
// run, driven by the classifier's output, so the persistence shape is proven too
// (a device:event row lands; a connectivity-report upgrades the recent offline row).
const { test } = require('node:test');
const assert = require('node:assert/strict');
const Database = require('better-sqlite3');
const {
ALLOWED_EVENT_TYPES,
isAllowedEventType,
normalizeDisconnectReason,
classifyConnectivity,
} = require('../lib/incident-classify');
// ---- 1. classifyConnectivity: reason/detail composition ----
test('connectivity: cold_start wins -> reason reboot', () => {
const c = classifyConnectivity({ cold_start: true, link_lost: true });
assert.equal(c.reason, 'reboot');
assert.equal(c.type, 'reboot');
assert.equal(c.detail, 'Device restarted (power/reboot)');
});
test('connectivity: link_lost true -> reason network, link-lost detail', () => {
const c = classifyConnectivity({ link_lost: true });
assert.equal(c.reason, 'network');
assert.equal(c.type, 'network');
assert.match(c.detail, /link lost/);
});
test('connectivity: link_lost false, no probe -> reason network, router/upstream detail', () => {
const c = classifyConnectivity({ link_lost: false });
assert.equal(c.reason, 'network');
assert.equal(c.type, 'network');
assert.match(c.detail, /server unreachable \(router\/internet\/upstream\)/);
});
test('connectivity: link up + internet_ok true -> server_down (OUR server, not the site)', () => {
const c = classifyConnectivity({ link_lost: false, internet_ok: true });
assert.equal(c.reason, 'server_down');
assert.equal(c.type, 'network');
assert.match(c.detail, /Internet reachable but the ScreenTinker server was unreachable/);
});
test('connectivity: link up + internet_ok false -> no_internet (router/ISP down)', () => {
const c = classifyConnectivity({ link_lost: false, internet_ok: false });
assert.equal(c.reason, 'no_internet');
assert.match(c.detail, /No internet — router\/ISP down/);
});
test('connectivity: link_lost true wins over internet_ok (device link is the root cause)', () => {
const c = classifyConnectivity({ link_lost: true, internet_ok: false });
assert.match(c.detail, /WiFi\/Ethernet link lost/);
});
test('connectivity: ssid / weak-rssi / ip_changed fragments append to detail', () => {
const c = classifyConnectivity({ link_lost: true, ssid: 'Office', rssi: -82, ip_changed: true });
assert.match(c.detail, /SSID "Office"/);
assert.match(c.detail, /weak signal \(-82 dBm\)/);
assert.match(c.detail, /IP changed \(DHCP\/router\)/);
// strong signal is NOT flagged
assert.ok(!/weak signal/.test(classifyConnectivity({ link_lost: true, rssi: -50 }).detail));
});
// ---- 2. normalizeDisconnectReason: socket.io reason -> category token ----
test('disconnect reason normalizes (whitespace->_, lowercase) and defaults to silent', () => {
assert.equal(normalizeDisconnectReason('transport close'), 'transport_close');
assert.equal(normalizeDisconnectReason('ping timeout'), 'ping_timeout');
assert.equal(normalizeDisconnectReason('Transport Error'), 'transport_error');
assert.equal(normalizeDisconnectReason(''), 'silent');
assert.equal(normalizeDisconnectReason(undefined), 'silent');
assert.equal(normalizeDisconnectReason(null), 'silent');
});
// ---- 3. allowed event types ----
test('event types: allowed set gates device:event', () => {
for (const t of ['offline', 'display_off', 'display_on', 'crash', 'reboot', 'network', 'app_error']) {
assert.ok(isAllowedEventType(t), `${t} allowed`);
assert.ok(ALLOWED_EVENT_TYPES.has(t));
}
assert.ok(!isAllowedEventType('bogus'));
assert.ok(!isAllowedEventType(''));
assert.ok(!isAllowedEventType(undefined));
});
// ---- 4. persistence: the SQL the handlers run, driven by the classifier ----
function freshDb() {
const db = new Database(':memory:');
db.exec(`
CREATE TABLE device_events (
id INTEGER PRIMARY KEY AUTOINCREMENT, device_id TEXT NOT NULL, type TEXT NOT NULL,
reason TEXT, detail TEXT, timestamp INTEGER NOT NULL DEFAULT (strftime('%s','now')));
CREATE TABLE device_status_log (
id INTEGER PRIMARY KEY AUTOINCREMENT, device_id TEXT, status TEXT, reason TEXT, detail TEXT,
timestamp INTEGER NOT NULL DEFAULT (strftime('%s','now')));
`);
return db;
}
test('device:event inserts a device_events row (allowed type)', () => {
const db = freshDb();
// mirrors the handler body after the isAllowedEventType gate
db.prepare("INSERT INTO device_events (device_id, type, reason, detail) VALUES (?, ?, ?, ?)")
.run('dev1', 'display_off', null, 'screen slept');
const rows = db.prepare('SELECT * FROM device_events WHERE device_id = ?').all('dev1');
assert.equal(rows.length, 1);
assert.equal(rows[0].type, 'display_off');
assert.equal(rows[0].detail, 'screen slept');
});
test('connectivity-report upgrades the recent offline status-log row + logs an event', () => {
const db = freshDb();
// A server-guessed offline row exists (the disconnect handler wrote 'transport_close').
db.prepare("INSERT INTO device_status_log (device_id, status, reason, detail) VALUES ('dev1','offline','transport_close',NULL)").run();
// Handler path: classify the device's report, then UPDATE the recent offline row + INSERT an event.
const { reason, detail, type } = classifyConnectivity({ link_lost: true, ssid: 'Shop', rssi: -80 });
db.prepare(`UPDATE device_status_log SET reason = ?, detail = ?
WHERE id = (SELECT id FROM device_status_log
WHERE device_id = ? AND status IN ('offline','offline_timeout')
AND timestamp > strftime('%s','now') - 900
ORDER BY timestamp DESC, id DESC LIMIT 1)`).run(reason, detail, 'dev1');
db.prepare("INSERT INTO device_events (device_id, type, reason, detail) VALUES (?, ?, ?, ?)")
.run('dev1', type, reason, detail);
const log = db.prepare("SELECT reason, detail FROM device_status_log WHERE device_id = 'dev1'").get();
assert.equal(log.reason, 'network', 'server guess upgraded to device ground truth');
assert.match(log.detail, /link lost/);
assert.match(log.detail, /SSID "Shop"/);
const ev = db.prepare("SELECT type, reason FROM device_events WHERE device_id = 'dev1'").get();
assert.equal(ev.type, 'network');
assert.equal(ev.reason, 'network');
});
test('connectivity-report with cold_start records a reboot event', () => {
const db = freshDb();
const { reason, type, detail } = classifyConnectivity({ cold_start: true });
db.prepare("INSERT INTO device_events (device_id, type, reason, detail) VALUES (?, ?, ?, ?)")
.run('dev2', type, reason, detail);
const ev = db.prepare("SELECT type, reason FROM device_events WHERE device_id = 'dev2'").get();
assert.equal(ev.type, 'reboot');
assert.equal(ev.reason, 'reboot');
});