mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-11 21:41:02 +08:00
- ui/sheet.tsx Sheet/ConfirmSheet replace the centred Modal, SweetAlert confirms, DialogView overlays, the reader Panel, the shelf settings floating card, the two media dialogs and Mantine Modal. - Phones: bottom sheet. Wide windows: right-edge drawer; compact sheets (confirm, prompt, pick) open next to the tap or focused control. - One set of exits (close, Escape, backdrop), 关闭 label, focus starts on 取消, danger confirms. - Remove sweetalert2 and the unused @mantine/notifications. - Remove the 来源与刮削 media settings panel and its entries in 我的 and 影音设置; TMDB and MusicBrainz stay configurable in 系统设置 > 服务配置. - docs/ui.md 3.7 describes the rule; tests and review scripts follow the new markup.
1164 lines
106 KiB
JavaScript
1164 lines
106 KiB
JavaScript
// Real local HTTP + built web client + Chromium. No external media or user data.
|
|
const {chromium}=require('playwright');
|
|
const {mkdtemp,mkdir,writeFile,rm}=require('node:fs/promises');
|
|
const {tmpdir}=require('node:os');
|
|
const {join,resolve}=require('node:path');
|
|
const {spawn,execFile}=require('node:child_process');
|
|
const runFile=require('node:util').promisify(execFile);
|
|
const {pathToFileURL}=require('node:url');
|
|
const {setTimeout:delay}=require('node:timers/promises');
|
|
const assert=require('node:assert/strict');
|
|
|
|
(async()=>{
|
|
if(process.env.MEDIA_SMOKE_FFMPEG){
|
|
assert.ok(process.env.MEDIA_FFPROBE_PATH,'media fixture mode requires an explicit ffprobe');
|
|
process.env.MEDIA_FFMPEG_PATH=process.env.MEDIA_SMOKE_FFMPEG;
|
|
}
|
|
const repo=resolve(__dirname,'../..');
|
|
const root=await mkdtemp(join(tmpdir(),'reader-media-browser-'));
|
|
const books=join(root,'books'),music=join(root,'music');await mkdir(books);await mkdir(music);
|
|
const samples=44100*30,wav=Buffer.alloc(44+samples*2);
|
|
wav.write('RIFF');wav.writeUInt32LE(wav.length-8,4);wav.write('WAVEfmt ',8);wav.writeUInt32LE(16,16);wav.writeUInt16LE(1,20);wav.writeUInt16LE(1,22);wav.writeUInt32LE(44100,24);wav.writeUInt32LE(88200,28);wav.writeUInt16LE(2,32);wav.writeUInt16LE(16,34);wav.write('data',36);wav.writeUInt32LE(samples*2,40);
|
|
for(let i=0;i<samples;i++)wav.writeInt16LE(Math.round(Math.sin(i*2*Math.PI*220/44100)*500),44+i*2);
|
|
await writeFile(join(music,'test-tone.wav'),wav);
|
|
await writeFile(join(music,'test-tone.lrc'),'[00:00.00]测试歌词第一句\n[00:05.00]测试歌词第二句\n[00:10.00]\n[00:15.00]测试歌词第三句');
|
|
await writeFile(join(music,'cover.png'),Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aD1sAAAAASUVORK5CYII=','base64'));
|
|
const port=process.env.MEDIA_SMOKE_PORT||'5897',origin='http://127.0.0.1:'+port;
|
|
const serverArgs=process.env.MEDIA_SMOKE_BUILT==='1'?[resolve(repo,'server/dist/main.js')]:['--import',pathToFileURL(resolve(repo,'server/node_modules/tsx/dist/loader.mjs')).href,resolve(repo,'server/src/main.ts')];
|
|
const server=spawn(process.execPath,serverArgs,{cwd:repo,windowsHide:true,stdio:['ignore','pipe','pipe'],env:{...process.env,BOOKS_DIR:books,DATA_DIR:join(root,'data'),WEB_DIR:join(repo,'web/dist'),HOST:'127.0.0.1',PORT:port,SCAN_INTERVAL:'0',WATCH_INTERVAL:'0',LOG_LEVEL:'silent'}});
|
|
let logs='';server.stderr.on('data',d=>logs+=d);server.stdout.on('data',d=>logs+=d);let browser;
|
|
try{
|
|
let live=false;for(let i=0;i<300;i++){try{if((await fetch(origin+'/api/v1/instance')).ok){live=true;break;}}catch{}if(server.exitCode!==null)throw Error(logs);await delay(100);}assert.ok(live,'server startup');
|
|
const reg=await fetch(origin+'/api/v1/auth/register',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({username:'media-test',password:'media-test-pass'})});assert.equal(reg.status,201);
|
|
const auth=await reg.json(),headers={authorization:'Bearer '+auth.session.accessToken,'content-type':'application/json'};
|
|
for(let attempt=0;;attempt++){
|
|
const response=await fetch(origin+'/api/v1/media/libraries',{headers});
|
|
if(response.ok)break;
|
|
const body=await response.json();assert.equal(body.error?.code,'MEDIA_STARTING',JSON.stringify(body));
|
|
assert.ok(attempt<300,'media startup timeout');await delay(100);
|
|
}
|
|
const create=await fetch(origin+'/api/v1/media/libraries',{method:'POST',headers,body:JSON.stringify({name:'浏览器音乐测试',kind:'music',root:music,access:'all'})});assert.equal(create.status,201);const lib=await create.json();
|
|
const scanResponse=await fetch(origin+`/api/v1/media/libraries/${lib.id}/scan`,{method:'POST',headers,body:'{}'});assert.equal(scanResponse.status,202,await scanResponse.clone().text());const scan=await scanResponse.json();
|
|
let job;for(let i=0;i<350;i++){job=await (await fetch(origin+'/api/v1/media/jobs/'+scan.id,{headers})).json();if(job.state!=='running')break;await delay(100);}assert.equal(job.state,'complete');
|
|
browser=await chromium.launch({headless:true,...(process.env.PROTOTYPE_CHROMIUM?{executablePath:process.env.PROTOTYPE_CHROMIUM}:{})});
|
|
const readingPage=await browser.newPage({viewport:{width:390,height:844}}),readingMediaRequests=[],readingErrors=[];
|
|
readingPage.on('request',request=>{if(request.url().startsWith(origin+'/api/v1/media/'))readingMediaRequests.push(request.url());});
|
|
readingPage.on('pageerror',error=>readingErrors.push(error.message));
|
|
await readingPage.goto(origin+'/#/shelf');
|
|
await readingPage.locator('input[autocomplete="username"]').fill('media-test');await readingPage.locator('input[type="password"]').fill('media-test-pass');
|
|
await readingPage.locator('form button[type=submit]').click();await readingPage.locator('.shelf-screen').waitFor();
|
|
await readingPage.evaluate(()=>window.readerApp.flush());
|
|
assert.equal(await readingPage.locator('.media-channel-entry').count(),0);
|
|
assert.equal(await readingPage.locator('.media-player').isVisible(),false);
|
|
assert.equal(await readingPage.locator('.media-player audio,.media-player video').evaluateAll(elements=>elements.every(element=>!element.getAttribute('src')&&element.paused)),true);
|
|
assert.deepEqual(readingMediaRequests,[],'fresh reading startup and persistence do not fetch media APIs');
|
|
assert.deepEqual(readingErrors,[]);
|
|
await readingPage.getByRole('button',{name:'影音',exact:true}).click();
|
|
await readingPage.locator('.media-channel-entry a[aria-current=page]').waitFor();
|
|
assert.equal(await readingPage.locator('.media-channel-entry svg').count(),4);
|
|
await readingPage.getByRole('link',{name:'阅读',exact:true}).click();
|
|
await readingPage.locator('.shelf-screen').waitFor();
|
|
assert.equal(await readingPage.getByRole('button',{name:/书架设置/}).count(),1);
|
|
await readingPage.close();
|
|
const page=await browser.newPage({viewport:{width:390,height:844}}),errors=[];page.on('pageerror',e=>errors.push(e.message));
|
|
async function stopPlayback(){
|
|
const close=page.locator('dialog[open] .sheet-close').last();if(await close.isVisible())await close.click();
|
|
const videoStop=page.getByRole('button',{name:'结束',exact:true});
|
|
if(await videoStop.isVisible()){await videoStop.click();return;}
|
|
const audioStop=page.getByRole('button',{name:'结束播放',exact:true});
|
|
if(!await audioStop.isVisible())await page.getByLabel('更多播放选项',{exact:true}).click();
|
|
await audioStop.click();
|
|
}
|
|
const visualDir=process.env.MEDIA_VISUAL_DIR,visualChecks=[];
|
|
if(visualDir){
|
|
await mkdir(visualDir,{recursive:true});
|
|
await writeFile(join(visualDir,'verification.json'),JSON.stringify({complete:false,checks:[],browserErrors:[]},null,2));
|
|
}
|
|
const auditPage=async(name,focus)=>{
|
|
if(!visualDir)return;
|
|
if(process.env.MEDIA_VISUAL_FILTER&&!new RegExp(process.env.MEDIA_VISUAL_FILTER).test(name))return;
|
|
await mkdir(visualDir,{recursive:true});
|
|
const viewport=page.viewportSize(),theme=await page.evaluate(()=>document.documentElement.dataset.theme??null);
|
|
const playing=await page.locator('.media-player audio,.media-player video').evaluateAll(elements=>elements.filter(element=>!element.paused&&!element.ended).map(element=>{const source=element.currentSrc;element.pause();return {tag:element.tagName,source};}));
|
|
try{
|
|
for(const [device,width,height]of [['narrow',360,800],['mobile',390,844],['desktop',1366,900]])for(const mode of ['light','dark']){
|
|
await page.setViewportSize({width,height});
|
|
await page.evaluate(mode=>{document.documentElement.dataset.theme=mode;const screen=document.querySelector('.media-screen');if(screen)screen.scrollTop=0;},mode);
|
|
await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
|
|
if(focus){
|
|
if(!await focus.isVisible())await focus.locator('xpath=ancestor::details[1]').locator(':scope > summary').click();
|
|
await focus.evaluate(element=>element.scrollIntoView({behavior:'instant',block:'center'}));
|
|
await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
|
|
assert.equal(await focus.isVisible(),true,`${name}: review target must be expanded`);
|
|
assert.ok(await focus.evaluate(element=>{const rect=element.getBoundingClientRect();return rect.top>=0&&rect.bottom<innerHeight-70;}),`${name}: review target must be inside the screenshot`);
|
|
assert.ok(await focus.evaluate(element=>{const rect=element.getBoundingClientRect(),heading=document.querySelector('.media-playback-page .media-heading');return !heading||rect.top>=heading.getBoundingClientRect().bottom;}),`${name}: review target must remain below the sticky playback heading`);
|
|
}
|
|
const metrics=await page.evaluate(()=>{
|
|
const screen=document.querySelector('.media-screen');
|
|
const covers=Array.from(document.querySelectorAll('.media-grid .media-tile .media-cover')).slice(0,2).map(cover=>cover.getBoundingClientRect().top);
|
|
const player=document.querySelector('.media-player-expanded.media-player-audio:not([hidden])');
|
|
return {pageOverflow:document.documentElement.scrollWidth>innerWidth+1,contentOverflow:!!screen&&screen.scrollWidth>screen.clientWidth+1,theme:document.documentElement.dataset.theme,coverTopDelta:covers.length===2?Math.abs(covers[0]-covers[1]):0,audioOverlap:!!player&&player.getClientRects().length>0&&!!screen&&screen.getBoundingClientRect().bottom>player.getBoundingClientRect().top};
|
|
});
|
|
if(name==='playback-unsupported')assert.ok(await page.locator('.media-playback-status [role="alert"]').evaluate(element=>{const box=element.getBoundingClientRect(),header=document.querySelector('.media-heading').getBoundingClientRect(),screen=document.querySelector('.media-screen').getBoundingClientRect();return box.top>=header.bottom&&box.bottom<=screen.bottom;}),'playback failure is visible on entry without scrolling');
|
|
if(name==='series-season-picker'){
|
|
assert.equal(await page.locator('.media-episode-grid').evaluate(grid=>getComputedStyle(grid).gridTemplateColumns.split(' ').length),width>700?8:4);
|
|
assert.ok(await page.getByLabel('选择季',{exact:true}).isVisible(),'season selector is visible');
|
|
assert.ok(await page.locator('.media-episode-grid button').evaluateAll(items=>items.every(item=>item.getBoundingClientRect().height>=44)),'episode tiles retain touch targets');
|
|
}
|
|
if(['movie-detail','audiobook-detail','resource-missing','new-work'].includes(name)){
|
|
assert.equal(await page.locator('.media-edition-admin[open]').count(),0,'edition management is folded on entry');
|
|
assert.ok(await page.locator('.media-edition-admin').evaluateAll(panels=>panels.every(panel=>panel.getBoundingClientRect().height<70)),'folded administration remains compact');
|
|
}
|
|
const file=`${name}-${device}-${mode}.png`;await page.screenshot({path:join(visualDir,file)});
|
|
if(name==='saved-queue'||name==='saved-queue-long-title'){
|
|
assert.equal(await page.locator('.media-saved-queue-title').evaluateAll(buttons=>buttons.length>0&&buttons.every(button=>button.getBoundingClientRect().width>=44&&button.getBoundingClientRect().height>=44&&button.getAttribute('aria-label').startsWith('播放 '))),true,'saved queue title remains a labelled playback target');
|
|
assert.equal(await page.locator('.media-saved-queue-detail').evaluateAll(buttons=>buttons.length>0&&buttons.every(button=>button.getBoundingClientRect().width>=40&&button.getAttribute('aria-label').includes('详情'))),true,'saved queue detail icons remain labelled');
|
|
assert.equal(await page.locator('.media-personal-title').evaluateAll(titles=>titles.length>0&&titles.every(title=>title.getBoundingClientRect().height<=parseFloat(getComputedStyle(title).lineHeight)*2+1)),true,'queue titles occupy at most two lines');
|
|
}
|
|
if(name==='audiobook-player'&&device!=='desktop')assert.ok(await page.locator('.media-playback-options').evaluate(element=>element.getBoundingClientRect().bottom<=document.querySelector('.media-screen').getBoundingClientRect().bottom),'short-screen audiobook speed and timer stay above the player dock');
|
|
visualChecks.push({page:name,device,width,height,mode,file,...metrics});
|
|
await writeFile(join(visualDir,'verification.json'),JSON.stringify({complete:false,checks:visualChecks},null,2));
|
|
assert.equal(metrics.pageOverflow,false,`${name} ${device} ${mode}: page overflow`);
|
|
assert.equal(metrics.contentOverflow,false,`${name} ${device} ${mode}: content overflow`);
|
|
assert.ok(metrics.coverTopDelta<1,`${name} ${device} ${mode}: cover alignment`);
|
|
assert.equal(metrics.audioOverlap,false,`${name} ${device} ${mode}: audio player obscures scroll area`);
|
|
}
|
|
}finally{
|
|
await page.setViewportSize(viewport);
|
|
await page.evaluate(({theme,playing})=>{
|
|
if(theme===null)delete document.documentElement.dataset.theme;else document.documentElement.dataset.theme=theme;
|
|
for(const item of playing){const media=document.querySelector('.media-player '+item.tag.toLowerCase());if(media&&media.currentSrc===item.source&&!media.ended)void media.play().catch(()=>{});}
|
|
},{theme,playing});
|
|
}
|
|
};
|
|
// Shorten only the client-advertised lifetime to exercise renewal against the real server.
|
|
let renewalCount=0;
|
|
page.on('response',response=>{if(response.url().endsWith('/renew')&&response.status()===200)renewalCount++;});
|
|
await page.route('**/api/v1/media/playback',async route=>{
|
|
const response=await route.fetch();const body=await response.json();
|
|
if(response.status()===201)body.expiresAt=Date.now()+1000;
|
|
await route.fulfill({response,json:body});
|
|
});
|
|
await page.goto(origin+'/#/media/music');await page.locator('input[autocomplete="username"]').fill('media-test');await page.locator('input[type="password"]').fill('media-test-pass');await page.locator('form button[type=submit]').click();
|
|
await page.waitForFunction(()=>Array.from(document.querySelectorAll('.media-grid .media-cover img')).some(img=>img.complete&&img.naturalWidth>0));
|
|
assert.equal(await page.locator('.media-search').count(),0,'search is collapsed by default');
|
|
const layout=await page.locator('.media-grid').evaluate(grid=>({top:grid.getBoundingClientRect().top,columns:getComputedStyle(grid).gridTemplateColumns.split(' ').length,overflow:document.documentElement.scrollWidth>innerWidth}));
|
|
assert.equal(layout.columns,3,'mobile uses three cover columns');
|
|
assert.ok(layout.top<170,'compact header keeps first row near the top');
|
|
assert.equal(layout.overflow,false,'mobile has no horizontal page overflow');
|
|
await auditPage('music-home');
|
|
// Exercise the mounted screen, not just an isolated error component.
|
|
for(const [name,status,code,message,title] of [
|
|
['service-starting',503,'MEDIA_STARTING','影音服务正在准备,请稍后重试','影音服务正在准备'],
|
|
['service-unavailable',503,'MEDIA_UNAVAILABLE','影音服务不可用,请管理员检查服务日志','影音服务暂不可用'],
|
|
]){
|
|
const pattern='**/api/v1/media/libraries';
|
|
const failRead=route=>route.fulfill({status,contentType:'application/json',body:JSON.stringify({error:{code,message}})});
|
|
await page.route(pattern,failRead);await page.reload();
|
|
await page.getByText(title,{exact:true}).waitFor();
|
|
assert.equal(await page.getByText('尚无媒体库,请点击右上角创建。',{exact:true}).count(),0);
|
|
assert.equal(await page.locator('.media-screen .media-toolbar').count(),0,'unread libraries do not show a zero count or empty selector');
|
|
await auditPage(name);
|
|
await page.unroute(pattern,failRead);
|
|
await page.locator('.media-screen-error').getByRole('button',{name:'重试',exact:true}).click();
|
|
await page.locator('.media-grid .media-tile').first().waitFor();
|
|
assert.equal(await page.locator('.media-screen-error').count(),0);
|
|
}
|
|
for(const [name,status,code,message,title] of [
|
|
['detail-denied',403,'MEDIA_FORBIDDEN','无权访问此媒体库','没有访问权限'],
|
|
['detail-unavailable',404,'MEDIA_NOT_FOUND','作品不存在或不可访问','内容已不可用'],
|
|
]){
|
|
const pattern='**/api/v1/media/items/review-unavailable';
|
|
const failRead=route=>route.fulfill({status,contentType:'application/json',body:JSON.stringify({error:{code,message}})});
|
|
await page.route(pattern,failRead);await page.goto(origin+'/#/media/music/review-unavailable');
|
|
await page.getByText(title,{exact:true}).waitFor();
|
|
assert.equal(await page.locator('.media-screen .media-toolbar').count(),0,'failed detail does not masquerade as an empty catalog');
|
|
await auditPage(name);
|
|
await page.locator('.media-screen-error').getByRole('button',{name:'返回频道',exact:true}).click();
|
|
await page.locator('.media-grid .media-tile').first().waitFor();
|
|
assert.equal(new URL(page.url()).hash,'#/media/music');
|
|
await page.unroute(pattern,failRead);
|
|
}
|
|
await page.getByRole('button',{name:'曲目',exact:true}).click();
|
|
await page.locator('.media-track-entry').first().waitFor();
|
|
assert.ok(await page.locator('.media-track-entry').first().evaluate(row=>row.getBoundingClientRect().height)<90,'ordinary track rows remain compact');
|
|
await auditPage('tracks');
|
|
await page.locator('.media-track-filters > summary').click();
|
|
await page.getByLabel('艺人包含',{exact:true}).fill('示例艺人 / Example Artist');
|
|
await page.getByLabel('专辑包含',{exact:true}).fill('示例专辑(仅展示筛选表单,尚未应用)');
|
|
await auditPage('track-filters',page.locator('.media-track-filters form'));
|
|
await page.getByRole('button',{name:'清除筛选',exact:true}).click();
|
|
await page.locator('.media-track-entry').first().waitFor();
|
|
await page.locator('.media-track-filters > summary').click();
|
|
let failDetailCover=true;
|
|
const coverRetryPattern='**/api/v1/media/items/*/cover';
|
|
const failCoverOnce=async route=>{
|
|
if(failDetailCover && await page.locator('.media-hero .media-cover').count()){failDetailCover=false;await route.fulfill({status:504,contentType:'application/json',body:JSON.stringify({error:{code:'MEDIA_COVER_TIMEOUT',message:'cover unavailable'}})});}
|
|
else await route.continue();
|
|
};
|
|
await page.route(coverRetryPattern,failCoverOnce);
|
|
await page.locator('.media-track-detail').first().click();
|
|
await page.locator('.media-hero h1').filter({hasText:'test-tone'}).waitFor();
|
|
const retryCover=page.getByRole('button',{name:'重试封面',exact:true});
|
|
await retryCover.waitFor();
|
|
await page.getByText('封面读取超时',{exact:true}).waitFor();
|
|
await auditPage('cover-retry',retryCover);
|
|
assert.ok(await page.locator('.media-hero .media-cover').evaluate(cover=>{const rect=cover.getBoundingClientRect();return Math.abs(rect.width-rect.height)<1;}),'retry must preserve square music artwork');
|
|
await retryCover.click();
|
|
await page.waitForFunction(()=>{const img=document.querySelector('.media-hero .media-cover img');return img?.complete&&img.naturalWidth>0;});
|
|
await page.unroute(coverRetryPattern,failCoverOnce);
|
|
await page.evaluate(()=>{location.hash='#/media/music';});
|
|
await page.locator('.media-track-entry').first().waitFor();
|
|
await page.getByRole('button',{name:'专辑',exact:true}).click();
|
|
await page.locator('.media-grid .media-tile').first().waitFor();
|
|
await page.locator('.media-actions summary').click();
|
|
await page.getByRole('button',{name:'影音设置',exact:true}).click();
|
|
await page.getByRole('heading',{name:'影音设置',exact:true}).waitFor();
|
|
if(process.env.MEDIA_SMOKE_SETTINGS_SCREENSHOT)await page.screenshot({path:process.env.MEDIA_SMOKE_SETTINGS_SCREENSHOT});
|
|
await auditPage('settings');
|
|
await page.getByRole('button',{name:/^浏览偏好/}).click();
|
|
await page.getByLabel('封面密度',{exact:true}).selectOption('comfortable');
|
|
await page.getByRole('button',{name:'保存偏好',exact:true}).click();
|
|
await page.getByText('影音偏好已保存',{exact:true}).waitFor();
|
|
await page.getByRole('button',{name:'返回影音设置',exact:true}).click();
|
|
await page.getByRole('button',{name:'← 返回影音',exact:true}).click();
|
|
assert.equal(await page.locator('.media-grid').evaluate(grid=>getComputedStyle(grid).gridTemplateColumns.split(' ').length),2);
|
|
await page.reload();await page.locator('.media-grid .media-tile').first().waitFor();
|
|
assert.equal(await page.locator('.media-grid').evaluate(grid=>getComputedStyle(grid).gridTemplateColumns.split(' ').length),2,'density survives reload');
|
|
await page.locator('.media-actions summary').click();await page.getByRole('button',{name:'影音设置',exact:true}).click();
|
|
await page.getByRole('button',{name:/^浏览偏好/}).click();
|
|
await page.getByRole('button',{name:'恢复默认值',exact:true}).click();await page.getByRole('button',{name:'保存偏好',exact:true}).click();
|
|
await page.getByRole('button',{name:'返回影音设置',exact:true}).click();
|
|
await page.getByRole('button',{name:'← 返回影音',exact:true}).click();
|
|
assert.equal(await page.locator('.media-grid').evaluate(grid=>getComputedStyle(grid).gridTemplateColumns.split(' ').length),3);
|
|
await page.getByRole('button',{name:'搜索影音',exact:true}).click();
|
|
await page.getByRole('textbox',{name:'搜索影音'}).fill('does-not-exist');
|
|
await page.locator('.media-search button').click();
|
|
await page.getByText('没有匹配内容,请调整搜索词或搜索范围。',{exact:true}).waitFor();
|
|
await auditPage('search-empty');
|
|
await page.getByRole('button',{name:'取消搜索'}).click();
|
|
await page.locator('.media-grid .media-tile').first().waitFor();
|
|
await page.locator('.media-actions summary').click();
|
|
await page.getByRole('button',{name:'历史',exact:true}).click();
|
|
await page.getByRole('heading',{name:'播放历史'}).waitFor();
|
|
await auditPage('history-empty');
|
|
await page.getByRole('button',{name:'← 返回',exact:true}).click();
|
|
const memberCreated=await fetch(origin+'/api/v1/admin/users',{method:'POST',headers,body:JSON.stringify({username:'media-member',password:'member-test-pass',displayName:'媒体成员',role:'member'})});
|
|
assert.equal(memberCreated.status,201);
|
|
await page.locator('.media-actions summary').click();
|
|
await page.getByRole('button',{name:'媒体库管理',exact:true}).click();
|
|
await page.getByRole('button',{name:'扫描与刮削',exact:true}).click();
|
|
await page.getByText('新建批量匹配',{exact:true}).click();
|
|
await page.getByRole('heading',{name:'最近任务',exact:true}).waitFor();
|
|
assert.equal(await page.getByRole('button',{name:'开始批量匹配',exact:true}).isDisabled(),true);
|
|
await auditPage('batch-scrape');
|
|
const configUrl=origin+`/api/v1/media/libraries/${lib.id}/configuration`;
|
|
const failConfiguration=async route=>route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({error:{code:'TEMPORARY',message:'媒体库配置暂时不可用'}})});
|
|
await page.route(configUrl,failConfiguration);
|
|
await page.getByRole('button',{name:'媒体库',exact:true}).click();
|
|
await page.getByLabel('媒体库操作 浏览器音乐测试',{exact:true}).click();
|
|
await page.getByRole('button',{name:'编辑媒体库',exact:true}).click();
|
|
await page.getByRole('button',{name:'重试读取配置',exact:true}).waitFor();
|
|
assert.equal(await page.getByRole('button',{name:'保存修改',exact:true}).isDisabled(),true);
|
|
await auditPage('library-config-error',page.locator('.media-library-editor'));
|
|
await page.unroute(configUrl,failConfiguration);
|
|
await page.getByRole('button',{name:'重试读取配置',exact:true}).click();
|
|
await page.getByLabel('媒体库名称',{exact:true}).fill('测试音乐库');
|
|
assert.ok((await page.locator('.media-library-editor').textContent()).includes(music));
|
|
await auditPage('library-rename');
|
|
await page.getByLabel('媒体库名称',{exact:true}).focus();
|
|
await page.keyboard.press('Tab');
|
|
assert.equal(await page.getByRole('button',{name:'取消',exact:true}).evaluate(button=>button===document.activeElement),true,'library cancel follows the name field in keyboard order');
|
|
await page.keyboard.press('Tab');
|
|
assert.equal(await page.getByRole('button',{name:'保存修改',exact:true}).evaluate(button=>button===document.activeElement),true,'library save is keyboard reachable');
|
|
await auditPage('library-form-actions',page.locator('.media-library-editor .media-toolbar'));
|
|
await page.getByRole('button',{name:'保存修改',exact:true}).click();
|
|
await page.locator('.media-library-editor').waitFor({state:'detached'});
|
|
const renamed=await (await fetch(origin+`/api/v1/media/libraries/${lib.id}`,{headers})).json();
|
|
assert.equal(renamed.name,'测试音乐库');
|
|
await page.getByLabel('媒体库操作 测试音乐库',{exact:true}).click();
|
|
await page.getByRole('button',{name:'任务',exact:true}).click();
|
|
await page.getByRole('heading',{name:'扫描任务 · 测试音乐库'}).waitFor();
|
|
await auditPage('library-manager');
|
|
const jobsUrl=origin+`/api/v1/media/libraries/${lib.id}/jobs`;
|
|
const failJobsRead=async route=>{assert.equal(route.request().method(),'GET');await route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({error:{code:'TEMPORARY',message:'扫描任务暂时不可用'}})});};
|
|
await page.route(jobsUrl,failJobsRead);
|
|
await page.getByRole('button',{name:'刷新扫描任务',exact:true}).click();
|
|
await page.getByRole('button',{name:'重试读取扫描任务',exact:true}).waitFor();
|
|
await page.getByText('以下为上次读取的任务,状态可能已变化。',{exact:true}).waitFor();
|
|
await page.unroute(jobsUrl,failJobsRead);
|
|
await page.getByRole('button',{name:'重试读取扫描任务',exact:true}).click();
|
|
await page.getByRole('button',{name:'重试读取扫描任务',exact:true}).waitFor({state:'detached'});
|
|
await page.route(jobsUrl,async route=>route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({items:[{id:'upgrade-failure-fixture',state:'failed',inspected:1,error:'probe-upgrade-failed'}]})}));
|
|
await page.getByRole('button',{name:'刷新扫描任务',exact:true}).click();
|
|
const upgradeMessage=page.getByText('技术信息升级未完成,原有资料已保留。请检查服务器 ffprobe 是否可用,再重新扫描。',{exact:true});
|
|
await upgradeMessage.waitFor();
|
|
await auditPage('scan-upgrade-error',upgradeMessage);
|
|
await page.unroute(jobsUrl);
|
|
await page.getByRole('button',{name:'刷新扫描任务',exact:true}).click();
|
|
await upgradeMessage.waitFor({state:'detached'});
|
|
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false);
|
|
await page.getByRole('button',{name:'媒体库',exact:true}).click();
|
|
await page.getByLabel('媒体库操作 测试音乐库',{exact:true}).click();
|
|
await page.getByRole('button',{name:'权限',exact:true}).click();
|
|
await page.getByRole('button',{name:'保存权限'}).waitFor();
|
|
await page.getByText('保存后所有已启用用户均可访问。下方为预选名单,不限制当前访问范围,切换为指定用户时使用。',{exact:true}).waitFor();
|
|
await auditPage('library-permissions');
|
|
await page.getByLabel('访问范围',{exact:true}).selectOption('restricted');
|
|
await page.getByText('保存后仅管理员可以访问。',{exact:true}).waitFor();
|
|
await auditPage('library-permissions-restricted-empty');
|
|
await page.getByLabel('访问范围',{exact:true}).selectOption('all');
|
|
await page.getByLabel('查找用户',{exact:true}).fill('missing-user');
|
|
await page.getByText('没有匹配的用户,已选名单保留。',{exact:true}).waitFor();
|
|
await auditPage('library-permissions-search-empty');
|
|
await page.getByLabel('查找用户',{exact:true}).fill('media-member');
|
|
await page.locator('.media-user-option input').check();
|
|
await page.getByLabel('访问范围',{exact:true}).selectOption('restricted');
|
|
await page.getByRole('button',{name:'保存权限'}).click();
|
|
await page.getByRole('heading',{name:'媒体库管理',exact:true}).waitFor();
|
|
const accessConfig=await (await fetch(origin+`/api/v1/media/libraries/${lib.id}/configuration`,{headers})).json();
|
|
assert.equal(accessConfig.access,'restricted');assert.equal(accessConfig.userIds.length,1);
|
|
await page.locator('.media-actions summary').click();
|
|
await page.getByRole('button',{name:'返回内容',exact:true}).click();
|
|
await page.locator('.media-grid .media-tile').first().click();await page.locator('.media-album-track-detail').first().click();
|
|
await page.getByRole('button',{name:'加入队列',exact:true}).waitFor();
|
|
await page.getByRole('button',{name:'收藏',exact:true}).last().click();await page.getByRole('button',{name:'取消收藏',exact:true}).waitFor();
|
|
await auditPage('track-detail');
|
|
await page.getByRole('button',{name:'加入队列',exact:true}).click();await page.getByRole('heading',{name:'播放队列'}).waitFor();
|
|
const initialQueue=await (await fetch(origin+'/api/v1/media/queue',{headers})).json();
|
|
const duplicate=await fetch(origin+'/api/v1/media/queue',{method:'POST',headers,body:JSON.stringify({partIds:[initialQueue.items[0].partId]})});assert.equal(duplicate.status,200,await duplicate.clone().text());
|
|
await page.locator('.media-actions summary').click();
|
|
await page.getByRole('button',{name:'队列',exact:true}).click();
|
|
const beforeMove=await (await fetch(origin+'/api/v1/media/queue',{headers})).json();assert.equal(beforeMove.items.length,2);await page.getByRole('button',{name:'编辑队列',exact:true}).click();await page.waitForFunction(()=>document.querySelectorAll('.media-queue-actions').length===2);
|
|
await page.getByRole('button',{name:/^上移 /}).last().click();
|
|
await page.waitForFunction(()=>!Array.from(document.querySelectorAll('[role=status]')).some(el=>el.textContent==='正在处理…'));
|
|
const afterMove=await (await fetch(origin+'/api/v1/media/queue',{headers})).json();
|
|
assert.deepEqual(afterMove.items.map(row=>row.id),beforeMove.items.map(row=>row.id).reverse());
|
|
await page.locator('.media-saved-queue').getByRole('button',{name:/^移除 /}).first().click();
|
|
await page.waitForFunction(()=>document.querySelectorAll('.media-queue-actions').length===1);
|
|
await page.getByRole('button',{name:'完成编辑队列',exact:true}).click();
|
|
await auditPage('saved-queue');
|
|
if(visualDir){
|
|
// Synthetic title stress case; playback identity and server data are unchanged.
|
|
const title=page.locator('.media-saved-queue-title').first();
|
|
const previous=await title.evaluate(button=>{const node=button.firstChild,previous=node.textContent;node.textContent='长标题LongUnbrokenQueueTitle'.repeat(8);return previous;});
|
|
await auditPage('saved-queue-long-title');
|
|
await title.evaluate((button,text)=>{button.firstChild.textContent=text;},previous);
|
|
}
|
|
await page.locator('.media-saved-queue-title').first().click();
|
|
await page.waitForFunction(()=>{const a=document.querySelector('.media-player audio');return a&&a.currentTime>.2&&!a.paused;});
|
|
const miniLayout=await page.locator('.media-mini').evaluate(mini=>{const rect=mini.getBoundingClientRect(),nav=document.querySelector('.media-channel-entry').getBoundingClientRect();return {bottom:rect.bottom,navTop:nav.top,within:rect.left>=0&&rect.right<=innerWidth};});
|
|
assert.equal(miniLayout.within,true);assert.ok(miniLayout.bottom<=miniLayout.navTop,'mini player stays above channel navigation');
|
|
if(process.env.MEDIA_SMOKE_SCREENSHOT)await page.screenshot({path:process.env.MEDIA_SMOKE_SCREENSHOT});
|
|
const beforePlaybackView=await page.locator('.media-player audio').evaluate(a=>({src:a.currentSrc,time:a.currentTime}));
|
|
let failPlayingDetails=true;
|
|
const playingDetailsPattern=/\/api\/v1\/media\/items\/[^/?]+$/;
|
|
await page.route(playingDetailsPattern,async route=>{
|
|
if(failPlayingDetails&&route.request().method()==='GET'){
|
|
failPlayingDetails=false;
|
|
await route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({error:{code:'TEMPORARY',message:'playing detail unavailable'}})});
|
|
}else await route.continue();
|
|
});
|
|
await page.getByRole('button',{name:'打开播放控制',exact:true}).click();
|
|
await page.locator('.media-playback-page').waitFor();
|
|
assert.equal(await page.getByRole('button',{name:'← 返回浏览',exact:true}).evaluate(button=>button===document.activeElement),true);
|
|
const artworkError=page.getByRole('alert').filter({hasText:'作品资料读取失败'});
|
|
await artworkError.waitFor();
|
|
await page.waitForFunction(t=>document.querySelector('.media-player audio').currentTime>t,beforePlaybackView.time);
|
|
assert.equal(await page.locator('.media-player audio').evaluate(a=>a.currentSrc),beforePlaybackView.src);
|
|
await auditPage('playing-artwork-error',artworkError);
|
|
await page.getByRole('button',{name:'重新读取作品资料',exact:true}).click();
|
|
await page.waitForFunction(()=>document.querySelector('.media-playing-artwork img')?.complete);
|
|
await artworkError.waitFor({state:'detached'});
|
|
await page.unroute(playingDetailsPattern);
|
|
assert.equal(await page.getByRole('heading',{name:'播放队列',exact:true}).count(),0);
|
|
await page.waitForFunction(t=>document.querySelector('.media-player audio').currentTime>t,beforePlaybackView.time);
|
|
assert.equal(await page.locator('.media-player audio').evaluate(a=>a.currentSrc),beforePlaybackView.src);
|
|
if(process.env.MEDIA_SMOKE_PLAYER_SCREENSHOT)await page.screenshot({path:process.env.MEDIA_SMOKE_PLAYER_SCREENSHOT});
|
|
await auditPage('audio-player');
|
|
await page.getByRole('button',{name:'歌词',exact:true}).click();
|
|
await page.getByRole('button',{name:'测试歌词第二句',exact:true}).click();
|
|
await page.waitForFunction(()=>document.querySelector('.media-lyrics [aria-current=true]')?.textContent==='测试歌词第二句');
|
|
assert.equal(await page.locator('.media-player audio').evaluate(a=>a.currentSrc),beforePlaybackView.src);
|
|
await page.locator('.media-player audio').evaluate(a=>{a.currentTime=15.2;});
|
|
await page.waitForFunction(()=>document.querySelector('.media-lyrics [aria-current=true]')?.textContent==='测试歌词第三句');
|
|
if(process.env.MEDIA_SMOKE_LYRICS_SCREENSHOT)await page.screenshot({path:process.env.MEDIA_SMOKE_LYRICS_SCREENSHOT});
|
|
await auditPage('lyrics');
|
|
await auditPage('lyrics-transport',page.locator('.media-playback-status .media-transport'));
|
|
await page.getByRole('button',{name:'封面',exact:true}).click();
|
|
await page.keyboard.press('Escape');
|
|
assert.equal(await page.getByRole('button',{name:'打开播放控制',exact:true}).evaluate(button=>button===document.activeElement),true);
|
|
await page.getByRole('heading',{name:'播放队列',exact:true}).waitFor();
|
|
assert.equal(await page.locator('.media-player audio').evaluate(a=>a.currentSrc),beforePlaybackView.src);
|
|
|
|
await page.getByRole('button',{name:'暂停音频',exact:true}).click();
|
|
await page.waitForFunction(()=>document.querySelector('.media-player audio').paused);
|
|
await page.getByRole('button',{name:'继续音频',exact:true}).click();
|
|
await page.waitForFunction(()=>!document.querySelector('.media-player audio').paused);
|
|
assert.equal(await page.locator('.media-playback-status').count(),0,'browse view keeps full transport out of content');
|
|
await page.getByRole('button',{name:'打开播放控制',exact:true}).click();
|
|
await page.getByRole('button',{name:'暂停',exact:true}).click();
|
|
const seekSlider=page.getByRole('slider',{name:'当前章节播放位置',exact:true});
|
|
const seekBox=await seekSlider.boundingBox();assert.ok(seekBox);
|
|
await page.mouse.move(seekBox.x+seekBox.width*.4,seekBox.y+seekBox.height/2);await page.mouse.down();
|
|
await page.mouse.move(seekBox.x+seekBox.width*.7,seekBox.y+seekBox.height/2,{steps:5});
|
|
const dragged=Number(await seekSlider.inputValue());
|
|
await page.locator('.media-player audio').evaluate(audio=>{audio.currentTime=2;audio.dispatchEvent(new Event('timeupdate'));});
|
|
assert.equal(Number(await seekSlider.inputValue()),dragged,'progress updates preserve the live drag');
|
|
await page.mouse.up();
|
|
await page.waitForFunction(position=>Math.abs(document.querySelector('.media-player audio').currentTime-position)<.2,dragged);
|
|
await page.getByLabel('更多播放选项',{exact:true}).click();
|
|
await page.getByRole('button',{name:'快进 30 秒',exact:true}).click();
|
|
await page.getByRole('button',{name:'快退 15 秒',exact:true}).click();
|
|
await page.locator('dialog[open] .sheet-close').last().click();
|
|
await page.waitForFunction(()=>{const a=document.querySelector('.media-player audio');return a.paused&&Math.abs(a.currentTime-15)<.2;});
|
|
await page.getByRole('button',{name:'继续',exact:true}).click();
|
|
await page.getByRole('button',{name:'更多播放选项',exact:true}).click();
|
|
await page.getByRole('combobox',{name:'倍速',exact:true}).selectOption('1.75');
|
|
assert.equal(await page.locator('.media-player audio').evaluate(audio=>audio.playbackRate),1.75);
|
|
await page.locator('dialog[open] .sheet-close').last().click();
|
|
await page.getByRole('combobox',{name:'定时',exact:true}).selectOption('15');
|
|
assert.equal(await page.getByRole('combobox',{name:'定时',exact:true}).locator('option:checked').textContent(),'15 分钟');
|
|
for(let i=0;i<50&&renewalCount===0;i++)await delay(100);
|
|
assert.ok(renewalCount>0,'authenticated playback lease renewed');
|
|
const before=await page.locator('.media-player audio').evaluate(a=>a.currentTime);
|
|
await page.locator('.media-channel-entry a').filter({hasText:'阅读'}).click();
|
|
await page.waitForFunction(()=>location.hash==='#/shelf');
|
|
await page.locator('.media-channel-entry').waitFor({state:'hidden'});
|
|
assert.equal(await page.locator('.media-channel-entry').count(),0,'reading does not retain media navigation nodes');
|
|
await page.locator('.media-player').waitFor({state:'hidden'});
|
|
await page.waitForFunction(t=>document.querySelector('.media-player audio').currentTime>t+.2,before);
|
|
await page.evaluate(()=>window.readerApp.flush());
|
|
const items=await (await fetch(origin+`/api/v1/media/libraries/${lib.id}/items?kind=track`,{headers})).json();
|
|
const detail=await (await fetch(origin+'/api/v1/media/items/'+items.items[0].id,{headers})).json();
|
|
const progress=await (await fetch(origin+'/api/v1/media/parts/'+detail.editions[0].parts[0].id+'/progress',{headers})).json();assert.ok(progress.position>0,'real playback progress persisted');
|
|
assert.equal((await (await fetch(origin+'/api/v1/media/queue',{headers})).json()).items.length,1);
|
|
assert.equal((await (await fetch(origin+'/api/v1/media/favorites',{headers})).json()).items.length,1);
|
|
// Generate a real VP8/WebM fixture in Chromium; no external files or codecs required.
|
|
const videoBytes=await page.evaluate(async()=>{
|
|
const canvas=document.createElement('canvas');canvas.width=320;canvas.height=180;
|
|
const context=canvas.getContext('2d');const stream=canvas.captureStream(10);
|
|
const recorder=new MediaRecorder(stream,{mimeType:'video/webm;codecs=vp8'}),chunks=[];
|
|
recorder.ondataavailable=event=>{if(event.data.size)chunks.push(event.data);};
|
|
const done=new Promise(resolve=>{recorder.onstop=resolve;});
|
|
let frame=0;const timer=setInterval(()=>{context.fillStyle=frame++%2?'#294535':'#57744c';context.fillRect(0,0,320,180);},100);
|
|
recorder.start();await new Promise(resolve=>setTimeout(resolve,3000));recorder.stop();await done;
|
|
clearInterval(timer);stream.getTracks().forEach(track=>track.stop());
|
|
return Array.from(new Uint8Array(await new Blob(chunks,{type:'video/webm'}).arrayBuffer()));
|
|
});
|
|
const videoRoot=join(root,'video');await mkdir(videoRoot);
|
|
await writeFile(join(videoRoot,'Subtitle Test.webm'),Buffer.from(videoBytes));
|
|
await writeFile(join(videoRoot,'Subtitle Test.zh-CN.srt'),'1\n00:00:00,000 --> 00:00:30,000\n本地字幕测试');
|
|
const videoLibraryResponse=await fetch(origin+'/api/v1/media/libraries',{method:'POST',headers,body:JSON.stringify({name:'字幕测试',kind:'video',root:videoRoot,access:'all'})});
|
|
assert.equal(videoLibraryResponse.status,201);const videoLibrary=await videoLibraryResponse.json();
|
|
const videoScan=await (await fetch(origin+`/api/v1/media/libraries/${videoLibrary.id}/scan`,{method:'POST',headers,body:'{}'})).json();
|
|
for(let i=0;i<350;i++){job=await (await fetch(origin+'/api/v1/media/jobs/'+videoScan.id,{headers})).json();if(job.state!=='running')break;await delay(100);}assert.equal(job.state,'complete');
|
|
await page.evaluate(()=>{location.hash='#/media/music';});
|
|
await page.getByRole('button',{name:'搜索影音',exact:true}).click();
|
|
await page.getByRole('textbox',{name:'搜索影音'}).fill('Subtitle Test');
|
|
await page.locator('.media-search button').click();
|
|
await page.getByText('没有匹配内容,请调整搜索词或搜索范围。',{exact:true}).waitFor();
|
|
await page.getByRole('navigation',{name:'搜索范围',exact:true}).getByRole('button',{name:'全部',exact:true}).click();
|
|
await page.locator('.media-search-result').first().click();
|
|
await page.waitForFunction(()=>location.hash.startsWith('#/media/video/'));
|
|
await page.getByRole('button',{name:'← 返回',exact:true}).click();
|
|
await page.waitForFunction(()=>location.hash==='#/media/music');
|
|
await page.getByRole('textbox',{name:'搜索影音'}).waitFor();
|
|
assert.equal(await page.getByRole('navigation',{name:'搜索范围',exact:true}).getByRole('button',{name:'全部',exact:true}).getAttribute('aria-pressed'),'true');
|
|
assert.ok(await page.getByRole('textbox',{name:'搜索影音'}).inputValue());
|
|
await page.locator('.media-search-result').first().click();
|
|
await page.waitForFunction(()=>location.hash.startsWith('#/media/video/'));
|
|
await page.getByText('文件与技术信息',{exact:true}).click();
|
|
await page.locator('.media-resource-info summary').first().click();
|
|
await page.getByText('能否直放取决于设备解码能力。当前不提供转码。',{exact:true}).waitFor();
|
|
assert.ok((await page.locator('.media-resource-info').first().textContent()).includes('文件可用'));
|
|
await page.getByRole('button',{name:'管理元数据',exact:true}).click();
|
|
await page.getByLabel('年份',{exact:true}).fill('2025');
|
|
await page.getByRole('button',{name:'保存修改',exact:true}).click();
|
|
await page.getByRole('button',{name:'保存修改',exact:true}).waitFor({state:'visible'});
|
|
await page.getByText('已保存修改。',{exact:true}).waitFor();
|
|
const metadataItemId=await page.evaluate(()=>decodeURIComponent(location.hash.split('/').at(-1)));
|
|
assert.equal((await(await fetch(origin+'/api/v1/media/items/'+metadataItemId,{headers})).json()).overrides.year,2025);
|
|
await page.getByRole('button',{name:'撤销人工修正',exact:true}).click();
|
|
await page.getByRole('button',{name:'保存修改',exact:true}).click();
|
|
await page.getByRole('button',{name:'返回作品详情',exact:true}).click();
|
|
await page.waitForFunction(()=>document.querySelector('.media-detail-year')?.textContent!=='2025');
|
|
assert.equal(Object.hasOwn((await(await fetch(origin+'/api/v1/media/items/'+metadataItemId,{headers})).json()).overrides,'year'),false);
|
|
await page.getByRole('button',{name:'播放',exact:true}).click();
|
|
await page.waitForFunction(()=>{const video=document.querySelector('.media-player video');return video&&video.currentTime>0&&video.videoWidth===320;});
|
|
assert.equal(await page.locator('.media-player video').evaluate(video=>video.playbackRate),1.75);
|
|
for(const viewport of [{width:1366,height:768},{width:844,height:390}]){
|
|
await page.setViewportSize(viewport);
|
|
const layout=await page.evaluate(()=>{
|
|
const rect=selector=>{const r=document.querySelector(selector).getBoundingClientRect();return {left:r.left,right:r.right,top:r.top,bottom:r.bottom,height:r.height};};
|
|
return {viewport:[innerWidth,innerHeight],video:rect('.media-player video'),controls:rect('.media-playback-status')};
|
|
});
|
|
assert.ok(layout.video.left>=0&&layout.video.right<=viewport.width&&layout.video.top>=0&&layout.video.bottom<=viewport.height,'video remains visible after viewport change');
|
|
assert.ok(layout.controls.height>=180,'transport has a usable scroll viewport');
|
|
if(viewport.height<500){
|
|
assert.ok(layout.video.right<=layout.controls.left,'landscape video and controls do not overlap');
|
|
assert.equal(await page.locator('.media-player').evaluate(controls=>{controls.scrollTop=controls.scrollHeight;return controls.scrollHeight<=controls.clientHeight||controls.scrollTop>0;}),true);
|
|
if(process.env.MEDIA_SMOKE_LANDSCAPE_SCREENSHOT)await page.screenshot({path:process.env.MEDIA_SMOKE_LANDSCAPE_SCREENSHOT});
|
|
await page.locator('.media-player').evaluate(controls=>{controls.scrollTop=0;});
|
|
}
|
|
}
|
|
await page.setViewportSize({width:390,height:844});
|
|
assert.equal(await page.locator('.media-player video').evaluate(video=>{const r=video.getBoundingClientRect();return r.top>=0&&r.bottom<=innerHeight&&r.height>100;}),true,'decoded video is visible without document scrolling');
|
|
if(process.env.MEDIA_SMOKE_VIDEO_SCREENSHOT){
|
|
await page.screenshot({path:process.env.MEDIA_SMOKE_VIDEO_SCREENSHOT});
|
|
console.log(JSON.stringify(await page.evaluate(()=>{
|
|
const box=selector=>{const node=document.querySelector(selector),r=node.getBoundingClientRect();return {top:r.top,bottom:r.bottom,height:r.height,client:node.clientHeight,scroll:node.scrollHeight,overflow:getComputedStyle(node).overflowY};};
|
|
return {viewport:innerHeight,app:box('#app'),screen:box('.media-screen'),video:box('.media-player video')};
|
|
})));
|
|
}
|
|
assert.equal(await page.getByRole('combobox',{name:'倍速',exact:true}).inputValue(),'1.75');
|
|
await page.getByRole('button',{name:'更多播放选项',exact:true}).click();
|
|
assert.equal(await page.getByRole('combobox',{name:'定时',exact:true}).locator('option:checked').textContent(),'15 分钟');
|
|
await page.getByRole('combobox',{name:'定时',exact:true}).selectOption('0');
|
|
assert.equal(await page.getByRole('combobox',{name:'定时',exact:true}).locator('option:checked').textContent(),'定时');
|
|
await page.locator('dialog[open] .sheet-close').last().click();
|
|
await page.getByRole('button',{name:'字幕',exact:true}).click();
|
|
// Exercise native TextTrack events; this validates controls, not container demux support.
|
|
await page.locator('.media-player video').evaluate(video=>{
|
|
const track=video.addTextTrack('subtitles','浏览器字幕轨','zh');
|
|
track.addCue(new VTTCue(0,30,'浏览器字幕轨测试'));track.mode='disabled';
|
|
});
|
|
await page.getByRole('combobox',{name:'字幕',exact:true}).selectOption({label:'浏览器字幕轨 · 内嵌'});
|
|
await page.waitForFunction(()=>document.querySelector('.media-player video').textTracks[0]?.mode==='showing');
|
|
await page.getByRole('combobox',{name:'字幕',exact:true}).selectOption({label:'zh-CN · SRT · 外置'});
|
|
try{await page.waitForFunction(()=>{const tracks=Array.from(document.querySelector('.media-player video').textTracks);return tracks.some(track=>track.mode==='showing'&&track.cues?.[0]?.text==='本地字幕测试')&&tracks.find(track=>track.label==='浏览器字幕轨')?.mode==='disabled';});}
|
|
catch(error){console.error(await page.locator('.media-player video').evaluate(video=>({tracks:Array.from(video.textTracks).map(track=>({label:track.label,mode:track.mode,cues:Array.from(track.cues||[]).map(cue=>cue.text)})),controls:document.querySelector('.media-subtitle-controls')?.textContent,selected:document.querySelector('[aria-label="字幕"]')?.value})));throw error;}
|
|
await page.getByRole('combobox',{name:'字幕',exact:true}).selectOption('');
|
|
assert.equal(await page.locator('.media-player video track').count(),0);
|
|
await page.evaluate(()=>{location.hash='#/media/music';});
|
|
await page.getByRole('region',{name:'最近续播'}).waitFor();
|
|
const continuation=page.getByRole('region',{name:'最近续播'});
|
|
assert.ok(await page.locator('.media-screen').evaluate(screen=>screen.getBoundingClientRect().width>=innerWidth-32),'mobile content uses available width even with few items');
|
|
const scrollProbe=await page.locator('.media-screen').evaluate(screen=>{
|
|
const spacer=document.createElement('div');spacer.style.height='2000px';screen.append(spacer);
|
|
const overflow=getComputedStyle(screen).overflowY;screen.scrollTop=screen.scrollHeight;
|
|
const reachable=screen.scrollTop>0&&screen.getBoundingClientRect().bottom<=innerHeight;
|
|
spacer.remove();screen.scrollTop=0;return {overflow,reachable};
|
|
});
|
|
assert.equal(scrollProbe.overflow,'auto');assert.equal(scrollProbe.reachable,true,'long media content has a user-scrollable viewport');
|
|
const themeCheck=await page.evaluate(()=>{
|
|
const root=document.documentElement,previous=root.dataset.theme;
|
|
root.dataset.theme='dark';
|
|
const screen=getComputedStyle(document.querySelector('.media-screen'));
|
|
const button=getComputedStyle(document.querySelector('.media-continue button'));
|
|
const channel=getComputedStyle(document.querySelector('.media-channel-entry'));
|
|
const result={text:screen.color,button:button.backgroundColor,channel:channel.backgroundColor,scheme:screen.colorScheme};
|
|
if(previous===undefined)delete root.dataset.theme;else root.dataset.theme=previous;
|
|
return result;
|
|
});
|
|
assert.equal(themeCheck.text,'rgb(233, 238, 223)');
|
|
assert.equal(themeCheck.button,'rgb(177, 198, 155)');
|
|
assert.equal(themeCheck.channel,'rgb(30, 36, 28)');
|
|
assert.equal(themeCheck.scheme,'dark');
|
|
if(process.env.MEDIA_SMOKE_DARK_SCREENSHOT){
|
|
const previous=await page.evaluate(()=>{const old=document.documentElement.dataset.theme;document.documentElement.dataset.theme='dark';return old??null;});
|
|
await page.screenshot({path:process.env.MEDIA_SMOKE_DARK_SCREENSHOT});
|
|
await page.evaluate(old=>{if(old===null)delete document.documentElement.dataset.theme;else document.documentElement.dataset.theme=old;},previous);
|
|
}
|
|
assert.equal(await continuation.evaluate(row=>{const r=row.getBoundingClientRect();return r.left>=0&&r.right<=innerWidth;}),true);
|
|
if(process.env.MEDIA_SMOKE_CONTINUE_SCREENSHOT)await page.screenshot({path:process.env.MEDIA_SMOKE_CONTINUE_SCREENSHOT});
|
|
await continuation.getByRole('button',{name:'续播',exact:true}).click();
|
|
await page.waitForFunction(()=>{const a=document.querySelector('.media-player audio');return a&&!a.paused&&a.currentTime>10;});
|
|
await page.locator('.media-actions summary').click();
|
|
await page.getByRole('button',{name:'队列',exact:true}).click();
|
|
await page.getByRole('button',{name:'清空当前频道队列',exact:true}).click();
|
|
await page.getByRole('button',{name:'保留队列',exact:true}).click();
|
|
assert.equal((await (await fetch(origin+'/api/v1/media/queue',{headers})).json()).items.length,1);
|
|
await page.getByRole('button',{name:'清空当前频道队列',exact:true}).click();
|
|
await page.getByRole('button',{name:'确认清空',exact:true}).click();
|
|
await page.getByText('暂无记录。',{exact:true}).waitFor();
|
|
assert.equal((await (await fetch(origin+'/api/v1/media/queue',{headers})).json()).items.length,0);
|
|
// Exercise real files through series hierarchy and audiobook file chapters.
|
|
const importLibrary=async(name,kind,folder)=>{
|
|
const response=await fetch(origin+'/api/v1/media/libraries',{method:'POST',headers,body:JSON.stringify({name,kind,root:folder,access:'all'})});
|
|
assert.equal(response.status,201);const library=await response.json();
|
|
const started=await fetch(origin+'/api/v1/media/libraries/'+library.id+'/scan',{method:'POST',headers,body:'{}'});
|
|
assert.equal(started.status,202);const task=await started.json();let state;
|
|
for(let i=0;i<350;i++){state=await(await fetch(origin+'/api/v1/media/jobs/'+task.id,{headers})).json();if(state.state!=='running')break;await delay(100);}
|
|
assert.equal(state.state,'complete');return library;
|
|
};
|
|
const albumRoot=join(root,'album-playback');await mkdir(albumRoot);
|
|
await writeFile(join(albumRoot,'artist.nfo'),'<artist><name>测试歌手</name><biography>这段艺人简介来自本地 artist.nfo,用于验证独立人物资料。</biography></artist>');
|
|
await writeFile(join(albumRoot,'artist.png'),Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jv1sAAAAASUVORK5CYII=','base64'));
|
|
for(const [name,disc,track]of [['last',2,1],['first',1,1],['second',1,2]]){
|
|
await writeFile(join(albumRoot,name+'.wav'),wav);
|
|
await writeFile(join(albumRoot,name+'.nfo'),`<album><title>${name}</title><album>连续播放专辑</album><artist>测试歌手</artist><disc>${disc}</disc><track>${track}</track></album>`);
|
|
}
|
|
const albumLibrary=await importLibrary('专辑连播测试','music',albumRoot);
|
|
await page.evaluate(()=>{location.hash='#/media/music';});
|
|
await page.reload();
|
|
await page.getByRole('button',{name:'搜索影音',exact:true}).click();
|
|
await page.getByRole('textbox',{name:'搜索影音'}).fill('first');
|
|
await page.locator('.media-search button').click();
|
|
await page.locator('.media-search-description').filter({hasText:'测试歌手'}).waitFor();
|
|
await page.getByRole('combobox',{name:'内容类型',exact:true}).selectOption('artist');
|
|
await page.getByText('没有匹配内容,请调整搜索词或搜索范围。',{exact:true}).waitFor();
|
|
await auditPage('search-type-empty');
|
|
await page.getByRole('combobox',{name:'内容类型',exact:true}).selectOption('track');
|
|
await page.locator('.media-search-description').filter({hasText:'测试歌手'}).waitFor();
|
|
await auditPage('search-results');
|
|
await page.reload();
|
|
await page.getByRole('button',{name:'歌手',exact:true}).click();
|
|
await page.locator('.media-grid .media-tile').filter({hasText:'测试歌手'}).waitFor();
|
|
await auditPage('artists');
|
|
await page.locator('.media-grid .media-tile').filter({hasText:'测试歌手'}).click();
|
|
await page.getByRole('heading',{name:'全部专辑',exact:true}).waitFor();
|
|
assert.equal(await page.locator('.media-hero .media-portrait').count(),1);
|
|
await page.getByText('这段艺人简介来自本地 artist.nfo,用于验证独立人物资料。',{exact:true}).waitFor();
|
|
await page.waitForFunction(()=>{const image=document.querySelector('.media-hero .media-portrait img');return image?.complete&&image.naturalWidth>0;});
|
|
assert.equal(await page.locator('.media-detail-kind').textContent(),'歌手');
|
|
assert.equal(await page.locator('.media-detail-heading').textContent(),'歌手测试歌手','artist heading does not repeat the artist as its own credit');
|
|
await auditPage('artist-detail');
|
|
// Synthetic long metadata exercises layout only; it is not live provider evidence.
|
|
const artistDetailPattern='**/api/v1/media/items/'+await page.evaluate(()=>decodeURIComponent(location.hash.split('/').at(-1)));
|
|
const artistInfoFixture=async route=>{
|
|
const response=await route.fetch(),body=await response.json();
|
|
assert.equal(response.status(),200);
|
|
body.metadata={...body.metadata,artistType:'Group',artistArea:'United Kingdom / 示例关联地区',artistDisambiguation:'视觉测试资料:这是用于区分同名艺人的较长说明,包含中文、English 和录音版本信息;不代表人物简介或真实来源验收。'};
|
|
await route.fulfill({response,json:body});
|
|
};
|
|
await page.route(artistDetailPattern,artistInfoFixture);
|
|
await page.reload();
|
|
const artistInfo=page.getByRole('region',{name:'艺人资料'});
|
|
await artistInfo.waitFor();
|
|
await auditPage('artist-info-fixture',artistInfo);
|
|
await page.unroute(artistDetailPattern,artistInfoFixture);
|
|
const albumItems=await(await fetch(origin+'/api/v1/media/libraries/'+albumLibrary.id+'/items?kind=album',{headers})).json();
|
|
let albumReadFails=true;
|
|
await page.route('**/items/*/album-playback',async route=>{
|
|
if(albumReadFails){albumReadFails=false;await route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({error:{code:'TEMPORARY',message:'暂时无法读取专辑,请重试'}})});}
|
|
else await route.continue();
|
|
});
|
|
await page.evaluate(id=>{location.hash='#/media/music/'+id;},albumItems.items[0].id);
|
|
await page.locator('.media-album-playback [role=alert]').waitFor();
|
|
assert.equal(await page.getByText('没有匹配的曲目。',{exact:true}).count(),0);
|
|
await auditPage('album-read-error',page.locator('.media-album-playback [role=alert]'));
|
|
await page.locator('.media-album-playback').getByRole('button',{name:'重新读取',exact:true}).click();
|
|
await page.getByRole('button',{name:'顺序播放 3 首',exact:true}).waitFor();
|
|
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false);
|
|
if(process.env.MEDIA_SMOKE_ALBUM_SCREENSHOT)await page.screenshot({path:process.env.MEDIA_SMOKE_ALBUM_SCREENSHOT});
|
|
await auditPage('album');
|
|
if(visualDir){
|
|
const titles=await page.locator('.media-album-track-title').allTextContents();
|
|
await page.locator('.media-album-track-title').first().evaluate(node=>{node.textContent='LongAlbumTrackTitleWithoutSpaces'.repeat(4)+' · 超长曲目标题布局验证';});
|
|
await auditPage('album-long-title');
|
|
await page.locator('.media-album-track-title').evaluateAll((nodes,titles)=>nodes.forEach((node,index)=>{node.textContent=titles[index];}),titles);
|
|
}
|
|
await page.getByRole('button',{name:'顺序播放 3 首',exact:true}).click();
|
|
await page.waitForFunction(()=>{const a=document.querySelector('.media-player audio');return a&&!a.paused&&a.currentTime>.1;});
|
|
await page.getByRole('button',{name:'打开播放控制',exact:true}).click();
|
|
await page.waitForFunction(()=>document.querySelector('.media-playing-title')?.textContent==='first');
|
|
await page.locator('.media-player audio').evaluate(a=>{a.currentTime=a.duration-.2;});
|
|
await page.waitForFunction(()=>document.querySelector('.media-playing-title')?.textContent==='second');
|
|
await page.getByRole('button',{name:'下一首 / 章',exact:true}).click();
|
|
await page.waitForFunction(()=>document.querySelector('.media-playing-title')?.textContent==='last');
|
|
await stopPlayback();
|
|
await page.evaluate(()=>{location.hash='#/media/music';});
|
|
await page.getByRole('combobox',{name:'媒体库',exact:true}).selectOption('');
|
|
await page.waitForFunction(()=>document.querySelectorAll('.media-grid .media-tile').length===2);
|
|
assert.equal(await page.locator('.media-grid').getByText('专辑连播测试',{exact:true}).count(),1);
|
|
assert.equal(await page.locator('.media-grid').getByText('测试音乐库',{exact:true}).count(),1);
|
|
await page.getByRole('combobox',{name:'媒体库',exact:true}).selectOption(albumLibrary.id);
|
|
await page.waitForFunction(()=>document.querySelectorAll('.media-grid .media-tile').length===1);
|
|
await page.getByRole('combobox',{name:'媒体库',exact:true}).selectOption('');
|
|
await page.waitForFunction(()=>document.querySelectorAll('.media-grid .media-tile').length===2);
|
|
if(process.env.MEDIA_SMOKE_ALL_LIBRARIES_SCREENSHOT)await page.screenshot({path:process.env.MEDIA_SMOKE_ALL_LIBRARIES_SCREENSHOT});
|
|
await auditPage('all-libraries');
|
|
await page.getByRole('button',{name:'文件夹',exact:true}).click();
|
|
await page.getByRole('region',{name:'选择文件夹媒体库',exact:true}).getByRole('button',{name:'专辑连播测试',exact:true}).click();
|
|
await page.locator('.media-folders .media-row').first().waitFor();
|
|
const seriesRoot=join(root,'series');await mkdir(seriesRoot);
|
|
for(const episode of ['01','02'])await writeFile(join(seriesRoot,'BrowserDrama.S01E'+episode+'.webm'),Buffer.from(videoBytes));
|
|
await writeFile(join(seriesRoot,'BrowserDrama.S02E01.webm'),Buffer.from(videoBytes));
|
|
const seriesLibrary=await importLibrary('剧集测试','video',seriesRoot);
|
|
const seriesItems=await(await fetch(origin+'/api/v1/media/libraries/'+seriesLibrary.id+'/items?kind=series',{headers})).json();
|
|
assert.equal(seriesItems.items.length,1);
|
|
await page.evaluate(()=>{location.hash='#/media/video';});
|
|
await page.getByRole('button',{name:'剧集',exact:true}).click();
|
|
await page.locator('.media-grid .media-tile').filter({hasText:'BrowserDrama'}).waitFor();
|
|
await auditPage('series-home');
|
|
await page.evaluate(id=>{location.hash='#/media/video/'+id;},seriesItems.items[0].id);
|
|
await page.getByRole('heading',{name:'选集',exact:true}).waitFor();
|
|
await page.locator('.media-episode-grid button').first().waitFor();
|
|
await auditPage('series-season-picker');
|
|
let seriesReadFails=true;
|
|
await page.route('**/items/*/series-playback',async route=>{
|
|
if(seriesReadFails){seriesReadFails=false;await route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({error:{code:'TEMPORARY',message:'暂时无法读取选集,请重试'}})});}
|
|
else await route.continue();
|
|
});
|
|
await page.getByText('跨季顺序播放',{exact:true}).click();
|
|
await page.locator('.media-season-playback [role=alert]').waitFor();
|
|
assert.equal(await page.getByText('暂无剧集',{exact:true}).count(),0);
|
|
await auditPage('series-read-error',page.locator('.media-season-playback [role=alert]'));
|
|
await page.locator('.media-season-playback').getByRole('button',{name:'重新读取',exact:true}).click();
|
|
await page.getByRole('button',{name:'顺序播放 3 集',exact:true}).waitFor();
|
|
await auditPage('series-detail');
|
|
const wholeSeriesPlan=await(await fetch(origin+'/api/v1/media/items/'+seriesItems.items[0].id+'/series-playback',{headers})).json();
|
|
const secondSeasonRequest=page.waitForRequest(request=>request.url()===origin+'/api/v1/media/playback'&&request.method()==='POST'&&request.postDataJSON().partId===wholeSeriesPlan.episodes[2].editions[0].parts[0].id);
|
|
await page.getByRole('button',{name:'顺序播放 3 集',exact:true}).click();
|
|
await secondSeasonRequest;
|
|
await page.waitForFunction(title=>document.querySelector('.media-playback-status strong')?.textContent.includes(title),wholeSeriesPlan.episodes[2].title);
|
|
await stopPlayback();
|
|
await page.getByRole('button',{name:'查看本季详情',exact:true}).click();
|
|
await page.getByRole('heading',{name:'选集',exact:true}).waitFor();
|
|
await page.getByRole('button',{name:'顺序播放 2 集',exact:true}).waitFor();
|
|
assert.equal(await page.locator('.media-season-row').count(),2);
|
|
await auditPage('season-detail');
|
|
const seasonId=await page.evaluate(()=>decodeURIComponent(location.hash.split('/').at(-1)));
|
|
const orderedSeason=await(await fetch(origin+'/api/v1/media/items/'+seasonId+'/season-playback',{headers})).json();
|
|
const nextEpisodeRequest=page.waitForRequest(request=>request.url()===origin+'/api/v1/media/playback'&&request.method()==='POST'&&request.postDataJSON().partId===orderedSeason.episodes[1].editions[0].parts[0].id);
|
|
await page.getByRole('button',{name:'顺序播放 2 集',exact:true}).click();
|
|
await nextEpisodeRequest;
|
|
await page.waitForFunction(title=>document.querySelector('.media-playback-status strong')?.textContent.includes(title),orderedSeason.episodes[1].title);
|
|
const firstEpisodeProgress=await(await fetch(origin+'/api/v1/media/parts/'+orderedSeason.episodes[0].editions[0].parts[0].id+'/progress',{headers})).json();
|
|
assert.equal(firstEpisodeProgress.completed,true,'episode completion is saved before the next episode');
|
|
await stopPlayback();
|
|
await page.locator('.media-season-row>button').nth(1).click();
|
|
await page.getByRole('button',{name:'播放',exact:true}).click();
|
|
await page.waitForFunction(()=>{const v=document.querySelector('.media-player video');return v.currentTime>.1&&v.videoWidth===320&&!v.paused;});
|
|
await stopPlayback();
|
|
let embeddedSubtitleDecoded=false,embeddedCoverDecoded=false;
|
|
if(process.env.MEDIA_SMOKE_FFMPEG){
|
|
const multiRoot=join(root,'multi-audio');await mkdir(multiRoot);
|
|
await runFile(process.env.MEDIA_SMOKE_FFMPEG,['-nostdin','-loglevel','error',
|
|
'-f','lavfi','-i','color=c=black:s=320x180:d=30',
|
|
'-f','lavfi','-i','sine=frequency=440:duration=30:sample_rate=48000',
|
|
'-f','lavfi','-i','sine=frequency=880:duration=30:sample_rate=48000',
|
|
'-map','0:v','-map','1:a','-map','2:a','-c:v','libx264','-pix_fmt','yuv420p','-c:a','aac',
|
|
'-metadata:s:a:0','language=zho','-metadata:s:a:1','language=eng',join(multiRoot,'Two Audio Tracks.mp4')],{windowsHide:true,timeout:30000});
|
|
const multiLibrary=await importLibrary('双音轨测试','video',multiRoot);
|
|
const multiItems=await(await fetch(origin+'/api/v1/media/libraries/'+multiLibrary.id+'/items?kind=movie',{headers})).json();
|
|
await page.evaluate(id=>{location.hash='#/media/video/'+id;},multiItems.items[0].id);
|
|
await page.getByRole('button',{name:'播放',exact:true}).click();
|
|
await page.waitForFunction(()=>{const video=document.querySelector('.media-player video');return video.currentTime>0&&video.videoWidth===320;});
|
|
await page.getByRole('button',{name:'音轨',exact:true}).click();
|
|
const decoderTrackCount=await page.locator('.media-player video').evaluate(video=>video.audioTracks?.length??0);
|
|
if(decoderTrackCount<2){
|
|
await page.getByText('文件包含多个音轨,此浏览器未开放切换;可选择其他版本或设备。',{exact:true}).waitFor();
|
|
assert.equal(await page.locator('select[aria-label="音轨"] option').count(),0,'never fabricate selectable tracks from probe metadata');
|
|
}else{
|
|
assert.equal(await page.getByRole('combobox',{name:'音轨',exact:true}).locator('option').count(),decoderTrackCount);
|
|
}
|
|
console.log(JSON.stringify({multiAudioDecoded:true,decoderTrackCount,unavailableSwitchingExplained:decoderTrackCount<2}));
|
|
await stopPlayback();
|
|
const embeddedRoot=join(root,'embedded-subtitles');await mkdir(embeddedRoot);
|
|
const source=join(root,'embedded.srt');await writeFile(source,'1\n00:00:00,000 --> 00:00:30,000\n内嵌字幕端到端测试\n');
|
|
await runFile(process.env.MEDIA_SMOKE_FFMPEG,['-nostdin','-hide_banner','-loglevel','error','-f','lavfi','-i','color=c=black:s=320x180:d=30','-i',source,'-c:v','libx264','-pix_fmt','yuv420p','-c:s','mov_text','-metadata:s:s:0','language=zho',join(embeddedRoot,'Embedded Subtitle.mp4')],{windowsHide:true,timeout:30000});
|
|
const library=await importLibrary('内嵌字幕测试','video',embeddedRoot);
|
|
const items=await(await fetch(origin+'/api/v1/media/libraries/'+library.id+'/items?kind=movie',{headers})).json();assert.equal(items.items.length,1);
|
|
await page.evaluate(id=>{location.hash='#/media/video/'+id;},items.items[0].id);
|
|
await page.getByRole('button',{name:'播放',exact:true}).waitFor();
|
|
await auditPage('movie-detail');
|
|
await page.getByRole('button',{name:'管理元数据',exact:true}).click();
|
|
const metadataForm=page.locator('form').filter({has:page.getByRole('heading',{name:'编辑元数据',exact:true})});
|
|
await auditPage('movie-metadata',metadataForm.getByRole('heading'));
|
|
const plot=metadataForm.getByLabel('简介',{exact:true});
|
|
await plot.fill('移动端表单验证:保存后的人工简介。');
|
|
await plot.focus();await page.keyboard.press('Tab');
|
|
const metadataSave=metadataForm.getByRole('button',{name:'保存修改',exact:true});
|
|
assert.equal(await metadataSave.evaluate(button=>button===document.activeElement),true,'metadata save follows the final textarea in keyboard order');
|
|
await auditPage('movie-metadata-actions',metadataSave);
|
|
await metadataSave.focus();
|
|
const metadataSaved=page.waitForResponse(response=>response.request().method()==='PATCH'&&response.url().endsWith('/metadata')&&response.status()===200);
|
|
await page.keyboard.press('Enter');await metadataSaved;
|
|
const savedMetadata=await(await fetch(origin+'/api/v1/media/items/'+items.items[0].id,{headers})).json();
|
|
assert.equal(savedMetadata.overrides.plot,'移动端表单验证:保存后的人工简介。','keyboard submission persists metadata');
|
|
await page.getByRole('button',{name:'返回作品详情',exact:true}).click();
|
|
await page.getByRole('button',{name:'播放',exact:true}).click();
|
|
await page.waitForFunction(()=>{const video=document.querySelector('.media-player video');return video.currentTime>0&&video.videoWidth===320;});
|
|
await page.getByRole('button',{name:'字幕',exact:true}).click();
|
|
await page.getByRole('combobox',{name:'字幕',exact:true}).selectOption({label:'zho · VTT · 内嵌提取'});
|
|
await page.waitForFunction(()=>Array.from(document.querySelector('.media-player video').textTracks).some(track=>track.mode==='showing'&&Array.from(track.activeCues||[]).some(cue=>cue.text==='内嵌字幕端到端测试')));
|
|
await page.getByRole('combobox',{name:'字幕',exact:true}).selectOption('');
|
|
assert.equal(await page.locator('.media-player video track').count(),0);
|
|
embeddedSubtitleDecoded=true;await stopPlayback();
|
|
const coverRoot=join(root,'embedded-cover');await mkdir(coverRoot);
|
|
const image=join(root,'embedded-art.png');
|
|
await runFile(process.env.MEDIA_SMOKE_FFMPEG,['-nostdin','-loglevel','error','-f','lavfi','-i','color=c=red:s=32x32','-frames:v','1',image],{windowsHide:true,timeout:30000});
|
|
await runFile(process.env.MEDIA_SMOKE_FFMPEG,['-nostdin','-loglevel','error','-f','lavfi','-i','sine=duration=30','-i',image,'-map','0:a','-map','1:v','-c:a','libmp3lame','-c:v','copy','-disposition:v','attached_pic','-metadata','album=内嵌封面专辑',join(coverRoot,'Embedded Cover.mp3')],{windowsHide:true,timeout:30000});
|
|
const coverLibrary=await importLibrary('内嵌封面测试','music',coverRoot);
|
|
const albums=await(await fetch(origin+'/api/v1/media/libraries/'+coverLibrary.id+'/items?kind=album',{headers})).json();assert.equal(albums.items.length,1);
|
|
assert.ok(albums.items[0].metadata.embeddedCoverAssetId);
|
|
await page.evaluate(id=>{location.hash='#/media/music/'+id;},albums.items[0].id);
|
|
await page.waitForFunction(()=>Array.from(document.querySelectorAll('.media-cover img')).some(img=>img.complete&&img.naturalWidth===32&&img.naturalHeight===32));
|
|
embeddedCoverDecoded=true;
|
|
}
|
|
const audiobookRoot=join(root,'audiobooks'),bookFolder=join(audiobookRoot,'浏览器有声作品');await mkdir(bookFolder,{recursive:true});
|
|
if(process.env.MEDIA_SMOKE_FFMPEG){
|
|
assert.ok(process.env.MEDIA_FFPROBE_PATH,'embedded chapter mode requires an explicit ffprobe');
|
|
const input=join(root,'chapter-input.wav'),metadata=join(root,'chapters.txt');
|
|
await writeFile(input,wav);
|
|
await writeFile(metadata,';FFMETADATA1\nalbum=浏览器有声作品\n[CHAPTER]\nTIMEBASE=1/1000\nSTART=0\nEND=15000\ntitle=01\n[CHAPTER]\nTIMEBASE=1/1000\nSTART=15000\nEND=30000\ntitle=02\n');
|
|
await runFile(process.env.MEDIA_SMOKE_FFMPEG,['-hide_banner','-loglevel','error','-y','-i',input,'-i',metadata,'-map','0:a','-map_metadata','1','-map_chapters','1','-c:a','aac','-b:a','64k',join(bookFolder,'book.m4b')],{windowsHide:true,timeout:30000});
|
|
}else for(const chapter of ['01','02'])await writeFile(join(bookFolder,chapter+'.wav'),wav);
|
|
await writeFile(join(bookFolder,'audiobook.nfo'),'<audiobook><narrator>浏览器演播者</narrator></audiobook>');
|
|
const audiobookLibrary=await importLibrary('有声书测试','audiobook',audiobookRoot);
|
|
const bookItems=await(await fetch(origin+'/api/v1/media/libraries/'+audiobookLibrary.id+'/items?kind=audiobook',{headers})).json();
|
|
assert.equal(bookItems.items.length,1);
|
|
const bookDetail=await(await fetch(origin+'/api/v1/media/items/'+bookItems.items[0].id,{headers})).json();
|
|
assert.equal(bookDetail.editions.length,1);assert.equal(bookDetail.editions[0].parts.length,2);
|
|
const chapters=bookDetail.editions[0].parts;assert.deepEqual(chapters.map(p=>p.title),['01','02']);
|
|
if(process.env.MEDIA_SMOKE_FFMPEG){assert.equal(chapters[0].assetId,chapters[1].assetId);assert.equal(chapters[0].end,15);assert.equal(chapters[1].start,15);assert.equal(chapters[1].end,30);}
|
|
const startedParts=[];page.on('request',request=>{if(request.url()===origin+'/api/v1/media/playback'&&request.method()==='POST')startedParts.push(request.postDataJSON().partId);});
|
|
await page.evaluate(()=>{location.hash='#/media/audiobook';});
|
|
await page.locator('.media-grid .media-tile').first().waitFor();
|
|
await auditPage('audiobook-home');
|
|
await page.getByRole('button',{name:'文件夹',exact:true}).click();
|
|
await page.locator('.media-folders .media-row button').first().click();
|
|
await page.locator('.media-folders .media-row button').first().click();
|
|
await page.locator('.media-folders .media-edition').waitFor();
|
|
assert.equal(await page.locator('.media-folders .media-edition .media-row').count(),process.env.MEDIA_SMOKE_FFMPEG?2:1);
|
|
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false);
|
|
if(process.env.MEDIA_SMOKE_FOLDER_SCREENSHOT)await page.screenshot({path:process.env.MEDIA_SMOKE_FOLDER_SCREENSHOT});
|
|
await auditPage('folder');
|
|
await page.getByRole('button',{name:'返回分类',exact:true}).click();
|
|
await page.getByRole('button',{name:'演播者',exact:true}).click();
|
|
await page.getByRole('button',{name:'浏览器演播者',exact:false}).waitFor();
|
|
await auditPage('narrator-list');
|
|
await page.getByRole('button',{name:'浏览器演播者',exact:false}).click();
|
|
await page.locator('[aria-label="演播者浏览"] .media-tile').click();
|
|
await page.getByText('浏览器演播者参与的版本',{exact:true}).waitFor();
|
|
assert.equal(await page.locator('[aria-label="演播者浏览"] .media-edition').count(),1);
|
|
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false);
|
|
if(process.env.MEDIA_SMOKE_NARRATOR_SCREENSHOT)await page.screenshot({path:process.env.MEDIA_SMOKE_NARRATOR_SCREENSHOT});
|
|
await auditPage('narrator');
|
|
await page.getByRole('button',{name:'播放此版本',exact:true}).click();
|
|
await page.waitForFunction(()=>{const audio=document.querySelector('.media-player audio');return audio&&!audio.paused&&audio.currentTime>.1;});
|
|
await page.getByRole('button',{name:'打开播放控制',exact:true}).click();
|
|
await page.getByRole('button',{name:'← 返回浏览',exact:true}).click();
|
|
await page.locator('.media-narrators[data-view="work"] .media-edition').waitFor();
|
|
await page.getByText('浏览器演播者参与的版本',{exact:true}).waitFor();
|
|
await page.getByRole('button',{name:'打开播放控制',exact:true}).click();
|
|
await stopPlayback();
|
|
assert.deepEqual(startedParts,[chapters[0].id],'narrator playback starts only the selected version');
|
|
startedParts.length=0; // The following assertion covers a separate full-detail chapter run.
|
|
await page.getByRole('button',{name:'完整作品详情',exact:true}).click();
|
|
await page.locator('.media-hero h1').waitFor();
|
|
await auditPage('audiobook-detail');
|
|
await page.getByRole('button',{name:'播放',exact:true}).first().click();
|
|
await page.waitForFunction(()=>{const a=document.querySelector('.media-player audio');return a.currentTime>.1&&!a.paused;});
|
|
await page.getByRole('button',{name:'打开播放控制',exact:true}).click();
|
|
// Let playback cross the chapter boundary (which may precede the file end).
|
|
await page.locator('.media-player audio').evaluate((a,end)=>{a.currentTime=(end??a.duration)-.3;},chapters[0].end);
|
|
await page.waitForFunction(()=>document.querySelector('.media-playing-chapter')?.textContent.includes('02'));
|
|
assert.deepEqual(startedParts,[chapters[0].id,chapters[1].id]);
|
|
await page.waitForFunction(start=>{const a=document.querySelector('.media-player audio');return a.currentTime>start+.2&&!a.paused;},chapters[1].start);
|
|
await page.getByRole('button',{name:'暂停',exact:true}).click();
|
|
await page.evaluate(()=>window.readerApp.flush());
|
|
const firstChapter=await(await fetch(origin+'/api/v1/media/parts/'+chapters[0].id+'/progress',{headers})).json();
|
|
const secondChapter=await(await fetch(origin+'/api/v1/media/parts/'+chapters[1].id+'/progress',{headers})).json();
|
|
assert.equal(firstChapter.completed,true);assert.ok(secondChapter.position>chapters[1].start);assert.equal(secondChapter.completed,false);
|
|
const chapterPosition=page.getByRole('slider',{name:'当前章节播放位置',exact:true});
|
|
assert.equal(await chapterPosition.getAttribute('min'),'0');
|
|
assert.equal(await chapterPosition.getAttribute('max'),String(chapters[1].end-chapters[1].start));
|
|
await chapterPosition.fill('5');await chapterPosition.dispatchEvent('change');
|
|
await page.waitForFunction(start=>Math.abs(document.querySelector('.media-player audio').currentTime-(start+5))<.2,chapters[1].start);
|
|
assert.equal(await page.locator('.media-player audio').evaluate(audio=>audio.controls),false);
|
|
await auditPage('audiobook-player');
|
|
await page.getByRole('button',{name:'全部章节',exact:true}).click();
|
|
await page.locator('.media-audiobook-chapters .media-row').first().waitFor();
|
|
assert.equal(await page.locator('.media-audiobook-chapters .media-row').count(),2);
|
|
assert.ok((await page.locator('.media-audiobook-chapters [aria-current=true]').textContent()).includes('02'));
|
|
await page.getByRole('button',{name:'← 返回播放',exact:true}).click();
|
|
await page.getByLabel('更多播放选项',{exact:true}).click();
|
|
await page.getByRole('button',{name:'上一首 / 章',exact:true}).click();
|
|
await page.waitForFunction(()=>document.querySelector('.media-playing-chapter')?.textContent.includes('01'));
|
|
await page.getByRole('button',{name:'下一首 / 章',exact:true}).click();
|
|
await page.waitForFunction(()=>document.querySelector('.media-playing-chapter')?.textContent.includes('02'));
|
|
assert.equal(await page.getByRole('button',{name:'下一首 / 章',exact:true}).isDisabled(),true);
|
|
await page.getByRole('button',{name:'播放队列',exact:true}).click();
|
|
await page.getByRole('button',{name:'查找播放列表',exact:true}).click();
|
|
await page.getByLabel('查找曲目或章节',{exact:true}).fill('01');
|
|
assert.equal(await page.locator('.media-current-playlist li').count(),1);
|
|
await page.locator('.media-current-playlist .media-playlist-select').click();
|
|
await page.waitForFunction(()=>document.querySelector('.media-current-playlist [aria-current=true]')?.textContent.includes('01'));
|
|
await page.getByRole('button',{name:'定位当前播放',exact:true}).click();
|
|
assert.equal(await page.getByLabel('查找曲目或章节',{exact:true}).inputValue(),'');
|
|
assert.equal(await page.locator('.media-current-playlist li').count(),2);
|
|
assert.equal(await page.locator('.media-current-playlist [aria-current="true"]').isDisabled(),true);
|
|
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false);
|
|
await page.locator('.media-current-playlist .media-playlist-select').nth(1).click();
|
|
await page.waitForFunction(()=>document.querySelector('.media-current-playlist [aria-current=true]')?.textContent.includes('02'));
|
|
const jumpedChapter=await(await fetch(origin+'/api/v1/media/parts/'+chapters[0].id+'/progress',{headers})).json();
|
|
assert.equal(jumpedChapter.completed,false,'manual playlist jump does not complete the outgoing chapter');
|
|
const sourceBeforeEdit=await page.locator('.media-player audio').evaluate(audio=>audio.currentSrc);
|
|
await page.getByRole('button',{name:'编辑播放列表',exact:true}).click();
|
|
await page.locator('.media-current-playlist li').nth(1).getByRole('button',{name:/^上移 /}).click();
|
|
await page.waitForFunction(()=>document.querySelector('.media-current-playlist li:first-child button')?.getAttribute('aria-current')==='true');
|
|
await page.locator('.media-current-playlist li').nth(1).getByRole('button',{name:/^移除 /}).click();
|
|
await page.waitForFunction(()=>document.querySelectorAll('.media-current-playlist li').length===1);
|
|
assert.equal(await page.locator('.media-current-playlist [aria-current="true"]').count(),1,'the remaining current track stays visible');
|
|
assert.equal(await page.locator('.media-player audio').evaluate(audio=>audio.currentSrc),sourceBeforeEdit);
|
|
assert.equal(await page.locator('.media-player audio').evaluate(audio=>audio.paused),false,'editing preserves active playback');
|
|
await page.getByRole('button',{name:'← 返回播放',exact:true}).click();
|
|
await page.locator('.media-playing-artwork').waitFor();
|
|
assert.equal(await page.locator('.media-player audio').evaluate(audio=>audio.currentSrc),sourceBeforeEdit,'returning to playback preserves the media session');
|
|
await page.getByLabel('更多播放选项',{exact:true}).click();
|
|
await page.getByRole('button',{name:'保存为待播队列',exact:true}).click();
|
|
await page.getByRole('button',{name:'确认保存队列',exact:true}).click();
|
|
await page.getByText('已保存,可在“更多 → 队列”中继续播放。',{exact:true}).waitFor();
|
|
const savedLiveQueue=await(await fetch(origin+'/api/v1/media/queue',{headers})).json();
|
|
assert.deepEqual(savedLiveQueue.items.filter(entry=>entry.kind==='audiobook').map(entry=>entry.partId),[chapters[1].id]);
|
|
assert.equal(await page.locator('.media-player audio').evaluate(audio=>audio.currentSrc),sourceBeforeEdit);
|
|
await page.locator('dialog[open] .sheet-close').last().click();
|
|
|
|
await stopPlayback();
|
|
await page.getByRole('button',{name:'播放',exact:true}).nth(1).click();
|
|
await page.waitForFunction(position=>{const a=document.querySelector('.media-player audio');return !a.paused&&a.currentTime>=position;},secondChapter.position);
|
|
await page.getByRole('button',{name:'打开播放控制',exact:true}).click();
|
|
await stopPlayback();
|
|
const assignmentRoot=join(root,'assignment');await mkdir(assignmentRoot);
|
|
for(const name of ['Source','Target'])await writeFile(join(assignmentRoot,name+'.webm'),Buffer.from(videoBytes));
|
|
const assignmentLibrary=await importLibrary('版本归属测试','video',assignmentRoot);
|
|
const assignmentItems=await(await fetch(origin+'/api/v1/media/libraries/'+assignmentLibrary.id+'/items?kind=movie',{headers})).json();
|
|
const sourceItem=assignmentItems.items.find(item=>item.title==='Source'),targetItem=assignmentItems.items.find(item=>item.title==='Target');
|
|
assert.ok(sourceItem);assert.ok(targetItem);
|
|
const sourceBefore=await(await fetch(origin+'/api/v1/media/items/'+sourceItem.id,{headers})).json();
|
|
await page.evaluate(id=>{location.hash='#/media/video/'+id;},sourceItem.id);
|
|
await page.locator('.media-edition-admin>summary').click();
|
|
await auditPage('edition-management');
|
|
await page.getByText('纠正版本归属',{exact:true}).click();
|
|
await page.getByRole('button',{name:'选择此作品',exact:true}).click();
|
|
await page.getByRole('region',{name:'确认版本归属'}).waitFor();
|
|
assert.equal((await(await fetch(origin+'/api/v1/media/items/'+sourceItem.id,{headers})).json()).editions.length,1,'preview must not mutate assignment');
|
|
await page.getByRole('button',{name:'确认修改归属',exact:true}).click();
|
|
await page.locator('.media-hero h1').filter({hasText:'Target'}).waitFor();
|
|
const targetAfter=await(await fetch(origin+'/api/v1/media/items/'+targetItem.id,{headers})).json();
|
|
assert.equal(targetAfter.editions.length,2);
|
|
assert.deepEqual(targetAfter.editions.find(edition=>edition.id===sourceBefore.editions[0].id).parts,sourceBefore.editions[0].parts);
|
|
assert.equal((await(await fetch(origin+'/api/v1/media/items/'+sourceItem.id,{headers})).json()).editions.length,0);
|
|
for(const edition of targetAfter.editions){
|
|
const response=await fetch(origin+'/api/v1/media/editions/'+edition.id,{method:'PATCH',headers,body:JSON.stringify({label:edition.id===sourceBefore.editions[0].id?'待合并版本':'保留版本',expectedLabel:edition.label})});
|
|
assert.equal(response.status,200);
|
|
}
|
|
await page.reload();
|
|
await page.getByLabel('播放版本',{exact:true}).selectOption({label:'待合并版本'});
|
|
const sourceSection=page.locator('.media-detail-sidebar .media-edition-tools').filter({has:page.locator('.media-resource-summary').filter({hasText:'待合并版本'})});
|
|
await sourceSection.locator('.media-edition-admin>summary').click();
|
|
await sourceSection.getByText('拆分或合并版本',{exact:true}).click();
|
|
await sourceSection.getByLabel('整理方式',{exact:true}).selectOption('merge');
|
|
const keepId=targetAfter.editions.find(edition=>edition.id!==sourceBefore.editions[0].id).id;
|
|
await sourceSection.getByLabel('目标版本',{exact:true}).selectOption(keepId);
|
|
await sourceSection.getByRole('button',{name:'预览整理结果',exact:true}).click();
|
|
assert.equal((await(await fetch(origin+'/api/v1/media/items/'+targetItem.id,{headers})).json()).editions.length,2);
|
|
await sourceSection.getByRole('button',{name:'确认整理',exact:true}).click();
|
|
await page.getByRole('heading',{name:'待合并版本',exact:true}).waitFor({state:'detached'});
|
|
const merged=await(await fetch(origin+'/api/v1/media/items/'+targetItem.id,{headers})).json();assert.equal(merged.editions.length,1);assert.equal(merged.editions[0].parts.length,2);
|
|
const keptSection=page.locator('.media-detail-sidebar .media-edition-tools').filter({has:page.locator('.media-resource-summary').filter({hasText:'保留版本'})});
|
|
await keptSection.locator('.media-edition-admin>summary').click();
|
|
await keptSection.getByText('调整章节顺序',{exact:true}).click();
|
|
const ordering=keptSection.getByRole('region',{name:'章节排序',exact:true});
|
|
await ordering.getByRole('button',{name:'下移 '+merged.editions[0].parts[0].title,exact:true}).click();
|
|
const savedOrder=page.waitForResponse(response=>response.url().endsWith('/editions/'+keepId+'/order')&&response.request().method()==='PUT');
|
|
await ordering.getByRole('button',{name:'保存章节顺序',exact:true}).click();
|
|
assert.equal((await savedOrder).status(),200);
|
|
await page.waitForFunction(()=>Array.from(document.querySelectorAll('[aria-label="章节排序"] button')).some(button=>button.textContent==='保存章节顺序'&&button.disabled));
|
|
const reordered=await(await fetch(origin+'/api/v1/media/items/'+targetItem.id,{headers})).json();
|
|
assert.deepEqual(reordered.editions[0].parts.map(part=>part.id),merged.editions[0].parts.map(part=>part.id).reverse());
|
|
await keptSection.getByText('拆分或合并版本',{exact:true}).click();
|
|
await keptSection.getByLabel('新版本名称',{exact:true}).fill('拆出版本');
|
|
await keptSection.getByRole('region',{name:'拆分或合并版本',exact:true}).getByRole('checkbox').first().check();
|
|
await keptSection.getByRole('button',{name:'预览整理结果',exact:true}).click();
|
|
await keptSection.getByRole('button',{name:'确认整理',exact:true}).click();
|
|
await page.getByLabel('播放版本',{exact:true}).selectOption({label:'拆出版本'});
|
|
await page.getByRole('heading',{name:'拆出版本',exact:true}).waitFor();
|
|
const split=await(await fetch(origin+'/api/v1/media/items/'+targetItem.id,{headers})).json();assert.equal(split.editions.length,2);
|
|
assert.deepEqual(new Set(split.editions.flatMap(edition=>edition.parts.map(part=>part.id))),new Set(merged.editions[0].parts.map(part=>part.id)));
|
|
const newWorkSection=page.locator('.media-detail-sidebar .media-edition-tools').filter({has:page.locator('.media-resource-summary').filter({hasText:'拆出版本'})});
|
|
await newWorkSection.locator('.media-edition-admin>summary').click();
|
|
await newWorkSection.getByText('纠正版本归属',{exact:true}).click();
|
|
await newWorkSection.getByText('目标作品不存在?新建作品',{exact:true}).click();
|
|
await newWorkSection.getByLabel('新作品标题',{exact:true}).fill('人工新建电影');
|
|
await newWorkSection.getByRole('button',{name:'预览新建作品',exact:true}).click();
|
|
assert.equal((await(await fetch(origin+'/api/v1/media/items/'+targetItem.id,{headers})).json()).editions.length,2);
|
|
await newWorkSection.getByRole('button',{name:'确认新建并关联',exact:true}).click();
|
|
await page.locator('.media-hero h1').filter({hasText:'人工新建电影'}).waitFor();
|
|
const createdItemId=await page.evaluate(()=>decodeURIComponent(location.hash.split('/').at(-1)));
|
|
const createdItem=await(await fetch(origin+'/api/v1/media/items/'+createdItemId,{headers})).json();
|
|
assert.equal(createdItem.title,'人工新建电影');
|
|
assert.deepEqual(createdItem.editions[0].parts,split.editions.find(edition=>edition.label==='拆出版本').parts);
|
|
await page.reload();await page.locator('.media-hero h1').filter({hasText:'人工新建电影'}).waitFor();
|
|
await auditPage('new-work');
|
|
// A committed library must survive failure of the separate scan request.
|
|
const createRoot=join(root,'create-recovery');await mkdir(createRoot);
|
|
await page.evaluate(()=>{location.hash='#/media/music';});
|
|
await page.locator('.media-actions summary').click();
|
|
await page.getByRole('button',{name:'媒体库管理',exact:true}).click();
|
|
const librariesBefore=(await(await fetch(origin+'/api/v1/media/libraries',{headers})).json()).items.length;
|
|
const creationRequestIds=[];
|
|
const loseCreationResponse=async route=>{
|
|
if(route.request().method()!=='POST')return route.continue();
|
|
creationRequestIds.push(route.request().postDataJSON().requestId);
|
|
const response=await route.fetch();assert.equal(response.status(),201);
|
|
if(creationRequestIds.length===1)return route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({error:{code:'TEMPORARY',message:'模拟创建响应丢失'}})});
|
|
await route.fulfill({response});
|
|
};
|
|
await page.route(origin+'/api/v1/media/libraries',loseCreationResponse);
|
|
const failNewScan=async route=>route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({error:{code:'TEMPORARY',message:'模拟扫描提交失败'}})});
|
|
await page.route('**/api/v1/media/libraries/*/scan',failNewScan);
|
|
await page.getByRole('button',{name:'新建媒体库',exact:true}).click();
|
|
const creator=page.locator('.media-library-create');
|
|
await creator.getByLabel('名称',{exact:true}).fill('创建恢复验证');
|
|
await creator.getByLabel('服务器目录',{exact:true}).fill(createRoot);
|
|
await creator.getByRole('button',{name:'创建并扫描',exact:true}).click();
|
|
await creator.getByText('模拟创建响应丢失',{exact:true}).waitFor();
|
|
assert.equal((await(await fetch(origin+'/api/v1/media/libraries',{headers})).json()).items.length,librariesBefore+1);
|
|
await creator.getByRole('button',{name:'创建并扫描',exact:true}).click();
|
|
await creator.getByText('模拟扫描提交失败',{exact:true}).waitFor();
|
|
assert.equal(creationRequestIds.length,2);assert.ok(creationRequestIds[0]);assert.equal(creationRequestIds[0],creationRequestIds[1]);
|
|
await page.unroute(origin+'/api/v1/media/libraries',loseCreationResponse);
|
|
await creator.getByRole('alert').waitFor();
|
|
assert.ok((await creator.textContent()).includes('已创建'));
|
|
assert.equal(await creator.locator('form').count(),0);
|
|
await auditPage('library-created-scan-error',creator);
|
|
const librariesCreated=(await(await fetch(origin+'/api/v1/media/libraries',{headers})).json()).items;
|
|
assert.equal(librariesCreated.length,librariesBefore+1);
|
|
const createdLibrary=librariesCreated.find(item=>item.name==='创建恢复验证');assert.ok(createdLibrary);
|
|
await page.unroute('**/api/v1/media/libraries/*/scan',failNewScan);
|
|
await creator.getByRole('button',{name:'启动此库扫描',exact:true}).click();
|
|
await page.getByRole('heading',{name:'扫描任务 · 创建恢复验证',exact:true}).waitFor();
|
|
await page.locator('.media-scan-job').first().waitFor();
|
|
assert.equal((await(await fetch(origin+'/api/v1/media/libraries',{headers})).json()).items.length,librariesBefore+1);
|
|
assert.equal((await(await fetch(origin+`/api/v1/media/libraries/${createdLibrary.id}/jobs`,{headers})).json()).items.length,1);
|
|
const organizeRoot=join(root,'music-parenting');await mkdir(organizeRoot);
|
|
for(const name of ['甲','乙']){
|
|
await mkdir(join(organizeRoot,name));await writeFile(join(organizeRoot,name,'tone.wav'),wav);
|
|
await writeFile(join(organizeRoot,name,'tone.nfo'),`<album><title>${name}曲目</title><album>${name}专辑</album><artist>${name}歌手</artist></album>`);
|
|
}
|
|
const organizeLibrary=await importLibrary('音乐归属测试','music',organizeRoot);
|
|
const organizeTracks=(await(await fetch(origin+`/api/v1/media/libraries/${organizeLibrary.id}/items?kind=track`,{headers})).json()).items;
|
|
const sourceTrack=organizeTracks.find(item=>item.title==='甲曲目'),targetTrack=organizeTracks.find(item=>item.title==='乙曲目');assert.ok(sourceTrack&&targetTrack);
|
|
const beforeParent=await(await fetch(origin+'/api/v1/media/items/'+sourceTrack.id,{headers})).json();
|
|
await page.evaluate(id=>{location.hash='#/media/music/'+id;},sourceTrack.id);
|
|
await page.getByRole('button',{name:'调整所属专辑',exact:true}).click();
|
|
const organizer=page.locator('.media-parent-editor');
|
|
await organizer.getByRole('button',{name:'预览新建专辑',exact:true}).waitFor();
|
|
await auditPage('music-parent-select',organizer.locator('form').last());
|
|
await organizer.locator('.media-row').filter({hasText:'乙专辑'}).getByRole('button',{name:'选择',exact:true}).click();
|
|
await auditPage('music-parent-preview',organizer.locator('.media-parent-preview'));
|
|
assert.equal((await(await fetch(origin+'/api/v1/media/items/'+sourceTrack.id,{headers})).json()).parentId,sourceTrack.parentId,'preview does not mutate parent');
|
|
await organizer.getByRole('button',{name:'确认调整归属',exact:true}).click();
|
|
await organizer.getByRole('button',{name:'调整所属专辑',exact:true}).waitFor();
|
|
const afterParent=await(await fetch(origin+'/api/v1/media/items/'+sourceTrack.id,{headers})).json();
|
|
assert.equal(afterParent.parentId,targetTrack.parentId);
|
|
assert.deepEqual(afterParent.editions,beforeParent.editions,'moving a track does not rebuild editions or parts');
|
|
const staleParent=await fetch(origin+'/api/v1/media/items/'+sourceTrack.id+'/parent',{method:'PUT',headers,body:JSON.stringify({targetParentId:sourceTrack.parentId,expectedParentId:sourceTrack.parentId})});assert.equal(staleParent.status,409);
|
|
await organizer.getByRole('button',{name:'调整所属专辑',exact:true}).click();
|
|
await organizer.getByRole('textbox',{name:'新建专辑名称',exact:true}).fill('人工整理专辑');
|
|
await organizer.getByRole('button',{name:'预览新建专辑',exact:true}).click();
|
|
await auditPage('music-parent-create',organizer.locator('.media-parent-preview'));
|
|
assert.equal((await(await fetch(origin+'/api/v1/media/items/'+sourceTrack.id,{headers})).json()).parentId,targetTrack.parentId);
|
|
await organizer.getByRole('button',{name:'取消新建',exact:true}).click();
|
|
assert.equal((await(await fetch(origin+'/api/v1/media/items/'+sourceTrack.id,{headers})).json()).parentId,targetTrack.parentId);
|
|
await organizer.getByRole('button',{name:'预览新建专辑',exact:true}).click();
|
|
await organizer.getByRole('button',{name:'确认新建并调整归属',exact:true}).click();
|
|
await organizer.getByRole('button',{name:'调整所属专辑',exact:true}).waitFor();
|
|
const createdParentTrack=await(await fetch(origin+'/api/v1/media/items/'+sourceTrack.id,{headers})).json();
|
|
const createdParent=await(await fetch(origin+'/api/v1/media/items/'+createdParentTrack.parentId,{headers})).json();
|
|
assert.equal(createdParent.title,'人工整理专辑');assert.deepEqual(createdParentTrack.editions,beforeParent.editions);
|
|
const staleCreate=await fetch(origin+'/api/v1/media/items/'+sourceTrack.id+'/parent',{method:'POST',headers,body:JSON.stringify({title:'人工整理专辑',expectedParentId:targetTrack.parentId})});assert.equal(staleCreate.status,409);
|
|
await organizer.getByRole('button',{name:'调整所属专辑',exact:true}).click();
|
|
await organizer.getByRole('textbox',{name:'新建专辑名称',exact:true}).fill('冲突时不应创建的专辑');
|
|
try{await organizer.getByRole('button',{name:'预览新建专辑',exact:true}).click();}
|
|
catch(error){console.error(await organizer.evaluate(element=>({text:element.textContent,inputs:[...element.querySelectorAll('input')].map(input=>({label:input.getAttribute('aria-label'),value:input.value,disabled:input.disabled}))})));throw error;}
|
|
const concurrentMove=await fetch(origin+'/api/v1/media/items/'+sourceTrack.id+'/parent',{method:'PUT',headers,body:JSON.stringify({targetParentId:targetTrack.parentId,expectedParentId:createdParentTrack.parentId})});assert.equal(concurrentMove.status,200);
|
|
await organizer.getByRole('button',{name:'确认新建并调整归属',exact:true}).click();
|
|
await organizer.getByRole('button',{name:'刷新当前归属',exact:true}).waitFor();
|
|
await auditPage('music-parent-conflict',organizer.locator('[role="status"]'));
|
|
assert.equal(await organizer.getByRole('button',{name:'预览新建专辑',exact:true}).isDisabled(),true);
|
|
await organizer.getByRole('button',{name:'刷新当前归属',exact:true}).click();
|
|
await organizer.getByRole('button',{name:'预览新建专辑',exact:true}).waitFor();
|
|
const afterConflictAlbums=(await(await fetch(origin+`/api/v1/media/libraries/${organizeLibrary.id}/items?kind=album`,{headers})).json()).items;
|
|
assert.equal(afterConflictAlbums.some(item=>item.title==='冲突时不应创建的专辑'),false);
|
|
const episodeToMove=await(await fetch(origin+'/api/v1/media/items/'+wholeSeriesPlan.episodes[0].id,{headers})).json();
|
|
const seriesBefore=await(await fetch(origin+'/api/v1/media/items/'+seriesItems.items[0].id,{headers})).json();
|
|
const destinationSeason=seriesBefore.children.find(item=>item.ordinal===2);assert.ok(destinationSeason);
|
|
await page.evaluate(id=>{location.hash='#/media/video/'+id;},episodeToMove.id);
|
|
await page.getByRole('button',{name:'调整归属与集号',exact:true}).click();
|
|
await page.getByRole('button',{name:'选择剧集',exact:true}).click();
|
|
await page.getByRole('combobox',{name:'目标季',exact:true}).selectOption(destinationSeason.id);
|
|
await page.getByRole('spinbutton',{name:'集号',exact:true}).fill('2');
|
|
await auditPage('video-hierarchy-select',page.getByRole('spinbutton',{name:'集号',exact:true}));
|
|
await page.getByRole('button',{name:'预览季集整理',exact:true}).click();
|
|
await auditPage('video-hierarchy-preview',page.locator('.media-parent-preview'));
|
|
assert.equal((await(await fetch(origin+'/api/v1/media/items/'+episodeToMove.id,{headers})).json()).parentId,episodeToMove.parentId);
|
|
await page.getByRole('button',{name:'确认季集整理',exact:true}).click();
|
|
await page.getByRole('button',{name:'调整归属与集号',exact:true}).waitFor();
|
|
const movedEpisode=await(await fetch(origin+'/api/v1/media/items/'+episodeToMove.id,{headers})).json();
|
|
assert.equal(movedEpisode.parentId,destinationSeason.id);assert.equal(movedEpisode.ordinal,2);
|
|
assert.deepEqual(movedEpisode.editions,episodeToMove.editions);
|
|
const staleHierarchy=await fetch(origin+'/api/v1/media/items/'+episodeToMove.id+'/hierarchy',{method:'PUT',headers,body:JSON.stringify({targetParentId:destinationSeason.id,ordinal:3,expectedParentId:episodeToMove.parentId,expectedOrdinal:episodeToMove.ordinal})});assert.equal(staleHierarchy.status,409);
|
|
await page.getByRole('button',{name:'调整归属与集号',exact:true}).click();
|
|
await page.getByRole('button',{name:'新建剧集并整理',exact:true}).click();
|
|
await page.getByRole('textbox',{name:'新剧集名称',exact:true}).fill('人工整理剧集');
|
|
await page.getByRole('spinbutton',{name:'新季号',exact:true}).fill('0');
|
|
await page.getByRole('button',{name:'预览新建归属',exact:true}).click();
|
|
await auditPage('video-hierarchy-create',page.locator('.media-parent-preview'));
|
|
assert.equal((await(await fetch(origin+'/api/v1/media/items/'+episodeToMove.id,{headers})).json()).parentId,destinationSeason.id);
|
|
await page.getByRole('button',{name:'确认季集整理',exact:true}).click();
|
|
await page.getByRole('button',{name:'调整归属与集号',exact:true}).waitFor();
|
|
const newHierarchyEpisode=await(await fetch(origin+'/api/v1/media/items/'+episodeToMove.id,{headers})).json();
|
|
const newHierarchySeason=await(await fetch(origin+'/api/v1/media/items/'+newHierarchyEpisode.parentId,{headers})).json();
|
|
const newHierarchyShow=await(await fetch(origin+'/api/v1/media/items/'+newHierarchySeason.parentId,{headers})).json();
|
|
assert.equal(newHierarchySeason.ordinal,0);assert.equal(newHierarchyShow.title,'人工整理剧集');assert.deepEqual(newHierarchyEpisode.editions,episodeToMove.editions);
|
|
await page.evaluate(()=>{location.hash='#/media/music';});
|
|
await page.locator('.media-actions summary').click();
|
|
await page.getByRole('navigation',{name:'影音操作'}).getByRole('button',{name:'收藏',exact:true}).click();
|
|
await page.getByRole('heading',{name:'我的收藏',exact:true}).waitFor();
|
|
await page.locator('.media-grid .media-tile').first().waitFor();
|
|
await auditPage('favorites-populated');
|
|
await page.locator('.media-actions summary').click();
|
|
await page.getByRole('navigation',{name:'影音操作'}).getByRole('button',{name:'历史',exact:true}).click();
|
|
await page.getByRole('heading',{name:'播放历史',exact:true}).waitFor();
|
|
await page.locator('.media-screen .media-row').first().waitFor();
|
|
await auditPage('history-populated');
|
|
const missingRoot=join(root,'missing-recovery');await mkdir(missingRoot);const missingFile=join(missingRoot,'recover.wav');await writeFile(missingFile,wav);
|
|
const missingLibrary=await importLibrary('资源恢复测试','music',missingRoot);
|
|
const missingTrack=(await(await fetch(origin+`/api/v1/media/libraries/${missingLibrary.id}/items?kind=track`,{headers})).json()).items[0];
|
|
const rescanMissing=async()=>{
|
|
const response=await fetch(origin+`/api/v1/media/libraries/${missingLibrary.id}/scan`,{method:'POST',headers,body:'{}'});assert.equal(response.status,202);const job=await response.json();
|
|
let state;for(let i=0;i<300;i++){state=await(await fetch(origin+'/api/v1/media/jobs/'+job.id,{headers})).json();if(state.state!=='running')break;await delay(100);}assert.equal(state.state,'complete');
|
|
};
|
|
const missingBefore=await(await fetch(origin+'/api/v1/media/items/'+missingTrack.id,{headers})).json();
|
|
await rm(missingFile);await rescanMissing();
|
|
await page.evaluate(id=>{location.hash='#/media/music/'+id;},missingTrack.id);
|
|
const refreshMissing=page.getByRole('button',{name:'刷新资源状态',exact:true});await refreshMissing.waitFor();
|
|
assert.equal(await page.getByRole('button',{name:'资源缺失',exact:true}).isDisabled(),true);
|
|
await auditPage('resource-missing',page.locator('.media-missing-resource'));
|
|
await writeFile(missingFile,wav);await rescanMissing();
|
|
await refreshMissing.click();await page.getByRole('button',{name:'播放',exact:true}).waitFor();
|
|
assert.equal(await page.locator('.media-missing-resource').count(),0);
|
|
const missingAfter=await(await fetch(origin+'/api/v1/media/items/'+missingTrack.id,{headers})).json();
|
|
assert.deepEqual(missingAfter.editions.map(e=>e.parts.map(p=>p.id)),missingBefore.editions.map(e=>e.parts.map(p=>p.id)));
|
|
const invalidRoot=join(root,'invalid-playback');await mkdir(invalidRoot);await writeFile(join(invalidRoot,'invalid.mp3'),'This is deliberately not a decodable audio file.');
|
|
const invalidLibrary=await importLibrary('无法解码测试','music',invalidRoot);
|
|
const invalidTrack=(await(await fetch(origin+`/api/v1/media/libraries/${invalidLibrary.id}/items?kind=track`,{headers})).json()).items[0];
|
|
await page.evaluate(id=>{location.hash='#/media/music/'+id;},invalidTrack.id);
|
|
await page.getByRole('button',{name:'播放',exact:true}).click();
|
|
await page.waitForFunction(()=>document.querySelector('.media-player audio')?.error?.code===4);
|
|
await page.getByRole('button',{name:'打开播放控制',exact:true}).click();
|
|
const unsupportedMessage=page.locator('.media-playback-status [role="alert"]');
|
|
await unsupportedMessage.filter({hasText:'当前设备无法直放此资源'}).waitFor();
|
|
assert.ok((await unsupportedMessage.textContent()).includes('当前不提供转码'));
|
|
await auditPage('playback-unsupported');
|
|
await stopPlayback();
|
|
await page.getByRole('button',{name:'播放',exact:true}).waitFor();
|
|
assert.equal(await page.locator('.media-player').isVisible(),false);
|
|
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false);
|
|
await page.goto(origin+'/#/media/music');
|
|
await page.getByRole('button',{name:'曲目',exact:true}).click();
|
|
await page.getByLabel('媒体库',{exact:true}).selectOption(lib.id);
|
|
await page.locator('.media-track-copy').first().click();
|
|
await page.waitForFunction(()=>{const audio=document.querySelector('.media-player audio');return audio&&audio.readyState>=2&&!audio.paused&&audio.currentTime>0;});
|
|
assert.equal(new URL(page.url()).hash,'#/media/music','direct track playback keeps the list open');
|
|
assert.deepEqual(errors,[]);
|
|
if(visualDir){await writeFile(join(visualDir,'verification.json'),JSON.stringify({complete:true,checks:visualChecks,browserErrors:errors},null,2));await require('./media-review-gallery.cjs').writeMediaReviewGallery(visualDir);}
|
|
console.log(JSON.stringify({passed:true,directTrackPlayback:true,editionAssignment:true,realAudioDecoded:true,realVideoDecoded:true,seriesHierarchyAndPlayback:true,audiobookChapterAdvanceAndResume:true,embeddedChapters:!!process.env.MEDIA_SMOKE_FFMPEG,embeddedSubtitleDecoded,embeddedCoverDecoded,subtitlesDecoded:true,localCoverDecoded:true,backgroundAcrossReading:true,renewalCount,progress:progress.position,browserErrors:errors,visualChecks:visualChecks.length}));
|
|
}finally{if(browser)await browser.close();server.kill();if(server.exitCode===null)await new Promise(r=>server.once('exit',r));await rm(root,{recursive:true,force:true});}
|
|
})().catch(e=>{console.error(e);process.exitCode=1;});
|