Files
shanhe-puzzle/tests/video.mjs
T
codex-bot 4845826deb
Shanhe source and offline package checks / verify (pull_request) Failing after 5s
feat: sync Shanhe 2.2 source and add remote package verification
2026-11-01 17:13:46 +08:00

48 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();}