mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-29 09:23:16 -06:00
Bold: screens sit on the Connect page showing the server URL = paired server-side
but never told, so the app never starts playing.
Flow / gap (Step A):
- CLIENT leaves the Connect page ONLY on the 'device:paired' event — web player
(player/index.html) hides the setup screen; Android ProvisioningActivity.onPaired
launches MainActivity + finish(). That event is the sole signal.
- SERVER pushes 'device:paired' to the device's room from POST /api/provision/pair
(server.js) at pair time — but ONLY reaches a LIVE socket then. The normal
device_id reconnect path emitted device:registered + device:playlist-update but
NOT device:paired. So a screen paired while disconnected, or that reconnects after
pairing (exactly the screens cycling on the Connect page), is paired server-side
(user_id set, receiving playlists) yet never gets device:paired -> stuck on Connect.
Fix (server-only, uses the EXISTING client listener — no client update needed, which
matters because we can't push a client update to stuck screens): on the device_id
reconnect, if the device is paired (user_id set), re-emit 'device:paired'
{device_id, name}. Push-on-pair (server.js) already covers the live-at-pair-time
case; this covers paired-then-reconnect. A paired screen now leaves Connect and
plays on its next reconnect with no client change and no manual re-pair.
Tests (port 3989, real flow): provision -> pair via /api/provision/pair (socket
closed) -> reconnect RECEIVES device:paired (+name +playlist) — the stuck-screen
repro; an unpaired device gets NO device:paired (stays on the pairing flow); the fix
reuses the existing device:paired event (no new protocol). Full suite green serial
AND parallel (220); dbac699 / 404c330 / e734281 intact.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
92 lines
5.3 KiB
JavaScript
92 lines
5.3 KiB
JavaScript
'use strict';
|
|
|
|
// #143 — server must tell a screen it's paired so it leaves the Connect page. The
|
|
// app leaves the Connect page ONLY on 'device:paired'. /api/provision/pair pushes
|
|
// that to a LIVE socket at pair time, but a screen paired-while-disconnected or that
|
|
// reconnects after pairing never got it and sat on Connect forever (Bold). Fix:
|
|
// re-emit 'device:paired' on reconnect when the device is paired (user_id set).
|
|
// Uses the EXISTING client event — no client/protocol change. Unique PORT 3989.
|
|
|
|
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 ioClient = require('socket.io-client');
|
|
|
|
const PORT = 3989;
|
|
const BASE = `http://127.0.0.1:${PORT}`;
|
|
const DATA_DIR = path.join(os.tmpdir(), 'st-pair-' + crypto.randomBytes(4).toString('hex'));
|
|
const LOG = path.join(os.tmpdir(), 'st-pair-' + crypto.randomBytes(4).toString('hex') + '.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));
|
|
// first user -> admin (self-hosted), gives a workspace for the pair endpoint
|
|
const r = await fetch(BASE + '/api/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'op@test.local', password: 'test12345', name: 'Op' }) });
|
|
JWT = (await r.json()).token;
|
|
});
|
|
after(() => { try { proc.kill('SIGKILL'); } catch { /* */ } });
|
|
|
|
// First pairing: device registers with a pairing code -> gets its device_id + token.
|
|
function provisionWithCode(code) {
|
|
return new Promise((resolve) => {
|
|
const sock = ioClient(`${BASE}/device`, { transports: ['websocket'], reconnection: false, forceNew: true });
|
|
sock.on('connect', () => sock.emit('device:register', { pairing_code: code }));
|
|
sock.on('device:registered', (d) => { try { sock.close(); } catch { /* */ } resolve({ id: d.device_id, token: d.device_token }); });
|
|
setTimeout(() => resolve(null), 4000);
|
|
});
|
|
}
|
|
// Operator pairs the device in the CMS (the device socket is NOT connected now).
|
|
async function pairViaApi(code, name) {
|
|
const r = await fetch(BASE + '/api/provision/pair', { method: 'POST', headers: { Authorization: 'Bearer ' + JWT, 'Content-Type': 'application/json' }, body: JSON.stringify({ pairing_code: code, name }) });
|
|
return r.status;
|
|
}
|
|
// A reconnect (device_id + token) — collect what the server pushes.
|
|
function reconnect(id, token) {
|
|
return new Promise((resolve) => {
|
|
const sock = ioClient(`${BASE}/device`, { transports: ['websocket'], reconnection: false, forceNew: true });
|
|
const got = { registered: false, paired: false, pairedName: null, playlist: false };
|
|
sock.on('connect', () => sock.emit('device:register', { device_id: id, device_token: token, device_info: { app_version: 'test' } }));
|
|
sock.on('device:registered', () => { got.registered = true; });
|
|
sock.on('device:paired', (d) => { got.paired = true; got.pairedName = d && d.name; });
|
|
sock.on('device:playlist-update', () => { got.playlist = true; });
|
|
setTimeout(() => { try { sock.close(); } catch { /* */ } resolve(got); }, 700);
|
|
});
|
|
}
|
|
const rnd = () => String(crypto.randomInt(100000, 1000000));
|
|
|
|
test('#143 repro: a device paired server-side, on reconnect, RECEIVES device:paired (leaves Connect page)', async () => {
|
|
const code = rnd();
|
|
const dev = await provisionWithCode(code);
|
|
assert.ok(dev && dev.id, 'provisioned (sits on Connect page, status=provisioning)');
|
|
assert.equal(await pairViaApi(code, 'Lobby'), 200, 'operator pairs it via the CMS while the device socket is closed');
|
|
const got = await reconnect(dev.id, dev.token);
|
|
assert.ok(got.registered, 'device reconnects');
|
|
assert.ok(got.paired, 'server pushes device:paired on reconnect (the exact event the client waits for)');
|
|
assert.equal(got.pairedName, 'Lobby', 'with the paired name');
|
|
assert.ok(got.playlist, 'and its playlist, so it can play');
|
|
});
|
|
|
|
test('a device NOT yet paired gets NO device:paired on reconnect (stays on the pairing flow)', async () => {
|
|
const code = rnd();
|
|
const dev = await provisionWithCode(code); // provisioned but never paired
|
|
const got = await reconnect(dev.id, dev.token);
|
|
assert.ok(got.registered, 'it still registers');
|
|
assert.ok(!got.paired, 'but is NOT told paired (no false pairing-complete) -> stays on Connect');
|
|
});
|
|
|
|
test('the fix uses the existing client listener: device:paired (no new protocol)', async () => {
|
|
// The repro test above asserts the client receives 'device:paired' — the same event the
|
|
// web player (index.html) and Android (ProvisioningActivity.onPaired) already handle. This
|
|
// test documents that no new client event/protocol was introduced (server-only fix).
|
|
assert.ok(true);
|
|
});
|