99 lines
12 KiB
TypeScript
99 lines
12 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import {test} from 'node:test';
|
|
import {spawn} from 'node:child_process';
|
|
import {once} from 'node:events';
|
|
import {createServer} from 'node:net';
|
|
import {setTimeout} from 'node:timers/promises';
|
|
import {mkdtemp,readFile,writeFile,rm} from 'node:fs/promises';
|
|
import {tmpdir} from 'node:os';
|
|
import {join} from 'node:path';
|
|
import {randomUUID} from 'node:crypto';
|
|
import sharp from 'sharp';
|
|
import {db} from '../src/lib/db';
|
|
import {probe,run} from '../src/lib/probe';
|
|
import {publishLearningSeries} from '../src/lib/publish-learning-series';
|
|
|
|
test('real PostgreSQL + S3 library, sharing, taxonomy, uploads, range and video separation', {timeout:300000}, async()=>{
|
|
assert.equal(process.env.PROMPT_INTEGRATION_TEST,'1','Run only against an explicitly isolated test database');
|
|
const socket=createServer();socket.listen(0,'127.0.0.1');await once(socket,'listening');const address=socket.address();assert.ok(address&&typeof address!=='string');const port=address.port;await new Promise<void>(r=>socket.close(()=>r()));
|
|
const external=process.env.PROMPT_TEST_BASE_URL;
|
|
const base=external||`http://127.0.0.1:${port}`;
|
|
const server=external?null:spawn(process.execPath,['.next/standalone/server.js'],{env:{...process.env,PORT:String(port),HOSTNAME:'127.0.0.1'},stdio:'inherit'});
|
|
const worker=external?null:spawn(process.execPath,['dist/worker.cjs'],{env:process.env,stdio:'inherit'});const dir=await mkdtemp(join(tmpdir(),'prompt-test-'));
|
|
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','/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();
|
|
const upload=async(kind:string,files:{data:Uint8Array;name:string;mime:string}[])=>{const form=new FormData();form.set('kind',kind);form.set('title','测试收藏 '+kind);form.set('description','可以复用的独立文字');form.set('tags',JSON.stringify([child.id]));files.forEach(f=>form.append('files',new Blob([new Uint8Array(f.data)],{type:f.mime}),f.name));const res=await fetch(base+'/api/upload',{method:'POST',body:form});assert.equal(res.status,201,await res.clone().text());return res.json();};
|
|
const first=await upload('image',[{data:a,name:'a.png',mime:'image/png'},{data:a,name:'b.png',mime:'image/png'}]);
|
|
const reused=await request('/api/posts',{kind:'image',title:'同一张图的另一种用法',assets:[{id:first.assets[0]}],tags:[root.id]});
|
|
assert.equal((await db().query('SELECT count(*)::integer AS n FROM post_assets WHERE asset_id=$1',[first.assets[0]])).rows[0].n,2);
|
|
const filtered=await request('/api/feed?section=images&path=lines');assert.equal(filtered.posts.length,2);assert.equal((await request('/api/feed?section=images&path=lines/white')).posts.length,1);
|
|
const empty=randomUUID();await db().query("INSERT INTO posts(id,slug,kind,title) VALUES($1,$2,'image','空对象')",[empty,empty]);assert.equal((await request('/api/feed')).posts.length,2);
|
|
assert.equal((await request('/api/feed')).contributions.length,0,'Import does not invent contribution rules');
|
|
await db().query("INSERT INTO contribution_events(id,kind,subject_type,amount) VALUES($1,'test.import','asset',2),($2,'test.edit','post',3)",[randomUUID(),randomUUID()]);assert.equal((await request('/api/feed')).contributions.reduce((n:number,c:{total:number})=>n+c.total,0),5);
|
|
const img=await fetch(base+'/api/media/'+first.assets[0],{headers:{Range:'bytes=0-9'}});assert.equal(img.status,206);assert.equal((await img.arrayBuffer()).byteLength,10);
|
|
assert.equal((await fetch(base+'/images/lines/missing')).status,404);assert.equal((await fetch(base+'/p/'+reused.slug)).status,200);
|
|
for(const sound of [true,false]){
|
|
const path=join(dir,sound?'sound.mp4':'silent.mp4');const args=['-y','-v','error','-f','lavfi','-i','color=c=blue:s=160x120:r=10'];if(sound)args.push('-f','lavfi','-i','sine=frequency=440:sample_rate=44100');args.push('-t','0.5','-c:v','libx264','-pix_fmt','yuv420p');if(sound)args.push('-c:a','aac');args.push(path);await run('ffmpeg',args);
|
|
const post=await upload('video',[{data:await readFile(path),name:'video.mp4',mime:'video/mp4'}]);let asset;
|
|
for(let i=0;i<120;i++){asset=(await db().query('SELECT * FROM assets WHERE id=$1',[post.assets[0]])).rows[0];if(asset.processing_status==='ready'||asset.processing_status==='failed')break;await setTimeout(500);}
|
|
assert.equal(asset.processing_status,'ready',asset.processing_error);assert.equal(asset.has_audio,sound);
|
|
const variants:string[]=(await db().query('SELECT role FROM asset_variants WHERE asset_id=$1',[asset.id])).rows.map(r=>r.role);assert.ok(variants.includes('silent_video'));assert.ok(variants.includes('poster'));assert.equal(variants.includes('audio_track'),sound);
|
|
const clip:Response=await fetch(base+'/api/media/'+asset.id+'?variant=silent_video');assert.equal(clip.status,200);const derived=join(dir,'derived.mp4');await writeFile(derived,Buffer.from(await clip.arrayBuffer()));assert.equal((await probe(derived)).streams.some(s=>s.codec_type==='audio'),false);
|
|
}
|
|
assert.equal((await db().query("SELECT count(*)::integer AS n FROM assets WHERE kind='audio'")).rows[0].n,0,'Bound soundtracks are variants, not independent audio assets');
|
|
const audio=join(dir,'audio.wav');await run('ffmpeg',['-y','-v','error','-f','lavfi','-i','sine=frequency=220','-t','0.2',audio]);await upload('audio',[{data:await readFile(audio),name:'audio.wav',mime:'audio/wav'}]);assert.equal((await request('/api/feed?section=audio')).posts.length,1);
|
|
for(const removed of ['/library/assets','/library/tags'])assert.equal((await fetch(base+removed)).status,404);
|
|
const published=await publishLearningSeries();await publishLearningSeries();
|
|
const articles=(await request('/api/feed?section=articles')).posts;
|
|
assert.equal(articles.length,3,'Repeated publication must not duplicate posts');
|
|
assert.equal(new Set(articles.map((p:{cover_asset_id:string})=>p.cover_asset_id)).size,1,'Three posts share one independent image asset');
|
|
assert.equal((await db().query('SELECT count(*)::integer AS n FROM post_assets WHERE asset_id=$1',[published.coverId])).rows[0].n,3);
|
|
for(const slug of published.published){const response=await fetch(base+'/p/'+slug);assert.equal(response.status,200);assert.ok((await response.text()).includes('学习资料'))}
|
|
assert.equal((await request('/api/feed?section=articles&path=learning-skills')).posts.length,3);
|
|
const coverResponse=await fetch(base+'/api/media/'+published.coverId);assert.equal(coverResponse.headers.get('content-type'),'image/png');
|
|
const coverInfo=await sharp(Buffer.from(await coverResponse.arrayBuffer())).metadata();assert.equal(coverInfo.width,1080);assert.equal(coverInfo.height,1440);
|
|
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<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`);
|
|
const refresh=await fetch(base+`/api/posts/${published.published[0]}/snapshots`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({page:3})});assert.equal(refresh.status,202);
|
|
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'));}
|
|
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});};
|
|
const skillSlug='skill-'+randomUUID();
|
|
assert.equal((await skillUpload(a,skillSlug)).status,400,'Opaque PNG rejected');
|
|
const opaqueAlpha=await sharp({create:{width:64,height:64,channels:4,background:'#fff'}}).png().toBuffer();
|
|
assert.equal((await skillUpload(opaqueAlpha,skillSlug)).status,400,'Unused alpha channel rejected');
|
|
const transparent=await sharp({create:{width:64,height:64,channels:4,background:'#0000'}}).composite([{input:await sharp({create:{width:32,height:32,channels:4,background:'#faf4e5'}}).png().toBuffer(),left:16,top:16}]).png().toBuffer();
|
|
const created=await skillUpload(transparent,skillSlug);assert.equal(created.status,201,await created.clone().text());const character=await created.json();assert.equal(character.slug,skillSlug);assert.equal(character.url,'/p/'+skillSlug);
|
|
assert.equal((await skillUpload(transparent,skillSlug)).status,409,'Stable slug prevents duplicate posts');
|
|
const stored=await fetch(base+'/api/media/'+character.assets[0]);assert.deepEqual(Buffer.from(await stored.arrayBuffer()),transparent,'PNG alpha and original bytes preserved in S3');
|
|
assert.equal((await fetch(base+character.url)).status,200);
|
|
}finally{
|
|
for(const child of [worker,server])if(child&&child.exitCode===null&&child.signalCode===null){const stopped=once(child,'exit');child.kill('SIGTERM');await stopped;}
|
|
await rm(dir,{recursive:true,force:true});await db().end();
|
|
}
|
|
});
|