// Real Jellyfin Web E2E. All credentials remain in memory; only isolated test users are changed. const fs = require('node:fs'); const { execFileSync } = require('node:child_process'); const { chromium } = require('C:/Users/michal/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright'); const origin = 'http://192.168.100.191:8096', web = origin + '/web/index.html'; const ssh = code => JSON.parse(execFileSync('ssh', ['-o', 'BatchMode=yes', 'root@192.168.100.25', 'pct exec 100191 -- python3 -'], { input: code, encoding: 'utf8', timeout: 30000 }).trim().split('\n').at(-1)); const admin = `import json,sqlite3,urllib.request,pathlib,secrets c=sqlite3.connect('file:/var/lib/jellyfin/data/jellyfin.db?mode=ro',uri=True);k=c.execute('SELECT AccessToken FROM ApiKeys LIMIT 1').fetchone()[0];c.close() def api(p,d=None,method=None): r=urllib.request.Request('http://127.0.0.1:8096'+p,headers={'Authorization':'MediaBrowser Token="'+k+'", Client="E2E", Device="Maintenance", DeviceId="decoder-e2e", Version="1"','Content-Type':'application/json'},data=json.dumps(d).encode() if d is not None else None,method=method) b=urllib.request.urlopen(r,timeout=20).read();return json.loads(b) if b else None `; const controlOnly = process.argv.includes('--control-only'); const configOnly=process.argv.includes('--config-only'); const report = { checked_utc: new Date().toISOString(), mode: process.argv.includes('--ui-only') || configOnly ? 'ui' : process.argv.includes('--playback-only') || controlOnly ? 'playback' : 'full', checks: [] }; const assert = (condition, message) => { if (!condition) throw Error(message); }; async function check(name, action) { try { const evidence = await action(); report.checks.push({ name, status: 'PASS', evidence }); } catch (e) { report.checks.push({ name, status: 'FAIL', error: e.message.replace(/Token="[^"]+"/g, 'Token=""').slice(0, 350) }); } console.log(name + ': ' + report.checks.at(-1).status); return report.checks.at(-1).status==='PASS'; } const accounts = []; let browser; const pages = [], browserErrors = []; async function api(page, path, method='GET', data) { return page.evaluate(async ({path,method,data}) => { const s = JSON.parse(localStorage.getItem('jellyfin_credentials')).Servers.find(s=>s.AccessToken&&s.UserId); const r = await fetch(path,{method,headers:{Authorization:'MediaBrowser Token="'+s.AccessToken+'", Client="DecoderE2E", Device="Browser", DeviceId="decoder-e2e", Version="1"','Content-Type':'application/json'},body:data===undefined?undefined:JSON.stringify(data)}); const text = await r.text(); return { status:r.status, body:text?JSON.parse(text):null }; }, {path,method,data}); } async function custom(page) { await page.goto(origin+'/DecoderIntegration/Channels'); await page.locator('#channelRows section').first().waitFor({timeout:30000}); } async function native(page) { await page.goto(web+'#/livetv'); await page.waitForURL(/tab=2/,{timeout:15000}); await page.locator('.card[data-type="TvChannel"]').first().waitFor({timeout:15000}); } async function exit(page, escape=false) { if(await page.locator('#decoderPlaybackExit').count()) { if(escape)await page.keyboard.press('Escape'); else await page.locator('#decoderPlaybackExit').click(); } await page.waitForURL(/livetv.*tab=2/,{timeout:30000}); await page.locator('.card[data-type="TvChannel"]').first().waitFor({timeout:15000}); assert(await page.locator('#decoderPlaybackExit').count()===0,'Playback control remains after stopping'); } async function start(page, name, double=false) { await native(page); if(name==='4Fun.TV') { await custom(page);await page.getByRole('button',{name:/^\d+\. 4Fun\.TV$/}).click({clickCount:double?2:1}); await page.locator('#decoderPlaybackExit').waitFor({timeout:15000});return; } const image=page.locator('.card[data-type="TvChannel"]').filter({has:page.locator('a.textActionButton',{hasText:new RegExp('^\\d+ '+name.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')+'$')})}).locator('a.cardImageContainer'); await image.click({position:{x:20,y:20},clickCount:double?2:1}); await page.locator('#decoderPlaybackExit').waitFor({timeout:10000}); } async function frames(page) { await page.waitForFunction(()=>[...document.querySelectorAll('video')].some(v=>v.currentTime>0&&v.readyState>=2&&v.videoWidth>0),{}, {timeout:20000}); const first=await page.locator('video').first().evaluate(v=>({time:v.currentTime,frames:v.getVideoPlaybackQuality?.().totalVideoFrames||v.webkitDecodedFrameCount||0,width:v.videoWidth})); await page.waitForTimeout(3000); const last=await page.locator('video').first().evaluate(v=>({time:v.currentTime,frames:v.getVideoPlaybackQuality?.().totalVideoFrames||v.webkitDecodedFrameCount||0,width:v.videoWidth})); assert(last.time>first.time && last.frames>first.frames,'Video clock or decoded frame counter did not advance'); return { time_progress:Number((last.time-first.time).toFixed(2)), decoded_frames:last.frames-first.frames,width:last.width }; } (async()=>{ try { browser=await chromium.launch({headless:true,channel:'msedge'}); for(let i=0;i<2;i++) { const user=ssh(admin+`n='decoder-e2e-'+secrets.token_hex(4);p=secrets.token_urlsafe(24);u=api('/Users/New',{'Name':n});print(json.dumps({'id':u['Id'],'name':n,'password':p}))`); accounts.push(user); ssh(admin+`api('/Users/${user.id}/Password',{'CurrentPw':'','NewPw':${JSON.stringify(user.password)}});print('{}')`); const context=await browser.newContext({viewport:{width:1280,height:900}}),page=await context.newPage();pages.push(page); page.on('pageerror',e=>browserErrors.push(e.message)); await page.goto(web);await page.locator('#txtManualName').fill(user.name);await page.locator('#txtManualPassword').fill(user.password);await page.getByRole('checkbox').check();await page.getByRole('button',{name:'Sign In',exact:true}).click();await page.waitForURL(/home/,{timeout:20000}); await page.waitForFunction(()=>JSON.parse(localStorage.getItem('jellyfin_credentials')||'{}').Servers?.some(s=>s.AccessToken&&s.UserId)); } const [page,other]=pages; report.plugin=ssh(admin+`print(json.dumps(next(x for x in api('/Plugins') if x['Name']=='Enigma2')))`); await check('two isolated users can sign in',async()=>({users:pages.length,plugin:report.plugin.Version})); if(report.mode!=='playback') { if(!configOnly) { await check('anonymous page requires login',async()=>{ const p=await (await browser.newContext()).newPage();await p.goto(origin+'/DecoderIntegration/Channels');assert(await p.locator('#channelRows section').count()===0,'Anonymous channels exposed');assert(await p.getByRole('link',{name:'Przejdź do logowania Jellyfin'}).count()===1,'No login link');await p.context().close(); }); let all; await check('complete channel list and logos',async()=>{ await custom(page);all=(await api(page,'/LiveTv/Channels?Limit=1000')).body.Items; assert(await page.locator('#channelRows section').count()===all.length,'Incomplete custom channel list');assert(all.length===216,'Unexpected channel count'); await page.waitForFunction(()=>[...document.querySelectorAll('#channelRows img')].some(i=>i.complete&&i.naturalWidth>0));return {channels:all.length,loaded_logos:await page.locator('#channelRows img').evaluateAll(xs=>xs.filter(i=>i.naturalWidth>0).length)}; }); await check('search filters and restores channels',async()=>{ await page.locator('#channelSearch').fill('TVP 1 HD');assert(await page.locator('#channelRows section').count()===1,'Search not exact for TVP1');await page.locator('#channelSearch').fill('');assert(await page.locator('#channelRows section').count()===all.length,'Clear search failed'); }); await check('favorites persist and synchronize natively',async()=>{ const response=page.waitForResponse(r=>r.url().includes('/DecoderIntegration/Preferences')&&r.request().method()==='PUT');await page.getByRole('button',{name:'☆ Ulubiony',exact:true}).first().click();assert((await response).ok(),'Preference save failed'); await page.waitForTimeout(500);await custom(page);await page.locator('#channelView').selectOption('favorites');assert(await page.locator('#channelRows section').count()===1,'Favorite not persisted'); const native=(await api(page,'/LiveTv/Channels?Limit=1000&EnableUserData=true&UserId='+accounts[0].id)).body.Items;assert(native.some(c=>c.UserData?.IsFavorite),'Native favorite missing'); }); await check('hide applies to native channels and is isolated per user',async()=>{ const response=page.waitForResponse(r=>r.url().includes('/DecoderIntegration/Preferences')&&r.request().method()==='PUT');await page.getByRole('button',{name:'Ukryj',exact:true}).click();await response; assert(await page.locator('#channelRows section').count()===0,'Favorite not hidden'); const mine=(await api(page,'/LiveTv/Channels?Limit=0&UserId='+accounts[0].id)).body, theirs=(await api(other,'/LiveTv/Channels?Limit=0&UserId='+accounts[1].id)).body; assert(mine.TotalRecordCount===215 && theirs.TotalRecordCount===216,'Hide leaked across users');return {mine:mine.TotalRecordCount,other:theirs.TotalRecordCount}; }); await check('restore hidden channel',async()=>{ await page.locator('#channelView').selectOption('hidden');const response=page.waitForResponse(r=>r.url().includes('/DecoderIntegration/Preferences')&&r.request().method()==='PUT');await page.getByRole('button',{name:'Pokaż',exact:true}).click();await response;await page.locator('#channelView').selectOption('all');assert(await page.locator('#channelRows section').count()===216,'Channel not restored'); }); await check('custom order persists in native API',async()=>{ const second=all[1].Id.replace(/-/g,'');const response=page.waitForResponse(r=>r.url().includes('/DecoderIntegration/Preferences')&&r.request().method()==='PUT');await page.locator('#channelRows section').nth(1).getByRole('button',{name:'↑',exact:true}).click();await response; assert((await api(page,'/LiveTv/Channels?Limit=1&UserId='+accounts[0].id)).body.Items[0].Id.replace(/-/g,'')===second,'Native order not synchronized'); }); await check('EPG renders actual programmes',async()=>{ await page.locator('#channelView').selectOption('guide');await page.locator('#channelRows section div p').first().waitFor({timeout:45000});const n=await page.locator('#channelRows section div p').count();assert(n>0,'Empty EPG');return {programmes:n}; }); await check('EPG image links exist and load',async()=>{ const images=page.locator('#channelRows section div img');const n=await images.count();assert(n>0,'No programme artwork in current EPG');await images.first().scrollIntoViewIfNeeded();await images.first().evaluate(i=>i.decode());return {images:n}; }); await check('default preference persists after reload',async()=>{ const response=page.waitForResponse(r=>r.url().includes('/DecoderIntegration/Preferences')&&r.request().method()==='PUT');await page.getByRole('button',{name:'Ustaw ten widok jako domyślny'}).click();await response;await custom(page);assert(await page.locator('#channelView').inputValue()==='guide','Default guide preference not persisted'); }); await check('native default Channels and explicit Guide',async()=>{ await native(page);await page.goto(web+'#/livetv?tab=1');await page.waitForTimeout(1000);assert(page.url().endsWith('tab=1'),'Explicit guide tab overwritten'); }); await check('ordinary user cannot read admin diagnostics',async()=>assert((await api(page,'/DecoderIntegration/Status')).status===403,'Admin endpoint exposed')); await check('external relay access denied',async()=>assert((await page.request.get(origin+'/DecoderIntegration/Live/00000000000000000000000000000000')).status()===403,'Relay exposed outside loopback')); await check('Vu recording provider disabled; timers remain readable',async()=>{ const channels=(await api(page,'/Channels')).body.Items,timers=(await api(page,'/LiveTv/Timers?Limit=0')).body;assert(channels.length===0,'Recording provider still registered');return {timers:timers.TotalRecordCount}; }); } await check('administrator plugin configuration loads current receiver settings',async()=>{ const policy=ssh(admin+`u=api('/Users/${accounts[1].id}');p=u['Policy'];p['IsAdministrator']=True;api('/Users/${accounts[1].id}/Policy',p);print(json.dumps({'administrator':api('/Users/${accounts[1].id}')['Policy']['IsAdministrator']}))`); assert(policy.administrator,'Temporary user did not gain admin rights'); // The old client session caches its original non-admin policy; sign in afresh. const config=await (await browser.newContext()).newPage();await config.goto(web);await config.locator('#txtManualName').fill(accounts[1].name);await config.locator('#txtManualPassword').fill(accounts[1].password);await config.getByRole('checkbox').check();await config.getByRole('button',{name:'Sign In',exact:true}).click();await config.waitForURL(/home/,{timeout:20000}); await config.waitForFunction(()=>window.ApiClient?.getCurrentUserId()); await config.evaluate(()=>window.location.hash='#/configurationpage?name=enigma2'); // Jellyfin 12 can request a separate elevated administrator sign-in. const elevated=config.locator('#txtManualPassword:visible'); try { await elevated.waitFor({timeout:5000}); const name=config.locator('#txtManualName:visible');if(await name.count()&&await name.isEditable())await name.fill(accounts[1].name); await elevated.fill(accounts[1].password);await config.getByRole('button',{name:'Sign In',exact:true}).last().click(); await config.evaluate(()=>window.location.hash='#/configurationpage?name=enigma2'); } catch {} try {await config.locator('#txtHostName').waitFor({timeout:20000});} catch {throw Error(JSON.stringify(await config.evaluate(async()=>({route:location.hash,admin:(await window.ApiClient.getCurrentUser()).Policy.IsAdministrator,headings:[...document.querySelectorAll('h1,h2')].map(e=>e.textContent).slice(0,5),input_ids:[...document.querySelectorAll('input')].map(i=>i.id).slice(0,20)}))));} await config.waitForFunction(()=>document.querySelector('#txtHostName')?.value==='192.168.10.20');assert(await config.locator('#txtStreamingPort').inputValue()==='8001','Unexpected configured source port'); assert(!await config.locator('#chkEnableVuRecordings').isChecked(),'Vu recordings unexpectedly enabled');await config.context().close(); // This check does not submit the form or alter the plugin configuration. }); } if(report.mode!=='ui') { let controlVideo=false; for(const name of controlOnly?['4Fun.TV']:['4Fun.TV','TVP 1 HD','TVP 2 HD','TVN HD']) { let started=false; const ok=await check(name+' '+(name==='4Fun.TV'?'custom':'native')+' one-click decoded video',async()=>{await start(page,name);started=true;return await frames(page);}); if(name==='4Fun.TV')controlVideo=ok; await check(name+' stop or recover to Channels',async()=>{if(started)await exit(page,name==='TVP 2 HD');else await native(page);}); } if(controlVideo) { await check('native channel card opens actual video and native OSD',async()=>{ const channel=(await api(page,'/LiveTv/Channels?Limit=1000')).body.Items.find(c=>c.Name==='4Fun.TV'); const prefs=(await api(page,'/DecoderIntegration/Preferences')).body;prefs.OrderedIds=[channel.Id];assert((await api(page,'/DecoderIntegration/Preferences','PUT',prefs)).status===204,'Test account order save failed'); await native(page);await page.locator('.card[data-type="TvChannel"]').filter({has:page.locator('a.textActionButton',{hasText:/^\d+ 4Fun\.TV$/})}).locator('a.cardImageContainer').click({position:{x:20,y:20}}); const r=await frames(page);assert(page.url().includes('/video'),'Native video OSD did not open');await exit(page,true);return r; }); } await check('custom channel button starts decoded video',async()=>{await custom(page);await page.getByRole('button',{name:controlOnly?/^\d+\. 4Fun\.TV$/:/^\d+\. TVN HD$/}).click();await page.locator('#decoderPlaybackExit').waitFor({timeout:15000});return await frames(page);}); await check('custom playback exit',async()=>exit(page,true)); let shared=false; if(controlVideo) shared=await check('double-click and two browsers share one physical upstream',async()=>{ await start(page,'4Fun.TV',true);await frames(page);await start(other,'4Fun.TV');await frames(other); const s=ssh(admin+`print(json.dumps(api('/DecoderIntegration/Status')['Streams']))`); const channel=s.Channels.find(c=>c.ServiceReference.includes(':428D:2BC0:13E:'));assert(channel?.Active&&channel.Consumers>=2,'Two browser consumers not present'); const owif=await page.request.get('http://192.168.10.20/api/about');const body=await owif.json();const streams=body.info?.streams||body.streams||[];assert(streams.filter(s=>/4fun.tv/i.test(JSON.stringify(s))).length===1,'Decoder does not report exactly one 4Fun.TV stream');return {consumers:channel.Consumers,physical_connections:1}; }); else report.checks.push({name:'double-click and two browsers share one physical upstream',status:'BLOCKED',error:'No working control video'}); if(shared)await check('stopping one viewer preserves other viewer',async()=>{await exit(page);const r=await frames(other);await exit(other,true);return r;}); else report.checks.push({name:'stopping one viewer preserves other viewer',status:'BLOCKED',error:'Two active viewers were not established'}); for(const p of pages)if(await p.locator('#decoderPlaybackExit').count())await exit(p); await check('last viewer releases source',async()=>{ await page.waitForTimeout(17000);const s=ssh(admin+`print(json.dumps(api('/DecoderIntegration/Status')['Streams']))`);assert(s.Consumers===0&&s.ActiveSources===0,'Diagnostic sources remain active');return {active:s.ActiveSources,consumers:s.Consumers}; }); } await check('no uncaught browser errors',async()=>assert(browserErrors.length===0,'Uncaught browser errors: '+browserErrors.join('; '))); } catch(e) { report.checks.push({name:'suite prerequisite',status:'FAIL',error:e.message.slice(0,350)}); } finally { if(browser)await browser.close(); for(const u of accounts)try { ssh(admin+`api('/Users/${u.id}',method='DELETE');pathlib.Path('/var/lib/jellyfin/decoder-integration/users/${u.id.replace(/-/g,'')}.json').unlink(missing_ok=True);print('{}')`); } catch(e){report.checks.push({name:'test account cleanup',status:'FAIL',error:'Failed to remove '+u.name});} report.finished_utc=new Date().toISOString();report.pass=report.checks.filter(c=>c.status==='PASS').length;report.fail=report.checks.filter(c=>c.status==='FAIL').length;report.blocked=report.checks.filter(c=>c.status==='BLOCKED').length; const path=process.argv.find(a=>a.startsWith('--report='))?.slice(9);if(path)fs.writeFileSync(path,JSON.stringify(report,null,2)+'\n'); console.log(JSON.stringify(report));process.exitCode=report.fail?1:0; } })();