Files
jellyfin-plugin-enigma2/test-e2e.cjs
T

188 lines
19 KiB
JavaScript

// 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="<redacted>"').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;
}
})();