Files
ytplayer/tests/offline-pages.spec.js
2026-10-03 17:56:01 +08:00

74 lines
6.0 KiB
JavaScript

const { test, expect } = require('@playwright/test');
const { openClassic } = require('./helpers/classic-fixture');
const capture = process.env.YTP_OFFLINE_CAPTURE;
async function fixture(page, layout, state) {
await openClassic(page);
await page.evaluate(({layout,state}) => {
data.playlists = [{id:'studio', name:'Studio', videos:Array.from({length:4},(_,i)=>({id:'preview'+String(i).padStart(4,'0'),title:i===2?'An extended conversation about making music and finding a little room to listen':'Studio session '+i,channel:'Preview',thumbnail:'/fixture-cover.svg'}))}];
cachedIds.clear(); downloading.clear(); downloadMeta.clear(); SaveQueue.resumeAll=async()=>{};
data.settings.layout = layout; applyAppearance();
API.cacheList = async () => ({ ok:true, items: state==='downloads-empty' ? [] : data.playlists[0].videos.slice(0,3).map((v,i)=>({id:v.id,size:104857600*(i+1)})), total:state==='downloads-empty'?0:629145600 });
if(state==='downloads-populated') {
const videos=data.playlists[0].videos.slice(0,4); const statuses=['active','queued','paused','failed'];
localStorage.setItem('ytpSaveQueue',JSON.stringify(Object.fromEntries(videos.map((v,i)=>[v.id,{...v,at:Date.now(),status:statuses[i],error:i===3?'Connection refused':''}]))));
videos.slice(0,2).forEach((v,i)=>{downloading.add(v.id);downloadMeta.set(v.id,v);SaveQueue.progress(v.id,i?{phase:'waiting'}:{phase:'downloading',received:10485760,total:104857600});});
}
view={type:state.startsWith('downloads')?'downloads':'saved'};render();
},{layout,state});
}
for(const layout of ['classic','glass-stage'])for(const width of [390,1440])for(const state of ['downloads-empty','downloads-populated','saved-populated'])test(`${layout} ${state} at ${width}px`,async({page})=>{
await page.setViewportSize({width,height:900});await fixture(page,layout,state);
if(state==='saved-populated')await expect(page.locator('.saved-card')).toHaveCount(3);
if(state==='downloads-populated')await expect(page.locator('.dl-row')).toHaveCount(4);
await page.locator('#listPane').scrollIntoViewIfNeeded();
if(state==='downloads-empty') {await expect(page.locator('#listFilterBar')).toBeHidden();await expect(page.getByRole('button',{name:'View saved videos',exact:true})).toBeVisible();}
if(state==='downloads-populated') {await expect(page.getByRole('button',{name:/^Pause /})).toHaveCount(2);await expect(page.getByRole('button',{name:/^Retry /})).toHaveCount(1);}
await page.locator('.card').evaluateAll(async nodes => { await Promise.all(nodes.flatMap(node => node.getAnimations().map(animation => animation.finished.catch(() => {})))); });
if(capture)await page.screenshot({path:`/tmp/ytplayer-offline-${capture}-${layout}-${width}-${state}.png`});
});
test('download cancellation waits for confirmation and filtering keeps queue intact', async({page})=>{
await fixture(page,'classic','downloads-populated');
await page.locator('.dl-row').first().getByRole('button',{name:/^Cancel /}).click();
await expect(page.getByRole('button',{name:'Cancel save',exact:true})).toBeVisible();
await page.getByRole('button',{name:'Keep',exact:true}).click();
await expect(page.locator('.dl-row')).toHaveCount(4);
await page.locator('#listFilterInput').fill('extended');
await expect(page.locator('.dl-row')).toHaveCount(1);
expect(await page.evaluate(()=>SaveQueue.pending().length)).toBe(4);
});
for(const layout of ['classic','glass-stage'])for(const width of [390,1440])test(`${layout} Saved actions do not overlap at ${width}px`,async({page})=>{
await page.setViewportSize({width,height:900});await fixture(page,layout,'saved-populated');
await expect(page.locator('.saved-file-action')).toHaveCount(6);
const bounds=await page.locator('.saved-file-actions').first().locator('button').evaluateAll(nodes=>nodes.map(n=>{const r=n.getBoundingClientRect();return {width:r.width,height:r.height,left:r.left,right:r.right,top:r.top};}));
expect(bounds[0].width).toBeGreaterThanOrEqual(44);expect(bounds[0].height).toBeGreaterThanOrEqual(44);
expect(bounds[1].left).toBeGreaterThanOrEqual(bounds[0].right);expect(bounds[1].top).toBe(bounds[0].top);
await expect(page.locator('.saved-storage-summary').getByRole('button',{name:'Clear all saved videos'})).toBeVisible();
});
test('saved deletion is confirmed and a storage failure retains the file',async({page})=>{
await fixture(page,'classic','saved-populated');
await page.evaluate(()=>{API.cacheDelete=async()=>({ok:false,error:'Disk busy'});});
await page.locator('.saved-file-action.danger').first().click();
await expect(page.getByRole('button',{name:'Delete saved file',exact:true})).toBeVisible();
await page.getByRole('button',{name:'Cancel',exact:true}).click();
await expect(page.locator('.saved-card')).toHaveCount(3);
await page.locator('.saved-file-action.danger').first().click();
await page.getByRole('button',{name:'Delete saved file',exact:true}).click();
await expect(page.locator('.saved-card')).toHaveCount(3);
await page.getByRole('button',{name:'Clear all saved videos'}).click();
await expect(page.getByRole('button',{name:'Clear all',exact:true})).toBeVisible();
await page.getByRole('button',{name:'Cancel',exact:true}).click();
await page.locator('#listFilterInput').fill('extended');
await expect(page.locator('.saved-card')).toHaveCount(1);
});
test('actual device playback reports metadata once without requesting streams',async({page})=>{
await openClassic(page);
const reports=[];await page.route('**/api/media/*/meta',route=>{reports.push(route.request().postDataJSON());return route.fulfill({contentType:'application/json',body:'{"ok":true,"known":false,"cache":"queued"}'});});
await page.evaluate(()=>{
current={meta:{id:'0gfX0dFLaBc',title:'Phone saved song',channel:'Phone artist',duration:240},qualities:[]};
Player.master=els.audio;Player.secondary=null;
els.audio.dispatchEvent(new Event('playing'));els.audio.dispatchEvent(new Event('playing'));
});
await expect.poll(()=>reports.length).toBe(1);expect(reports[0]).toMatchObject({id:'0gfX0dFLaBc',title:'Phone saved song',channel:'Phone artist'});
});