48 lines
12 KiB
JavaScript
48 lines
12 KiB
JavaScript
import {chromium} from 'playwright';import assert from 'node:assert/strict';import fs from 'node:fs';import {execFileSync} from 'node:child_process';import {setDifficulty} from './helpers.mjs';
|
||
const browser=await chromium.launch({executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',headless:true});
|
||
const errors=[];let context;
|
||
async function page(options={}){
|
||
context=await browser.newContext({viewport:{width:390,height:844},isMobile:true,hasTouch:true,deviceScaleFactor:2});const p=await context.newPage();p.on('pageerror',e=>errors.push(e.message));
|
||
await p.addInitScript(({fallback,unsupported})=>{
|
||
if(fallback||unsupported)Object.defineProperty(window,'VideoEncoder',{value:undefined,configurable:true});if(unsupported)Object.defineProperty(window,'MediaRecorder',{value:undefined,configurable:true});
|
||
window.nativeCalls=[];window.nativeData=[];window.xhs={miniTool:{writeTempFile:async args=>{if(window.failWrite)throw Error('write failed');window.nativeData.push(args.data);const path='test://media-'+window.nativeData.length+(args.data.startsWith('data:video/')?'.mp4':'.jpg');window.nativeCalls.push({write:path});return {filePath:path};},postNote:async args=>{window.nativeCalls.push({post:args});if(window.failPost)throw Error('cancelled');},saveImageToPhotosAlbum:async()=>{}}};
|
||
},options);await p.goto('http://localhost:4173');await setDifficulty(p,{speed:4,bounce:3});return p;
|
||
}
|
||
async function state(p){return p.evaluate(()=>shanheDebug.state());}
|
||
async function finish(p){await p.locator('#pause').tap();await p.getByRole('button',{name:'结束旅程',exact:true}).tap();await p.waitForFunction(()=>shanheDebug.state().video.settled);}
|
||
try {
|
||
const p=await page();await p.locator('#start').tap();await p.waitForFunction(()=>shanheDebug.state().video.capture.supported!==null);assert.equal((await state(p)).video.capture.supported,true);
|
||
// Real game actions and real capture time, not a fabricated video input.
|
||
for(let i=0;i<3;i++){await p.locator('#board').focus();await p.keyboard.press('Enter');await p.waitForTimeout(650);await p.keyboard.press('ArrowUp');await p.waitForTimeout(350);}
|
||
await p.waitForTimeout(3500);await p.keyboard.press('ArrowUp');await p.waitForTimeout(500);const initial=await state(p);assert.equal(initial.dropped,3);await p.locator('#pause').tap();await p.waitForTimeout(150);const paused=await state(p);await p.waitForTimeout(350);assert.equal((await state(p)).video.capture.frames,paused.video.capture.frames);assert.equal((await state(p)).elapsed,paused.elapsed);await p.getByRole('button',{name:'继续游戏',exact:true}).tap();await p.waitForTimeout(300);await finish(p);
|
||
let s=await state(p);assert.ok(s.video.capture.frames>10);assert.equal(s.result.bounceName,'元气弹跳');await p.locator('#video-action').tap();await p.waitForFunction(()=>shanheDebug.state().video.generated,{},{timeout:30000});s=await state(p);const encodedBytes=s.video.bytes;assert.equal(s.video.mode,'webcodecs');assert.ok(s.video.bytes>1000);assert.ok(s.video.seconds>=s.result.elapsed/1000+2&&s.video.seconds<s.result.elapsed/1000+2.1);await p.screenshot({path:'artifacts/video-result.png'});
|
||
await p.locator('#video-action').tap();await p.waitForFunction(()=>window.nativeCalls.some(c=>c.post));const calls=await p.evaluate(()=>window.nativeCalls),post=calls.find(c=>c.post).post;
|
||
assert.equal(post.pageType,'video_publish');assert.equal(post.mediaInfo.video_resources.video_url,calls[0].write);assert.equal(post.mediaInfo.video_resources.cover_url,calls[1].write);assert.ok(post.content.includes('元气弹跳')&&post.content.includes('随心落子'));assert.ok(post.title.length<=20&&post.content.length<=1000);
|
||
const encoded=await p.evaluate(()=>window.nativeData[0]);assert.ok(encoded.startsWith('data:video/mp4;base64,'));fs.writeFileSync('artifacts/gameplay-demo.mp4',Buffer.from(encoded.split(',')[1],'base64'));
|
||
const probe=JSON.parse(execFileSync('/opt/homebrew/bin/ffprobe',['-v','error','-show_streams','-show_format','-of','json','artifacts/gameplay-demo.mp4'],{encoding:'utf8'}));assert.equal(probe.streams.length,1);assert.equal(probe.streams[0].codec_name,'h264');assert.equal(probe.streams[0].width,720);assert.equal(probe.streams[0].height,1280);assert.ok(Number(probe.format.duration)>=2);
|
||
execFileSync('/opt/homebrew/bin/ffmpeg',['-v','error','-i','artifacts/gameplay-demo.mp4','-f','null','-']);
|
||
execFileSync('/opt/homebrew/bin/ffmpeg',['-v','error','-y','-i','artifacts/gameplay-demo.mp4','-frames:v','1','artifacts/video-first.png']);execFileSync('/opt/homebrew/bin/ffmpeg',['-v','error','-y','-sseof','-0.5','-i','artifacts/gameplay-demo.mp4','-frames:v','1','artifacts/video-last.png']);
|
||
// Retry after bridge cancellation/write failure uses fresh temporary paths.
|
||
await p.evaluate(()=>{window.failPost=true;});await p.locator('#video-action').tap();await p.waitForFunction(()=>!document.querySelector('#video-action').disabled);assert.ok((await p.locator('#toast').textContent()).includes('未完成'));assert.equal((await state(p)).video.generated,true);
|
||
await p.evaluate(()=>{window.failPost=false;window.failWrite=true;});await p.locator('#video-action').tap();await p.waitForFunction(()=>!document.querySelector('#video-action').disabled);assert.equal((await state(p)).video.generated,true);
|
||
await p.evaluate(()=>{window.xhs=null;});await p.locator('#video-action').tap();assert.ok((await p.locator('#toast').textContent()).includes('小红书小工具'));
|
||
for(const viewport of [{width:320,height:568},{width:375,height:667},{width:740,height:390}]){await p.setViewportSize(viewport);await p.waitForFunction(()=>document.documentElement.scrollHeight<=innerHeight+1);assert.ok(await p.evaluate(()=>document.documentElement.scrollHeight<=innerHeight+1),JSON.stringify(viewport));assert.ok(await p.locator('#video-action').isVisible());assert.ok(await p.locator('.result-actions').evaluate(el=>el.getBoundingClientRect().bottom<=innerHeight));}
|
||
// Bounded compact states must preserve the entire time range, including frame 0.
|
||
const budget=await p.evaluate(async()=>{const source=document.querySelector('#board'),meta={levelName:'预算',speedName:'速度',bounceName:'弹性',score:0,dropped:0,total:34,elapsed:0};const options={snapshot:()=>({bytes:120*1024}),render:()=>{}};const capture=new PuzzleVideo.Capture(options);await capture.ready;for(let i=0;i<750;i++){meta.elapsed=i*100;capture.capture(source,meta,true);}const memory=capture.info();capture.dispose();const cleared=capture.info();capture.capture(source,meta,true);return {memory,cleared,stale:capture.info()};});
|
||
assert.ok(budget.memory.bytes<=32*1024*1024);assert.ok(budget.memory.frames<750);assert.equal(budget.memory.from,0);assert.equal(budget.memory.to,74900);assert.ok(budget.memory.sampleFPS<30);assert.equal(budget.cleared.frames,0);assert.equal(budget.cleared.bytes,0);assert.equal(budget.stale.frames,0);
|
||
// An isolated vector timeline verifies >60-second MP4 encoding, start/middle/end
|
||
// content, retained exact final state and resolution, independent of game physics.
|
||
const long=await p.evaluate(async()=>{let at=0;const cap=new PuzzleVideo.Capture({snapshot:()=>({bytes:1024,at,view:{width:300,height:500}}),render:(c,scene)=>{c.fillStyle=scene.at<30000?'#ff0000':scene.at<65000?'#0000ff':'#00ff00';c.fillRect(0,0,300,500);c.fillStyle='#fff';c.font='20px sans-serif';c.fillText('time '+scene.at,30,70);}});await cap.ready;const source={width:300,height:500},meta={levelName:'全程测试',speedName:'随心落子',bounceName:'蹦蹦跳',score:0,dropped:0,total:34,elapsed:0,dangerRatio:.2};for(at=0;at<=80000;at+=100){meta.elapsed=at;cap.capture(source,meta,true);}const info=cap.info();const output=await PuzzleVideo.generate(cap,document.querySelector('#result-card'),{cancelled:false},()=>{});const data=await PuzzleVideo.dataURL(output.blob);cap.dispose();return {info,data,seconds:output.seconds};});
|
||
assert.equal(long.info.from,0);assert.equal(long.info.to,80000);assert.ok(long.seconds>=82&&long.seconds<82.1);fs.writeFileSync('artifacts/gameplay-full-session.mp4',Buffer.from(long.data.split(',')[1],'base64'));execFileSync('/opt/homebrew/bin/ffmpeg',['-v','error','-i','artifacts/gameplay-full-session.mp4','-f','null','-']);
|
||
for(const [time,channel] of [[0,0],[45,2],[80,1]]){const pixel=execFileSync('/opt/homebrew/bin/ffmpeg',['-v','error','-ss',String(time),'-i','artifacts/gameplay-full-session.mp4','-frames:v','1','-vf','crop=2:2:360:600','-pix_fmt','rgb24','-f','rawvideo','-']);assert.ok(pixel[channel]>220&&pixel[(channel+1)%3]<30&&pixel[(channel+2)%3]<30,'timeline color at '+time);}
|
||
// A new run releases the previous frames/output and cannot publish stale footage.
|
||
await p.locator('#replay').tap();s=await state(p);assert.equal(s.video.generated,false);assert.ok(s.video.capture.frames<=1);await context.close();
|
||
const fallback=await page({fallback:true});await fallback.locator('#start').tap();await fallback.waitForTimeout(800);await finish(fallback);await fallback.locator('#video-action').tap();await fallback.waitForFunction(()=>shanheDebug.state().video.generated,{},{timeout:15000});assert.equal((await state(fallback)).video.mode,'recorder');await fallback.locator('#video-action').tap();await fallback.waitForFunction(()=>window.nativeData.length>=2);const slow=await fallback.evaluate(()=>window.nativeData[0]);fs.writeFileSync('artifacts/gameplay-fallback.mp4',Buffer.from(slow.split(',')[1],'base64'));execFileSync('/opt/homebrew/bin/ffmpeg',['-v','error','-i','artifacts/gameplay-fallback.mp4','-f','null','-']);
|
||
// Cancellation, backgrounding and navigation do not leave a recorder running.
|
||
await fallback.locator('#replay').tap();await fallback.waitForTimeout(900);await finish(fallback);await fallback.locator('#video-action').tap();await fallback.waitForTimeout(150);await fallback.locator('#video-action').tap();await fallback.waitForFunction(()=>!shanheDebug.state().video.generating);assert.equal((await state(fallback)).video.generated,false);assert.ok((await fallback.locator('#video-status').textContent()).includes('取消'));
|
||
await fallback.locator('#video-action').tap();await fallback.waitForTimeout(150);await fallback.evaluate(()=>{Object.defineProperty(document,'hidden',{configurable:true,value:true});document.dispatchEvent(new Event('visibilitychange'));});await fallback.waitForTimeout(200);assert.equal((await state(fallback)).video.generated,false);await fallback.evaluate(()=>{delete document.hidden;document.dispatchEvent(new Event('visibilitychange'));});
|
||
await fallback.locator('#video-action').tap();await fallback.waitForTimeout(150);await fallback.locator('#result-back').tap();await fallback.waitForTimeout(200);assert.equal((await state(fallback)).screen,'entry');assert.equal((await state(fallback)).video.capture,null);await context.close();
|
||
const unsupported=await page({unsupported:true});await unsupported.locator('#start').tap();await unsupported.waitForTimeout(200);await finish(unsupported);assert.equal((await state(unsupported)).video.capture.supported,false);assert.equal(await unsupported.locator('#video-action').isDisabled(),true);assert.equal(await unsupported.locator('#share-note').isEnabled(),true);assert.ok((await unsupported.locator('#video-status').textContent()).includes('不支持'));await unsupported.locator('#share-note').tap();await unsupported.waitForFunction(()=>window.nativeCalls.some(c=>c.post));assert.equal((await unsupported.evaluate(()=>window.nativeCalls.find(c=>c.post))).post.pageType,'photo_publish');
|
||
assert.deepEqual(errors,[]);fs.writeFileSync('artifacts/video-report.json',JSON.stringify({passed:true,scope:'Modern Chrome development checks; native SDK mocked. Platform/Android/iOS pending.',webcodecs:{bytes:encodedBytes,stream:probe.streams[0].codec_name,width:720,height:1280,fps:30,fullSessionSeconds:long.seconds},checks:['真实游戏矢量状态生成720p30 H.264 MP4,ffmpeg完整解码','80秒完整时间线的开头/45秒/80秒状态逐帧检查','长局状态内存32MiB预算保留首尾,超预算降低采样率','末尾真实成绩图、得分和难度','MP4 MediaRecorder备用编码完整解码','暂停不采样/不计时、重新开局释放旧片段','取消/后台/导航清理编码','writeTempFile+video_publish视频封面参数与重试','无SDK/无编码能力降级成绩图','小屏横屏不滚动'],errors},null,2));console.log('游玩视频两条MP4编码、完整解码、真实流程、原生参数及失败降级、取消清理和手机布局通过');
|
||
} finally {for(const ctx of browser.contexts())await ctx.close();await browser.close();}
|