screentinker/server/test/heartbeat-deleted-device.test.js
ScreenTinker 7747d7e051 Put Members in the nav, reveal titles on touch, and stop a stale heartbeat killing a socket
Three loose ends from the interface review.

Inviting a colleague is a core action and had no entry in the navigation at
all. The only route was an unlabelled icon beside the workspace name, or typing
the URL. There is now a Members item, translated, which resolves to the active
workspace so the static link needs no id. The Teams entry it sits near stays
hidden, since that feature is still switched off.

A native title= is hover-only, so the icon-only buttons — rename a wall, remove
a device from one, manage members — explained themselves on a desktop and said
nothing on a touchscreen. Long-pressing one now shows its label. The text was
already there and already translated; it simply had no way to reach a finger.

The last one is the bug that took a real screen dark. A device row can vanish
while its socket is still heartbeating, and the telemetry insert then failed a
foreign key. That throw was fatal in a way that is hard to guess: the
safe-socket wrapper reads a throwing handler as a broken one and disconnects
the socket server-side, and socket.io deliberately does not retry that kind of
disconnect — so the player sat doing nothing until a person reloaded it. A
heartbeat for a device that no longer exists is an ordinary race, not a fault
worth ending a connection over; the write is skipped and the register path
answers unpaired, which is the reply that actually helps the client recover.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 20:00:35 -05:00

113 lines
5 KiB
JavaScript

'use strict';
// A device row can vanish while its socket is still open — an operator deletes it, or a re-pair
// replaces it. The next heartbeat carrying telemetry then failed the foreign key on
// device_telemetry, and that throw was fatal in a way nobody would guess:
//
// the safe-socket wrapper treats a throwing handler as a broken one and disconnects the socket
// SERVER-side -> socket.io does NOT auto-retry an 'io server disconnect' -> the player
// sits there doing nothing until a human reloads the page.
//
// That is not theoretical. Deleting a device row mid-session took a real screen dark, and it
// needed someone at the other end to press reload. A heartbeat for a device that no longer
// exists is an ordinary race, not a fault worth ending a connection over.
const { test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const path = require('node:path');
const os = require('node:os');
const fs = require('node:fs');
const crypto = require('node:crypto');
const Database = require('better-sqlite3');
const ioClient = require('../node_modules/socket.io-client');
const { freePort } = require('./helpers/free-port');
let PORT, BASE, proc, db;
const DATA_DIR = path.join(os.tmpdir(), 'st-hbdel-' + crypto.randomBytes(4).toString('hex'));
const LOG = path.join(os.tmpdir(), 'st-hbdel-' + crypto.randomBytes(4).toString('hex') + '.log');
const TELEMETRY = { battery_level: 80, battery_charging: true, storage_free_mb: 100, storage_total_mb: 200,
ram_free_mb: 50, ram_total_mb: 100, cpu_usage: 5, wifi_ssid: 'x', wifi_rssi: -50, uptime_seconds: 60 };
before(async () => {
PORT = await freePort();
BASE = `http://127.0.0.1:${PORT}`;
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 new Promise(r => setTimeout(r, 250));
}
if (!up) throw new Error('server did not boot:\n' + fs.readFileSync(LOG, 'utf8').slice(-2000));
db = new Database(path.join(DATA_DIR, 'db', 'remote_display.db'));
});
after(() => { try { db && db.close(); } catch { /* */ } try { proc.kill('SIGKILL'); } catch { /* */ } });
function makeDevice() {
const id = crypto.randomUUID();
const token = crypto.randomBytes(32).toString('hex');
db.prepare(`INSERT INTO devices (id, name, status, device_token, created_at)
VALUES (?, 'HB', 'online', ?, strftime('%s','now'))`).run(id, token);
return { id, token };
}
function connect(dev) {
return new Promise((resolve, reject) => {
const s = ioClient(BASE + '/device', { transports: ['websocket'], reconnection: false });
s.on('connect', () => s.emit('device:register', { device_id: dev.id, device_token: dev.token }));
s.on('device:registered', () => resolve(s));
s.on('device:auth-error', (e) => reject(new Error(e && e.error)));
setTimeout(() => reject(new Error('register timeout')), 10000);
});
}
const wait = (ms) => new Promise(r => setTimeout(r, ms));
test('a heartbeat for a device deleted mid-session does NOT drop the socket', async () => {
const dev = makeDevice();
const s = await connect(dev);
s.emit('device:heartbeat', { device_id: dev.id, telemetry: TELEMETRY });
await wait(400);
assert.equal(s.connected, true, 'sanity: healthy heartbeat keeps the socket');
// The row disappears underneath the live socket.
db.prepare('DELETE FROM devices WHERE id = ?').run(dev.id);
s.emit('device:heartbeat', { device_id: dev.id, telemetry: TELEMETRY });
await wait(700);
assert.equal(s.connected, true,
'the socket survives — before this, the FK threw, the server disconnected it, and socket.io '
+ 'would not retry, so the screen stayed dark until someone reloaded it');
s.close();
});
test('and the server stays up rather than logging a foreign-key failure', async () => {
const log = fs.readFileSync(LOG, 'utf8');
assert.doesNotMatch(log, /FOREIGN KEY constraint failed/,
'no constraint error was raised at all');
assert.doesNotMatch(log, /handler threw for/, 'and no handler was reported as broken');
});
test('telemetry for a LIVE device is still recorded', async () => {
const dev = makeDevice();
const s = await connect(dev);
s.emit('device:heartbeat', { device_id: dev.id, telemetry: TELEMETRY });
await wait(600);
const n = db.prepare('SELECT COUNT(*) c FROM device_telemetry WHERE device_id = ?').get(dev.id).c;
assert.ok(n >= 1, 'the guard must not have turned telemetry off for everyone');
s.close();
});
test('a heartbeat with no telemetry is unaffected', async () => {
const dev = makeDevice();
const s = await connect(dev);
s.emit('device:heartbeat', { device_id: dev.id });
await wait(400);
assert.equal(s.connected, true);
s.close();
});