#!/usr/bin/env node // Plex sign-in: does it stay signed in, and does it repair itself? Uses a fake plex.tv and a fake Plex server. // Run: node tools/plex-test.js const { spawn } = require('child_process'); const http = require('http'); const fs = require('fs'), os = require('os'), path = require('path'), crypto = require('crypto'); const root = path.join(__dirname, '..'); let failed = 0; const ok = (n, c, x) => { console.log((c ? 'PASS ' : 'FAIL ') + n + (c ? '' : ' -> ' + JSON.stringify(x))); if (!c) failed++; }; const wait = ms => new Promise(r => setTimeout(r, ms)); const SECRET = 'plex-test-secret-'.repeat(3), UID = 'uplex01'; const listen = h => new Promise(res => { const s = http.createServer(h); s.listen(0, '127.0.0.1', () => res(s)); }); const jsonOut = (rs, code, o) => { rs.writeHead(code, { 'content-type': 'application/json' }); rs.end(JSON.stringify(o)); }; (async () => { // ---- fake plex.tv const tv = { adminPin: false, switchCalls: [], homeJson: false, accountToken: 'ACCOUNT', serverToken: 'SRV1', plexPort: 0, pinPolls: 0, resourceCalls: 0 }; const plexTv = await listen((rq, rs) => { const u = new URL(rq.url, 'http://x'); let m; if (rq.method === 'POST' && u.pathname === '/api/v2/pins') return jsonOut(rs, 201, { id: 42, code: 'ABCD' }); if (u.pathname === '/api/v2/pins/42') return jsonOut(rs, 200, { authToken: ++tv.pinPolls >= 2 ? tv.accountToken : null }); if (u.pathname === '/api/home/users' && rq.method === 'GET') { if (!['ACCOUNT'].includes(rq.headers['x-plex-token'])) { rs.writeHead(401); return rs.end(); } if (tv.homeJson) return jsonOut(rs, 200, { users: [{ id: 1, title: 'Nate', admin: true, protected: tv.adminPin, thumb: 'https://plex.example/a.png' }, { id: 2, title: 'Kid', admin: false, protected: true, restricted: true }, { id: 3, title: 'Guest', admin: false, protected: false }] }); rs.writeHead(200, { 'content-type': 'application/xml' }); return rs.end(''); } if ((m = /^\/api\/home\/users\/(\d+)\/switch$/.exec(u.pathname)) && rq.method === 'POST') { tv.switchCalls.push(m[1] + ':' + (u.searchParams.get('pin') || '') + ':' + rq.headers['x-plex-token']); if (m[1] === '1' && u.searchParams.get('pin') !== '9999') { rs.writeHead(403); return rs.end('bad pin'); } if (m[1] === '2' && u.searchParams.get('pin') !== '1234') { rs.writeHead(403); return rs.end('bad pin'); } rs.writeHead(200, { 'content-type': 'application/xml' }); return rs.end(``); } if (u.pathname === '/api/v2/resources') { tv.resourceCalls++; const who = rq.headers['x-plex-token'], other = { KIDTOKEN: 'KIDSRV', GUESTTOKEN: 'GUESTSRV' }[who]; if (who !== tv.accountToken && !other) return jsonOut(rs, 401, { error: 'bad token' }); return jsonOut(rs, 200, [{ name: 'E-va', clientIdentifier: 'sid1', provides: 'server', owned: who === tv.accountToken, accessToken: other || tv.serverToken, connections: [{ uri: `http://127.0.0.1:${tv.plexPort}`, local: false, relay: false, address: '127.0.0.1', port: tv.plexPort }] }]); } rs.writeHead(404); rs.end(); }); // ---- fake Plex server: /identity is open; everything else needs the server token const seen = [], plexReqs = []; const makePlex = () => listen((rq, rs) => { if (rq.url === '/identity') return jsonOut(rs, 200, { MediaContainer: { machineIdentifier: 'sid1' } }); plexReqs.push(rq.method + ' ' + rq.url); if (rq.url.startsWith('/video/:/transcode/universal/start.m3u8')) { rs.writeHead(200, { 'content-type': 'application/vnd.apple.mpegurl' }); return rs.end('#EXTM3U\n#EXT-X-MAP:URI="/video/:/transcode/universal/session/S/init.mp4"\n#EXTINF:4,\nsession/S/seg0.ts\n#EXTINF:4,\n/video/:/transcode/universal/session/S/seg1.ts\n#EXT-X-ENDLIST\n'); } if (rq.url.startsWith('/library/parts/1/2/file.mp4')) { const rg = rq.headers.range; if (rg) { rs.writeHead(206, { 'content-type': 'video/mp4', 'content-range': 'bytes 0-9/100', 'accept-ranges': 'bytes', 'content-length': '10' }); return rs.end('0123456789'); } rs.writeHead(200, { 'content-type': 'video/mp4', 'accept-ranges': 'bytes' }); return rs.end('x'.repeat(100)); } seen.push(rq.headers['x-plex-token']); if (![tv.serverToken, 'KIDSRV', 'GUESTSRV'].includes(rq.headers['x-plex-token'])) { rs.writeHead(401); return rs.end(); } jsonOut(rs, 200, { MediaContainer: { Directory: [{ key: '1', type: 'artist', title: 'Music' }] } }); }); let plex = await makePlex(); tv.plexPort = plex.address().port; // ---- the hub, with one admin account already in place const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-plex-')), port = 20000 + Math.floor(Math.random() * 20000), base = `http://localhost:${port}`; fs.writeFileSync(path.join(dir, 'users.json'), JSON.stringify({ users: [{ id: UID, name: 'Admin', role: 'admin', tabs: [], handle: 'aGFuZGxl', epoch: 1, disabled: false, created: Date.now() }] })); const exp = Date.now() + 864e5, cookie = `hub_session=${UID}.1.${exp}.${crypto.createHmac('sha256', SECRET).update(`s${UID}.1.${exp}`).digest('base64url')}`; const startHub = () => { const c = spawn(process.execPath, ['server.js'], { cwd: root, env: { ...process.env, PORT: String(port), DATA_DIR: dir, COOKIE_SECRET: SECRET, PLEX_TV_URL: `http://127.0.0.1:${plexTv.address().port}` } }); const h = { child: c, log: '' }; c.stdout.on('data', d => h.log += d); c.stderr.on('data', d => h.log += d); return h; }; const ready = async () => { for (let i = 0; i < 60; i++) { try { await fetch(base + '/login'); return; } catch { await wait(100); } } throw new Error('hub did not start'); }; const call = async (p, body, method) => { const r = await fetch(base + p, { method: method || (body ? 'POST' : 'GET'), headers: { cookie, 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined }); let j = {}; try { j = JSON.parse(await r.text()); } catch {} return { status: r.status, json: j }; }; // the saved file is encrypted; decrypt it the same way the server does so the test can look inside const savedPlex = () => { try { const b = Buffer.from(JSON.parse(fs.readFileSync(path.join(dir, 'users', UID, 'plex.json'), 'utf8')).data, 'base64'), d = crypto.createDecipheriv('aes-256-gcm', crypto.scryptSync(SECRET, 'nehub-plex', 32), b.subarray(0, 12)); d.setAuthTag(b.subarray(12, 28)); return JSON.parse(Buffer.concat([d.update(b.subarray(28)), d.final()]).toString()); } catch { return null; } }; let hub = startHub(); await ready(); // ================= signing in, and staying signed in ================= let r, r2; r = await call('/api/plex/status'); ok('before signing in: not signed in', r.json.signedIn === false, r.json); r = await call('/api/plex/pin', {}); ok('starting a sign-in gives a code and a plex.tv link', r.json.id === 42 && /app\.plex\.tv\/auth/.test(r.json.authUrl), r.json); r = await call('/api/plex/pin?id=42&code=ABCD'); ok('while the person has not approved yet: not ready', r.json.ok === false, r.json); r = await call('/api/plex/pin?id=42&code=ABCD'); ok('after they approve: signed in', r.json.ok === true, r.json); r = await call('/api/plex/status'); ok('status: signed in, no server chosen yet', r.json.signedIn === true && r.json.server === null, r.json); r = await call('/api/plex/servers'); ok('their servers are listed', r.json.length === 1 && r.json[0].name === 'E-va' && r.json[0].id === 'sid1', r.json); r = await call('/api/plex/server', { id: 'sid1' }); ok('choosing the server connects', r.status === 200 && r.json.ok, r); let s = savedPlex(); ok('everything needed to stay signed in is saved (account sign-in, server, its token, its id)', s && s.token === 'ACCOUNT' && s.stk === 'SRV1' && s.sid === 'sid1' && s.name === 'E-va' && s.srv.startsWith('http://127.0.0.1:'), s); ok('and it is encrypted on disk', !fs.readFileSync(path.join(dir, 'users', UID, 'plex.json'), 'utf8').includes('ACCOUNT')); r = await call('/api/plex/pin?id=42&code=ABCD'); r = await call('/api/plex/status'); ok('polling the sign-in again later does not wipe the chosen server', r.json.signedIn && r.json.server === 'E-va', r.json); const tvBefore = tv.resourceCalls; r = await call('/plex/library/sections'); ok('Plex pages load through the hub', r.status === 200 && r.json.MediaContainer.Directory[0].title === 'Music', r); ok('a healthy sign-in makes no extra calls to plex.tv', tv.resourceCalls === tvBefore, [tv.resourceCalls, tvBefore]); hub.child.kill(); await wait(400); hub = startHub(); await ready(); r = await call('/api/plex/status'); ok('AFTER A RESTART: still signed in, same server', r.json.signedIn === true && r.json.server === 'E-va', r.json); r = await call('/plex/library/sections'); ok('AFTER A RESTART: Plex pages still load', r.status === 200, r.status); // ================= playlists can be changed, nothing else can ================= plexReqs.length = 0; r = await call('/plex/playlists?type=audio&smart=0&title=Road&uri=x', undefined, 'POST'); ok('a playlist can be created through the hub', r.status === 200 && plexReqs.includes('POST /playlists?type=audio&smart=0&title=Road&uri=x'), [r.status, plexReqs]); r = await call('/plex/playlists/7/items?uri=x', undefined, 'PUT'); ok('and have tracks added', r.status === 200 && plexReqs.includes('PUT /playlists/7/items?uri=x'), [r.status, plexReqs]); r = await call('/plex/playlists/7/items/55', undefined, 'DELETE'); ok('and have a track removed', r.status === 200 && plexReqs.includes('DELETE /playlists/7/items/55'), [r.status, plexReqs]); r = await call('/plex/playlists/7', undefined, 'DELETE'); ok('and be deleted', r.status === 200 && plexReqs.includes('DELETE /playlists/7'), [r.status, plexReqs]); plexReqs.length = 0; const bad = [['DELETE', '/plex/library/metadata/5'], ['PUT', '/plex/library/sections/1/refresh'], ['POST', '/plex/playlists/7/../library/metadata/5'], ['DELETE', '/plex/library/sections/1']]; const refused = []; for (const [m, p] of bad) { const x = await call(p, undefined, m); refused.push(x.status); } ok('but nothing else on the Plex server can be changed through the hub', refused.every(x => x >= 400) && !plexReqs.length, [refused, plexReqs]); // ---- editing a title's details (what Plex's Edit dialog has) and choosing its poster r = await call('/api/plex/status'); ok('the owner is told they can edit details', r.json.canEdit === true, r.json); const E = '/plex/library/sections/1/all?type=1&id=5&title.value=' + encodeURIComponent('New & Title') + '&title.locked=1&summary.value=Hi&summary.locked=1&genre%5B0%5D.tag.tag=Drama&genre%5B1%5D.tag.tag=Comedy&genre.locked=1&originallyAvailableAt.value=2020-01-02&originallyAvailableAt.locked=1'; plexReqs.length = 0; r = await call(E, undefined, 'PUT'); ok('the owner can save a title\'s details, locked so a refresh keeps them', r.status === 200 && plexReqs.length === 1 && plexReqs[0].startsWith('PUT /library/sections/1/all?type=1&id=5&title.value=New') && plexReqs[0].includes('title.locked=1'), [r.status, plexReqs]); plexReqs.length = 0; r = await call('/plex/library/metadata/5/poster?url=' + encodeURIComponent('/library/metadata/5/thumb/123'), undefined, 'PUT'); ok('and pick one of its posters', r.status === 200 && plexReqs.length === 1, [r.status, plexReqs]); plexReqs.length = 0; r = await call('/plex/library/metadata/5/posters?url=' + encodeURIComponent('https://img.example/p.jpg'), undefined, 'POST'); ok('or add one from an image link', r.status === 200 && plexReqs.length === 1, [r.status, plexReqs]); plexReqs.length = 0; const badEdit = [['PUT', '/plex/library/sections/1/all?type=1&id=5&mediaType=9&title.value=x'], ['PUT', '/plex/library/sections/1/all?type=1&id=5&deleteAll=1'], ['PUT', '/plex/library/sections/1/all?type=1&title.value=x'], ['PUT', '/plex/library/sections/1/all?type=9&id=5'], ['DELETE', '/plex/library/sections/1/all?type=1&id=5&title.value=x'], ['PUT', '/plex/library/metadata/5/poster?url=file:///etc/passwd'], ['PUT', '/plex/library/metadata/5/poster?url=http://10.0.0.1/x.jpg'], ['PUT', '/plex/library/metadata/5/poster?url=https://a.example/x.jpg&extra=1'], ['POST', '/plex/library/metadata/5/poster?url=https://a.example/x.jpg'], ['PUT', '/plex/library/metadata/5/posters?url=https://a.example/x.jpg']]; const bst = []; for (const [m, p] of badEdit) bst.push((await call(p, undefined, m)).status); ok('anything else in an edit request is refused before it reaches Plex', bst.every(x => x >= 400) && !plexReqs.length, [bst, plexReqs]); // ================= Plex Home: using Plex as someone else in the Home ================= r = await call('/api/plex/users'); ok('the Plex Home is listed (read from plex.tv\'s XML), with who is using NE Hub now', r.status === 200 && r.json.users.length === 3 && r.json.users.find(x => x.id === '1').current && r.json.users.find(x => x.id === '2').protected && r.json.users.find(x => x.id === '2').restricted && r.json.users.find(x => x.id === '1').admin, r.json); tv.homeJson = true; r = await call('/api/plex/users'); ok('and the same when plex.tv answers JSON', r.json.users.length === 3 && r.json.users[1].title === 'Kid', r.json); tv.homeJson = false; r = await call('/api/plex/switch', { id: '2' }); ok('a protected person needs their PIN', r.status === 403 && /PIN/.test(r.json.error), r); r = await call('/api/plex/switch', { id: '2', pin: '0000' }); ok('a wrong PIN is refused', r.status === 403, r); r = await call('/api/plex/status'); ok('and nothing changed after the failures', r.json.user === null || r.json.user.title === 'Nate', r.json); r = await call('/api/plex/switch', { id: '99' }); ok('someone who is not in the Home is a 404', r.status === 404, r); seen.length = 0; r = await call('/api/plex/switch', { id: '2', pin: '1234' }); ok('with the right PIN the hub switches to that person', r.status === 200 && r.json.user.title === 'Kid', r); r = await call('/api/plex/status'); ok('status says who is using Plex now', r.json.user && r.json.user.title === 'Kid' && r.json.signedIn && r.json.server === 'E-va', r.json); r = await call('/plex/library/sections'); ok('Plex requests now use that person\'s own server token', r.status === 200 && seen[seen.length - 1] === 'KIDSRV', [r.status, seen]); plexReqs.length = 0; r = await call('/api/plex/status'); const ke = await call(E, undefined, 'PUT'); ok('a Home member who is not the owner cannot edit (no Edit button, and the hub refuses)', r.json.canEdit === false && ke.status >= 400 && !plexReqs.length, [r.json, ke.status, plexReqs]); s = savedPlex(); ok('the owner\'s sign-in is kept (to switch back, and to list the Home) and the new one is saved', s && s.mainToken === 'ACCOUNT' && s.token === 'KIDTOKEN' && s.stk === 'KIDSRV' && s.user.title === 'Kid', s); r = await call('/api/plex/users'); ok('the list still works as the owner and marks the new current person', r.json.users.find(x => x.id === '2').current && !r.json.users.find(x => x.id === '1').current, r.json); r = await call('/api/plex/switch', { id: '3' }); ok('switching straight to another person works (no PIN needed for them)', r.status === 200 && r.json.user.title === 'Guest', r); ok('that switch was asked with the owner\'s token, not the first person\'s', tv.switchCalls[tv.switchCalls.length - 1] === '3::ACCOUNT', tv.switchCalls); tv.switchCalls.length = 0; r = await call('/api/plex/switch', { id: '1' }); r = await call('/api/plex/status'); r2 = await call('/plex/library/sections'); ok('switching back to the owner needs no PIN and no call to plex.tv', r.json.user.title === 'Nate' && tv.switchCalls.length === 0 && r2.status === 200 && seen[seen.length - 1] === 'SRV1', [r.json, tv.switchCalls, seen.slice(-2)]); s = savedPlex(); ok('and uses the owner\'s own tokens again', s.token === 'ACCOUNT' && s.stk === 'SRV1', s); // the owner has a PIN in Plex: the hub asks for it too, plex.tv checks it, and guessing is slowed down tv.adminPin = true; await call('/api/plex/switch', { id: '3' }); r = await call('/api/plex/users'); ok('an owner with a PIN is marked protected in the list', r.json.users.find(x => x.id === '1').protected === true, r.json); tv.switchCalls.length = 0; r = await call('/api/plex/switch', { id: '1' }); ok('going back to the owner asks for the PIN', r.status === 403 && /PIN/.test(r.json.error) && !tv.switchCalls.length, [r, tv.switchCalls]); r = await call('/api/plex/switch', { id: '1', pin: '1111' }); ok('a wrong owner PIN is refused by plex.tv and nothing changes', r.status === 403 && (await call('/api/plex/status')).json.user.title === 'Guest', r); r = await call('/api/plex/switch', { id: '1', pin: '9999' }); ok('the right owner PIN switches back, and the wrong-guess count starts over', r.status === 200 && r.json.user.title === 'Nate', r); await call('/api/plex/switch', { id: '3' }); tv.switchCalls.length = 0; r = await call('/api/plex/switch', { id: '1', pin: '1111' }); for (let i = 0; i < 4; i++) r = await call("/api/plex/switch", { id: "1", pin: "2222" }); r = await call('/api/plex/switch', { id: '1', pin: '9999' }); ok('after five wrong guesses even the right PIN waits', r.status === 429 && /Try again/.test(r.json.error), r); ok('and plex.tv is not asked while waiting', tv.switchCalls.length === 5, tv.switchCalls); tv.adminPin = false; hub.child.kill(); await wait(400); hub = startHub(); await ready(); await call('/api/plex/switch', { id: '2', pin: '1234' }); hub.child.kill(); await wait(400); hub = startHub(); await ready(); r = await call('/api/plex/status'); ok('AFTER A RESTART: still using Plex as the chosen person', r.json.user && r.json.user.title === 'Kid', r.json); r = await call('/plex/library/sections'); ok('AFTER A RESTART: their Plex pages load', r.status === 200, r.status); await call('/api/plex/switch', { id: '1' }); // ================= Movies & TV: the pass-through carries video ================= r = await fetch(base + '/plex/library/parts/1/2/file.mp4', { headers: { cookie, range: 'bytes=0-9' } }); const rb = await r.text(); ok('a video file is streamed with Range, so the browser can seek (206 and the range come back)', r.status === 206 && rb === '0123456789' && r.headers.get('content-range') === 'bytes 0-9/100' && r.headers.get('accept-ranges') === 'bytes', [r.status, r.headers.get('content-range')]); r = await fetch(base + '/plex/video/:/transcode/universal/start.m3u8?path=%2Flibrary%2Fmetadata%2F5&protocol=hls', { headers: { cookie } }); const pl = await r.text(); ok('a converted-video playlist keeps its addresses under /plex (relative ones untouched, absolute ones prefixed)', r.status === 200 && /mpegurl/.test(r.headers.get('content-type')) && pl.includes('URI="/plex/video/:/transcode/universal/session/S/init.mp4"') && pl.includes('\nsession/S/seg0.ts\n') && pl.includes('\n/plex/video/:/transcode/universal/session/S/seg1.ts\n'), pl); r = await fetch(base + '/plex/:/scrobble?key=5&identifier=com.plexapp.plugins.library', { headers: { cookie } }); ok('marking something watched (a GET) is allowed through', r.status === 200, r.status); // ================= repairing itself ================= tv.serverToken = 'SRV2'; const calls1 = tv.resourceCalls; // Plex rotates the server token r = await call('/plex/library/sections'); ok('a changed server token is repaired automatically (the page still loads)', r.status === 200, r.status); s = savedPlex(); ok('and the new token is saved', s.stk === 'SRV2' && s.token === 'ACCOUNT', s); ok('it asked plex.tv exactly once', tv.resourceCalls === calls1 + 1, [tv.resourceCalls, calls1]); r = await call('/plex/library/sections'); ok('the next request needs no repair', r.status === 200 && tv.resourceCalls === calls1 + 1, [r.status, tv.resourceCalls]); const parallel = await Promise.all([1, 2, 3, 4].map(() => call('/plex/library/sections'))); ok('several requests at once all work', parallel.every(x => x.status === 200), parallel.map(x => x.status)); const oldPlex = plex; plex = await makePlex(); tv.plexPort = plex.address().port; oldPlex.close(); await wait(100); // the server moves to a new address r = await call('/plex/library/sections'); ok('a server that moved to a new address is found again', r.status === 200, r.status); s = savedPlex(); ok('and the new address is saved', s.srv === `http://127.0.0.1:${tv.plexPort}`, s.srv); hub.child.kill(); await wait(400); hub = startHub(); await ready(); r = await call('/plex/library/sections'); ok('after another restart, still working on the repaired address', r.status === 200, r.status); // ================= when it genuinely cannot continue ================= tv.accountToken = 'CHANGED'; tv.serverToken = 'SRV3'; // Plex has revoked this account sign-in const calls2 = tv.resourceCalls; r = await call('/plex/library/sections'); ok('if plex.tv rejects the account sign-in, the request fails cleanly (401)', r.status === 401, r.status); r = await call('/api/plex/status'); ok('and the person is shown as signed out, so they are asked to sign in again', r.json.signedIn === false, r.json); ok('only one attempt was made', tv.resourceCalls === calls2 + 1, [tv.resourceCalls, calls2]); // a dead server: do not hammer plex.tv tv.accountToken = 'ACCOUNT'; tv.serverToken = 'SRV1'; await call('/api/plex/pin?id=42&code=ABCD'); r = await call('/api/plex/server', { id: 'sid1' }); ok('signing in again and choosing the server works', r.status === 200, r); plex.close(); await wait(100); const calls3 = tv.resourceCalls; r = await call('/plex/library/sections'); const afterFirst = tv.resourceCalls; r = await call('/plex/library/sections'); r = await call('/plex/library/sections'); ok('with the server down it gives up quickly and does not hammer plex.tv (at most one lookup for three requests)', r.status >= 400 && tv.resourceCalls - calls3 <= 1 && tv.resourceCalls === afterFirst, [r.status, calls3, afterFirst, tv.resourceCalls]); ok('the saved sign-in is kept while the server is simply offline', (savedPlex() || {}).token === 'ACCOUNT', savedPlex()); plex = await makePlex(); tv.plexPort = plex.address().port; r = await call('/api/plex/logout', {}); r = await call('/api/plex/status'); ok('signing out removes everything', r.json.signedIn === false && !fs.existsSync(path.join(dir, 'users', UID, 'plex.json')), r.json); // a sign-in saved by an older version has no server id: it is matched by the server's name and the id is remembered const seal = o => { const iv = crypto.randomBytes(12), c = crypto.createCipheriv('aes-256-gcm', crypto.scryptSync(SECRET, 'nehub-plex', 32), iv); const d = Buffer.concat([c.update(JSON.stringify(o), 'utf8'), c.final()]); return Buffer.concat([iv, c.getAuthTag(), d]).toString('base64'); }; fs.mkdirSync(path.join(dir, 'users', UID), { recursive: true }); fs.writeFileSync(path.join(dir, 'users', UID, 'plex.json'), JSON.stringify({ data: seal({ token: 'ACCOUNT', srv: 'http://127.0.0.1:1', stk: 'STALE', name: 'E-va' }) })); // no sid, dead address, stale token await wait(31000 * 0 + 1); hub.child.kill(); await wait(400); hub = startHub(); await ready(); // a fresh process: no repair pause r = await call('/plex/library/sections'); s = savedPlex(); ok('a sign-in saved by an older version (no server id) repairs itself by server name', r.status === 200 && s.sid === 'sid1' && s.stk === 'SRV1', [r.status, s]); ok('the hub logged what it did (for support)', /\[plex\] (signed in|connected|repaired)/.test(hub.log), hub.log.split('\n').filter(l => /plex/.test(l)).slice(0, 4)); hub.child.kill(); plex.close(); plexTv.close(); fs.rmSync(dir, { recursive: true, force: true }); console.log(failed ? `\n${failed} check(s) failed` : '\nAll Plex checks passed'); process.exit(failed ? 1 : 0); })().catch(e => { console.error('TEST CRASHED:', e); process.exit(1); });