Expose screenshot worker progress and allow complete series rendering in CI
PROMPT Docker CI and deployment / verify-and-deploy (push) Failing after 11m9s
PROMPT Docker CI and deployment / verify-and-deploy (push) Failing after 11m9s
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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('<html'));}
|
||||
const home=await fetch(base+'/').then(r=>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});};
|
||||
|
||||
Reference in New Issue
Block a user