From 2a5cff3bec7d532ad8ae3eb6e29312ab0aede5e6 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 23 Oct 2026 12:26:58 +0800 Subject: [PATCH] Expose screenshot worker progress and allow complete series rendering in CI --- scripts/integration.sh | 2 +- src/lib/snapshots.ts | 8 +++++--- tests/integration.test.ts | 17 +++++++++++++---- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/scripts/integration.sh b/scripts/integration.sh index dc9271f..55f6f40 100644 --- a/scripts/integration.sh +++ b/scripts/integration.sh @@ -7,7 +7,7 @@ export PG_PORT=0 S3_PORT=0 WEB_PORT=0 WEB_BIND_ADDRESS=127.0.0.1 TEST_ENV=$(mktemp) chmod 0600 "$TEST_ENV" compose(){ docker compose -p "$COMPOSE_PROJECT_NAME" --env-file "$TEST_ENV" -f compose.yml "$@"; } -cleanup(){ compose down -v --remove-orphans; rm -f "$TEST_ENV"; } +cleanup(){ code=$?; if [ "$code" -ne 0 ]; then compose logs --tail 160 web worker; fi; compose down -v --remove-orphans; rm -f "$TEST_ENV"; } trap cleanup EXIT # Random credentials exist only for this disposable stack; no host Node required. docker run --rm --entrypoint node "$PROMPT_IMAGE" -e "const c=require('crypto');const hex=()=>c.randomBytes(24).toString('hex');console.log('POSTGRES_PASSWORD='+hex()+'\nS3_ACCESS_KEY='+hex()+'\nS3_SECRET_KEY='+hex())" > "$TEST_ENV" diff --git a/src/lib/snapshots.ts b/src/lib/snapshots.ts index 4b54904..89e8f8b 100644 --- a/src/lib/snapshots.ts +++ b/src/lib/snapshots.ts @@ -10,7 +10,7 @@ export async function snapshotState(slug:string){ const post=(await db().query('SELECT id,page_key FROM posts WHERE slug=$1',[slug])).rows[0]; if(!post||!chapters.some(c=>c.key===post.page_key))return null; const pages=(await db().query('SELECT page_index,asset_id,rendered_at,revision FROM page_snapshots WHERE post_id=$1 ORDER BY page_index',[post.id])).rows; - const job=(await db().query('SELECT status,error,revision FROM render_jobs WHERE post_id=$1',[post.id])).rows[0]||null; + const job=(await db().query('SELECT status,error,revision,attempts,target_page FROM render_jobs WHERE post_id=$1',[post.id])).rows[0]||null; return {postId:post.id,pages,job}; } export async function queueSnapshots(postId:string,pageIndex?:number){ @@ -29,14 +29,16 @@ export async function renderOne(){ await context.route('**/*',route=>new URL(route.request().url()).origin===origin?route.continue():route.abort()); const page=await context.newPage();const output:{index:number;id:string}[]=[]; for(let index=job.target_page||1;index<=(job.target_page||8);index++){ + console.log('Rendering canvas',post.slug,index); const response=await page.goto(`${origin}/p/${post.slug}/pages/${index}`,{waitUntil:'networkidle',timeout:45000}); if(!response?.ok())throw Error('Canvas page unavailable'); - await page.locator('[data-render-page]').waitFor();await page.evaluate(()=>document.fonts.ready); + await page.locator('[data-render-page]').waitFor();await page.evaluate(async()=>{await document.fonts.ready;}); + console.log('Canvas loaded',post.slug,index); const overflow=await page.evaluate(()=>{const sheet=document.querySelector('.learning-sheet');if(!sheet)return false;const footer=sheet.querySelector('footer')!;return Array.from(sheet.children).filter(c=>c!==footer).some(c=>c.getBoundingClientRect().bottom>footer.getBoundingClientRect().top-2)}); if(overflow)throw Error(`Canvas ${index} overlaps its footer`); const path=join(dir,`${index}.png`);const bytes=await page.locator('[data-render-page]').screenshot({path,animations:'disabled'}); const hash=createHash('sha256').update(bytes).digest('hex');const key=`snapshots/${hash}.png`;const size=await putFile(key,path,'image/png'); - const asset=(await db().query(`INSERT INTO assets(id,kind,filename,storage_key,mime_type,byte_size,sha256,width,height) VALUES($1,'image',$2,$3,'image/png',$4,$5,1080,1440) ON CONFLICT(storage_key) DO UPDATE SET storage_key=excluded.storage_key RETURNING id`,[randomUUID(),`图文-${index}.png`,key,size,hash])).rows[0];output.push({index,id:asset.id}); + const asset=(await db().query(`INSERT INTO assets(id,kind,filename,storage_key,mime_type,byte_size,sha256,width,height) VALUES($1,'image',$2,$3,'image/png',$4,$5,1080,1440) ON CONFLICT(storage_key) DO UPDATE SET storage_key=excluded.storage_key RETURNING id`,[randomUUID(),`图文-${index}.png`,key,size,hash])).rows[0];output.push({index,id:asset.id});console.log('Canvas stored',post.slug,index); } await transaction(async c=>{ const current=(await c.query('SELECT revision FROM render_jobs WHERE post_id=$1 FOR UPDATE',[job.post_id])).rows[0]; diff --git a/tests/integration.test.ts b/tests/integration.test.ts index ce10c75..436c525 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -23,7 +23,7 @@ test('real PostgreSQL + S3 library, sharing, taxonomy, uploads, range and video const request=async(path:string,body?:unknown)=>{const res=await fetch(base+path,body?{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}:undefined);assert.ok(res.ok,`${path}: ${res.status} ${res.ok?'':await res.text()}`);return res.json();}; try{ let ready=false;for(let i=0;i<60;i++){try{if((await fetch(base+'/api/health')).ok){ready=true;break;}}catch{}await setTimeout(250);}assert.ok(ready); - for(const route of ['/','/images','/videos','/audio','/articles','/motion','/library/import','/tools/doodle','/tools/skills','/tools/skills/street-people'])assert.equal((await fetch(base+route)).status,200,route); + for(const route of ['/','/images','/videos','/audio','/articles','/motion','/library/import','/tools/doodle','/tools/skills','/tools/skills/street-people','/tools/skills/shadow-puppet'])assert.equal((await fetch(base+route)).status,200,route); const root=await request('/api/tags',{name:'线条',slug:'lines'});const child=await request('/api/tags',{name:'白色',slug:'white',parentId:root.id}); await assert.rejects(db().query('UPDATE tags SET parent_id=$1 WHERE id=$2',[child.id,root.id]),/cycle/); const a=await sharp({create:{width:160,height:220,channels:3,background:'#b8c9af'}}).png().toBuffer(); @@ -60,8 +60,8 @@ test('real PostgreSQL + S3 library, sharing, taxonomy, uploads, range and video assert.equal((await request('/api/feed')).contributions.reduce((n:number,c:{total:number})=>n+c.total,0),5,'Editorial publication does not invent contribution events'); for(const slug of published.published){ for(const n of [1,3,8]){const r=await fetch(base+`/p/${slug}/pages/${n}`);assert.equal(r.status,200);const html=await r.text();assert.ok(html.includes('data-render-page'));assert.ok(!html.includes('global-topbar'));} - let status;for(let i=0;i<120;i++){status=await request(`/api/posts/${slug}/snapshots`);if(status.job?.status==='failed')assert.fail(status.job.error);if(status.job?.status==='ready'&&status.pages.length===8)break;await setTimeout(500)} - assert.equal(status.pages.length,8,'All eight HTML pages must be screenshot on the server'); + let status;for(let i=0;i<480;i++){status=await request(`/api/posts/${slug}/snapshots`);if(status.job?.status==='failed')assert.fail(status.job.error);if(status.job?.status==='ready'&&status.pages.length===8)break;await setTimeout(500)} + assert.equal(status.pages.length,8,'Screenshot job state: '+JSON.stringify(status.job)); const r:Response=await fetch(base+'/api/media/'+status.pages[2].asset_id);const meta:{width?:number;height?:number}=await sharp(Buffer.from(await r.arrayBuffer())).metadata();assert.equal(meta.width,1080);assert.equal(meta.height,1440); } const before=await request(`/api/posts/${published.published[0]}/snapshots`); @@ -69,7 +69,16 @@ test('real PostgreSQL + S3 library, sharing, taxonomy, uploads, range and video let after;for(let i=0;i<80;i++){after=await request(`/api/posts/${published.published[0]}/snapshots`);if(after.job?.status==='ready'&&after.pages[2].rendered_at!==before.pages[2].rendered_at)break;await setTimeout(500)} assert.notEqual(after.pages[2].rendered_at,before.pages[2].rendered_at);assert.equal(after.pages[1].rendered_at,before.pages[1].rendered_at,'Single-page refresh preserves other snapshots'); for(const doc of ['SKILL','style','upload','checks']){const r=await fetch(base+`/skills/street-people/${doc}.md`);assert.equal(r.status,200);assert.ok(r.headers.get('content-type')?.includes('text/markdown'));const text=await r.text();assert.ok(text.startsWith('# '));assert.ok(!text.includes('r.text());assert.ok(home.includes('查看文档与 Agent 入口')); + for(const doc of ['SKILL','workflow','visual-grammar','prompt','delivery']){ + const path=`/skills/shadow-puppet/${doc}.md`;const response=await fetch(base+path);assert.equal(response.status,200,path);assert.ok(response.headers.get('content-type')?.includes('text/markdown'));assert.ok((await response.text()).startsWith('# ')); + const head=await fetch(base+path,{method:'HEAD'});assert.equal(head.status,200);assert.equal(await head.text(),''); + if(doc!=='SKILL')assert.equal((await fetch(base+'/tools/skills/shadow-puppet/'+doc)).status,200); + } + for(const image of ['egret-example','dancer-example'])assert.equal((await fetch(base+`/skills/shadow-puppet/${image}.png`,{method:'HEAD'})).status,200); + for(const path of ['/skills/missing/SKILL.md','/skills/shadow-puppet/missing.md','/tools/skills/shadow-puppet/missing'])assert.equal((await fetch(base+path)).status,404,path); + assert.equal((await fetch(base+'/api/skill-docs/shadow-puppet/SKILL',{method:'POST'})).status,405); + const home=await fetch(base+'/').then(r=>r.text());assert.ok(home.includes('查看文档与 Agent 入口'));assert.ok(home.includes('照片入皮影')); + const spec=await request('/api/skills/street-people');assert.equal(spec.generation.executor,'coding-agent'); assert.equal((await fetch(base+'/api/skills/street-people?format=md')).headers.get('content-type'),'text/markdown; charset=utf-8'); const skillUpload=async(bytes:Uint8Array,slug:string)=>{const f=new FormData();f.set('slug',slug);f.append('files',new Blob([new Uint8Array(bytes)],{type:'image/png'}),'person.png');return fetch(base+'/api/skills/street-people',{method:'POST',body:f});};