'use strict'; // Reader settings per login session: a session follows the latest set until it changes one, then keeps its own. const test = require('node:test'), assert = require('node:assert/strict'); const fs = require('fs'), os = require('os'), path = require('path'), net = require('net'); const { spawn } = require('child_process'); const readerSessions = require('../reader-sessions'); async function freePort() { const listener = net.createServer(); await new Promise(resolve => listener.listen(0, '127.0.0.1', resolve)); const port = listener.address().port; await new Promise(resolve => listener.close(resolve)); return port; } test('device labels name browser and system', () => { assert.equal(readerSessions.deviceLabel('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Version/17.0 Mobile/15E148 Safari/604.1'), 'Safari on iPhone'); assert.equal(readerSessions.deviceLabel('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/130.0 Safari/537.36 Edg/130.0'), 'Edge on Windows'); assert.equal(readerSessions.deviceLabel(''), 'Unknown device'); }); test('sessions lock their reader settings on the first change, new ones copy the last active one', { timeout: 20000 }, async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-reader-sessions-')); let child; try { const library = path.join(root, 'library'), data = path.join(root, 'data'); fs.mkdirSync(library); fs.mkdirSync(data); const port = await freePort(), output = []; child = spawn(process.execPath, ['server.js'], { cwd: path.resolve(__dirname, '..'), windowsHide: true, env: { ...process.env, LIBRARY: library, DATA: data, PORT: String(port), SCAN_INTERVAL: '0', FULL_SCAN_INTERVAL: '0', SCRAPER_URL: 'http://127.0.0.1:9', SCRAPER_TOKEN: 'fixture-token', SCRAPER_TOKEN_FILE: '', BACKUP: '', GREATEST_COVER_WARM: '0' } }); child.stdout.on('data', d => output.push(String(d))); child.stderr.on('data', d => output.push(String(d))); const base = `http://127.0.0.1:${port}`; let ready = false; for (let n = 0; n < 100 && !ready; n++) { ready = await fetch(base + '/health').then(r => r.ok).catch(() => false); if (!ready) await new Promise(resolve => setTimeout(resolve, 50)); } assert.ok(ready, output.join('')); const json = async r => { const body = await r.json(); assert.equal(r.status, 200, JSON.stringify(body)); return body; }; const post = (cookie, url, body) => fetch(base + url, { method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) Firefox/130.0', ...(cookie ? { cookie } : {}) }, body: JSON.stringify(body) }).then(async r => (url.endsWith('login') || url.endsWith('signup') ? r : json(r))); const me = async cookie => (await json(await fetch(base + '/api/me', { headers: { cookie } }))).settings; const overview = cookie => fetch(base + '/api/reader-sessions', { headers: { cookie } }).then(json); const login = async () => (await post(null, '/api/login', { name: 'reader', password: 'reader-password' })).headers.get('set-cookie').split(';')[0]; const a = (await post(null, '/api/signup', { name: 'reader', password: 'reader-password' })).headers.get('set-cookie').split(';')[0]; // First session: defaults, following the latest set. assert.equal((await me(a)).lastMode, 'page'); assert.equal((await overview(a)).sessions[0].locked, false); // Session B exists before A changes anything: it follows A's change. const b = await login(); await post(a, '/api/settings', { lastMode: 'flow', zoom: { flow: 1.5 } }); assert.equal((await me(b)).lastMode, 'flow'); assert.equal((await me(b)).zoom.flow, 1.5); // B changes its own: B locks, A keeps its own set. await post(b, '/api/settings', { pageTone: 'sepia' }); assert.equal((await me(b)).pageTone, 'sepia'); assert.equal((await me(b)).lastMode, 'flow'); assert.equal((await me(a)).pageTone, 'original'); // A non-reader setting is shared and does not lock anything. const c = await login(); await post(c, '/api/settings', { continueRow: false }); assert.equal((await me(a)).continueRow, false); assert.equal((await overview(c)).sessions.find(s => s.current).locked, false); // C follows the last active session, which is A (it loaded /api/me last). assert.equal((await me(c)).pageTone, 'original'); await me(b); assert.equal((await me(c)).pageTone, 'sepia'); // Overview: three sessions, labels, intent. const o = await overview(a); assert.equal(o.enabled, true); assert.equal(o.sessions.length, 3); assert.equal(o.sessions[0].current, true); assert.equal(o.sessions[0].device, 'Firefox on Linux'); assert.deepEqual(o.sessions.map(s => s.locked).sort(), [false, true, true]); // "Follow latest again" drops A's lock; "Use for new sessions" makes a set the latest. const idA = o.sessions[0].id, idB = o.sessions.find(s => !s.current && s.locked).id; await post(a, '/api/reader-sessions', { id: idA, action: 'follow' }); assert.equal((await me(a)).pageTone, 'sepia'); assert.equal((await fetch(base + '/api/reader-sessions', { method: 'POST', headers: { 'content-type': 'application/json', cookie: a }, body: JSON.stringify({ id: 'nope', action: 'follow' }) })).status, 404); assert.equal((await post(a, '/api/reader-sessions', { id: idB, action: 'latest' })).latestFrom, idB); // Off: one shared set; a change applies everywhere and locks nothing. On again: kept locks return. await post(b, '/api/settings', { readerPerSession: false }); await post(a, '/api/settings', { pageTone: 'dim' }); assert.equal((await me(b)).pageTone, 'dim'); assert.equal((await me(c)).pageTone, 'dim'); await post(a, '/api/settings', { readerPerSession: true }); assert.equal((await me(b)).pageTone, 'sepia'); assert.equal((await me(a)).pageTone, 'sepia'); } finally { if (child && child.exitCode === null) { child.kill(); await new Promise(resolve => child.once('exit', resolve)); } fs.rmSync(root, { recursive: true, force: true }); } });