Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 74e058bb44 | |||
| c405f47916 | |||
| 0849268232 | |||
| ab5ea7876b | |||
| a589f51698 | |||
| 40c1023a86 | |||
| 2cc6f1f543 | |||
| a3435c463f | |||
| fcc0aba810 |
@@ -0,0 +1,27 @@
|
||||
name: Endoleap brand checks
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'packages/brand/**'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'package.json'
|
||||
- 'AGENTS.md'
|
||||
- '.gitea/workflows/brand.yml'
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'packages/brand/**'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- '.gitea/workflows/brand.yml'
|
||||
concurrency:
|
||||
group: brand-${{ gitea.event.pull_request.number || gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ces-control
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Verify brand assets, Tailwind compilation and typed exports in Docker
|
||||
run: docker build -f packages/brand/Dockerfile -t "endoleap-brand-check:${{ gitea.sha }}" .
|
||||
@@ -9,6 +9,8 @@ on:
|
||||
- 'scripts/*studio*'
|
||||
- 'compose.creative-review.yml'
|
||||
- 'scripts/integration.sh'
|
||||
- 'scripts/cleanup-integration.sh'
|
||||
- 'scripts/test-integration-cleanup.py'
|
||||
concurrency:
|
||||
group: creative-workspace-${{ gitea.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
@@ -21,7 +23,10 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Check patch formatting
|
||||
run: git diff --check HEAD^ HEAD
|
||||
run: |
|
||||
git diff --check HEAD^ HEAD
|
||||
bash -n scripts/integration.sh scripts/cleanup-integration.sh
|
||||
python3 scripts/test-integration-cleanup.py
|
||||
- name: Verify Studio types, tests, build and smoke
|
||||
run: docker build -f apps/studio/Dockerfile -t "creative-studio-review:${{ gitea.sha }}" .
|
||||
- name: Build Wiki integration and runtime images
|
||||
@@ -35,3 +40,8 @@ jobs:
|
||||
WATERFALL_WIKI_TEST_IMAGE: creative-wiki-tests:${{ gitea.sha }}
|
||||
GITEA_RUN_ID: ${{ gitea.run_id }}
|
||||
run: bash scripts/integration.sh
|
||||
- name: Clean this run disposable integration resources even after cancellation
|
||||
if: always()
|
||||
env:
|
||||
GITEA_RUN_ID: ${{ gitea.run_id }}
|
||||
run: bash scripts/cleanup-integration.sh
|
||||
|
||||
@@ -16,6 +16,8 @@ on:
|
||||
- 'scripts/infra.sh'
|
||||
- 'scripts/auth-admin.sh'
|
||||
- 'scripts/integration.sh'
|
||||
- 'scripts/cleanup-integration.sh'
|
||||
- 'scripts/test-integration-cleanup.py'
|
||||
- 'pnpm-workspace.yaml'
|
||||
|
||||
jobs:
|
||||
@@ -29,6 +31,8 @@ jobs:
|
||||
fetch-depth: 0
|
||||
- name: Check shared content and documentation contracts
|
||||
run: |
|
||||
python3 scripts/test-integration-cleanup.py
|
||||
bash -n scripts/integration.sh scripts/cleanup-integration.sh
|
||||
node scripts/check-studio-contracts.mjs
|
||||
node --check scripts/export-studio-series.cjs
|
||||
node --check scripts/save-studio-series.mjs
|
||||
@@ -60,3 +64,8 @@ jobs:
|
||||
- name: Remove temporary acceptance output after upload
|
||||
if: always() && steps.acceptance_evidence.outcome == 'success'
|
||||
run: rm -rf artifacts/studio-acceptance
|
||||
- name: Clean this run disposable integration resources even after cancellation
|
||||
if: always()
|
||||
env:
|
||||
GITEA_RUN_ID: ${{ gitea.run_id }}
|
||||
run: bash scripts/cleanup-integration.sh
|
||||
|
||||
@@ -18,3 +18,7 @@ __pycache__/
|
||||
*.webp
|
||||
*.gif
|
||||
*.avif
|
||||
|
||||
# Approved, versioned brand resources are package source assets.
|
||||
!packages/brand/assets/**/*.png
|
||||
!packages/brand/docs/logo-preview.png
|
||||
|
||||
@@ -14,3 +14,10 @@ This block is written and re-added by `next dev` — verify at `node_modules/nex
|
||||
- 页面改动必须先 push,等待 ces-control 的 CI/CD 成功部署,再直接访问服务器检查效果。
|
||||
- 不启动本地网页预览,不使用 localhost / 127.0.0.1 页面进行视觉验收,也不把本地预览链接交付给用户。
|
||||
- 类型检查、单元测试等不提供网页预览的检查可以本地执行;完整运行检查由 Docker CI 完成。
|
||||
|
||||
## 啡跃 Endoleap 品牌与主题
|
||||
|
||||
- 共享品牌包位于 [`packages/brand`](packages/brand/README.md)。修改品牌色、主题或标志前,阅读包内的 [品牌边界](packages/brand/docs/overview.md)、[配色与交互规范](packages/brand/docs/colors.md)、[Tailwind / shadcn 接入](packages/brand/docs/integration.md) 和 [Logo 规则](packages/brand/docs/logo.md)。不在仓库根 docs 重复维护一套品牌规范。
|
||||
- 四套正式主题为 home-light、home-dark、canvas-light、canvas-dark;不创建 drafts,不恢复 proposed 命名。画布 Light / Dark 背景保持中性近白 / 近黑,用户素材与作品不整体染色。
|
||||
- 基础色只在 tokens/palette.json 定义,主题引用色板键;Logo 轮廓只在 tokens/geometry.json 定义。保持 CSS、类型声明、资产与清单一致,不通过图片模型重画已固定的图形。
|
||||
- 本地仅运行不生成产物的轻量读取检查。品牌导出、Tailwind/TypeScript 编译和 SVG 栅格化检查必须通过已确认的 ces-control Docker CI 执行,不在本机启动预览或构建。
|
||||
|
||||
@@ -6,15 +6,18 @@ const fs=require('node:fs/promises');
|
||||
const base=process.env.BASE_URL||'http://8.211.154.134:18131';
|
||||
assert.equal(base,'http://8.211.154.134:18131');
|
||||
const browser=await chromium.launch(process.env.CHROMIUM_PATH?{executablePath:process.env.CHROMIUM_PATH,headless:true,args:['--no-sandbox','--disable-dev-shm-usage']}:{channel:'chrome',headless:true});
|
||||
let page;
|
||||
try{
|
||||
const context=await browser.newContext({viewport:{width:1600,height:1100},storageState:process.env.STORAGE_STATE||undefined});
|
||||
const page=await context.newPage(),errors=[];let choosers=0;
|
||||
page=await context.newPage();const errors=[];let choosers=0;
|
||||
page.on('pageerror',e=>errors.push(e.message));page.on('filechooser',()=>choosers++);
|
||||
const snapshot=()=>page.evaluate(()=>JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')));
|
||||
const node=id=>page.locator(`[data-node-id="${id}"]`);
|
||||
const content=id=>node(id).locator('[data-node-content]');
|
||||
const waitField=(id,key,value)=>page.waitForFunction(({id,key,value})=>JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')).cards.find(n=>n.id===id)?.[key]===value,{id,key,value});
|
||||
const composer=page.getByRole('region',{name:'节点编辑器'});
|
||||
const capture=async name=>{if(process.env.SCREENSHOT_DIR){await fs.mkdir(process.env.SCREENSHOT_DIR,{recursive:true});await page.screenshot({path:process.env.SCREENSHOT_DIR+'/'+name+'.png'})}};
|
||||
async function unobstructed(id){const box=await node(id).boundingBox();assert.ok(box);for(const card of (await snapshot()).cards.filter(n=>n.id!==id)){const other=await node(card.id).boundingBox();assert.ok(other);assert.equal(box.x<other.x+other.width&&box.x+box.width>other.x&&box.y<other.y+other.height&&box.y+box.height>other.y,false,`${id} overlaps ${card.id}`)}}
|
||||
const close=(a,b)=>assert.ok(Math.abs(a-b)<2,`${a} ≈ ${b}`);
|
||||
async function drag(handle,dx,dy){const b=await handle.boundingBox();assert.ok(b);await page.mouse.move(b.x+b.width/2,b.y+b.height/2);await page.mouse.down();await page.mouse.move(b.x+b.width/2+dx,b.y+b.height/2+dy,{steps:12});await page.mouse.up()}
|
||||
await page.goto(base+'/infinite_canvas');
|
||||
@@ -28,6 +31,17 @@ const fs=require('node:fs/promises');
|
||||
await waitField('brief','text','选择后再次点击进入编辑');
|
||||
await node('brief').getByRole('button',{name:'加粗',exact:true}).click();
|
||||
assert.equal((await snapshot()).cards.find(n=>n.id==='brief').style.bold,true);
|
||||
await node('brief').getByRole('button',{name:'斜体',exact:true}).click();
|
||||
await node('brief').getByLabel('文字颜色',{exact:true}).fill('#78905e');
|
||||
await node('brief').getByRole('combobox',{name:'文字样式',exact:true}).selectOption('24');
|
||||
await page.waitForFunction(()=>{const n=JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')).cards.find(n=>n.id==='brief');return n.style.bold&&n.style.italic&&n.style.color==='#78905e'&&n.style.size===24});
|
||||
assert.deepEqual(await node('brief').getByRole('textbox',{name:'01 / 创意简报 内容'}).evaluate(el=>{const s=getComputedStyle(el);return {size:s.fontSize,weight:s.fontWeight,style:s.fontStyle,color:s.color}}),{size:'24px',weight:'700',style:'italic',color:'rgb(120, 144, 94)'});
|
||||
const compact=(await snapshot()).cards.find(n=>n.id==='brief');
|
||||
await node('brief').getByRole('button',{name:'展开文本节点',exact:true}).click();
|
||||
const expanded=(await snapshot()).cards.find(n=>n.id==='brief');assert.ok(expanded.width>(compact.width||320)&&expanded.height>(compact.height||350));
|
||||
await capture('canvas-text-format-desktop');
|
||||
await page.getByRole('button',{name:'撤销',exact:true}).click();
|
||||
assert.equal((await snapshot()).cards.find(n=>n.id==='brief').width,compact.width);assert.equal((await snapshot()).cards.find(n=>n.id==='brief').height,compact.height);
|
||||
await page.keyboard.press('Escape');
|
||||
const before=(await snapshot()).cards.find(n=>n.id==='brief');
|
||||
const z=await page.locator('[class*=world]').evaluate(el=>new DOMMatrix(getComputedStyle(el).transform).a);
|
||||
@@ -38,6 +52,7 @@ const fs=require('node:fs/promises');
|
||||
await content('reference').click();assert.equal(await composer.count(),0);
|
||||
await page.waitForTimeout(700);await content('reference').click();await composer.waitFor();assert.equal(choosers,0,'Entering image editing must not force a file picker');
|
||||
const panelBefore=await composer.boundingBox(),imageBefore=await node('reference').boundingBox();
|
||||
await capture('canvas-composer-desktop');
|
||||
await page.getByRole('button',{name:'缩小',exact:true}).click();
|
||||
const panelAfter=await composer.boundingBox(),imageAfter=await node('reference').boundingBox();
|
||||
close(panelBefore.width,panelAfter.width);close(panelBefore.height,panelAfter.height);assert.ok(imageAfter.width<imageBefore.width);
|
||||
@@ -46,6 +61,7 @@ const fs=require('node:fs/promises');
|
||||
await page.waitForFunction(()=>!!JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')).cards.find(n=>n.id==='reference').image);
|
||||
await node('reference').getByRole('button',{name:'图片高清 ↗',exact:true}).click();
|
||||
const upscale=(await snapshot()).cards.find(n=>n.operation==='upscale');assert.ok(upscale);
|
||||
await unobstructed(upscale.id);
|
||||
const download=page.waitForEvent('download');await composer.getByRole('button',{name:'导出任务',exact:true}).click();
|
||||
const task=JSON.parse(await fs.readFile(await (await download).path(),'utf8'));
|
||||
assert.equal(task.target.id,upscale.id);assert.equal(task.references[0].id,'reference');assert.ok(task.references[0].image);
|
||||
@@ -55,6 +71,7 @@ const fs=require('node:fs/promises');
|
||||
await node('brief').getByRole('button',{name:'引用 01 / 创意简报 生成',exact:true}).click();
|
||||
await page.getByRole('dialog',{name:'画布操作'}).getByRole('button',{name:'脚本',exact:true}).click();
|
||||
const script=(await snapshot()).cards.find(n=>n.kind==='script');assert.ok(script);
|
||||
await unobstructed(script.id);
|
||||
await composer.getByRole('textbox',{name:'创作要求',exact:true}).fill('基于简报写六个分镜');
|
||||
await waitField(script.id,'instruction','基于简报写六个分镜');assert.equal((await snapshot()).cards.find(n=>n.id===script.id).text,'');
|
||||
await composer.getByRole('button',{name:'画布参考',exact:true}).click();
|
||||
@@ -89,5 +106,5 @@ const fs=require('node:fs/promises');
|
||||
const viewport=await page.locator('[class*=viewport]').boundingBox();assert.equal(viewport.width,390);assert.equal(viewport.height,844);
|
||||
if(process.env.SCREENSHOT_DIR)await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-nodes-mobile.png'});
|
||||
assert.deepEqual(errors,[]);console.log('Passed: selected-node re-click editing, drag, formatting, fixed composer, upload, references, task export, deletion/undo, reload and mobile bounds');
|
||||
}finally{await browser.close()}
|
||||
}catch(error){if(process.env.SCREENSHOT_DIR&&page){await fs.mkdir(process.env.SCREENSHOT_DIR,{recursive:true});await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-failure.png'}).catch(()=>{})}throw error}finally{await browser.close()}
|
||||
})().catch(e=>{console.error(e);process.exitCode=1});
|
||||
|
||||
@@ -59,8 +59,14 @@ export function removeCanvasNode(doc:CanvasDocument,id:string):CanvasDocument {
|
||||
}
|
||||
export function deriveNode(doc:CanvasDocument,source:string,node:CanvasNode):CanvasDocument {
|
||||
if(doc.cards.length>=80||doc.cards.some(n=>n.id===node.id))throw Error('节点数量或标识不正确');
|
||||
const result=connectNodes({...doc,cards:[...doc.cards,node]},source,node.id);
|
||||
return parseCanvasDocument(result);
|
||||
const target=parseCanvasDocument({version:1,cards:[node],links:[]}).cards[0];
|
||||
for(let attempt=0;attempt<=doc.cards.length+1;attempt++){
|
||||
const blockers=doc.cards.filter(n=>target.x<n.x+(n.width||320)+64&&target.x+(target.width||320)+64>n.x&&target.y<n.y+(n.height||350)+64&&target.y+(target.height||350)+64>n.y);
|
||||
if(!blockers.length)return parseCanvasDocument(connectNodes({...doc,cards:[...doc.cards,target]},source,target.id));
|
||||
const nextY=Math.max(...blockers.map(n=>n.y+(n.height||350)+64));
|
||||
target.y=nextY>100000?-100000:nextY;
|
||||
}
|
||||
throw Error('请移动现有节点后再创建引用节点');
|
||||
}
|
||||
export function agentTask(doc:CanvasDocument,id:string){
|
||||
const node=doc.cards.find(n=>n.id===id);if(!node)throw Error('目标节点不存在');
|
||||
|
||||
@@ -36,6 +36,30 @@ test('editing an image passes the current image to the external agent',()=>{
|
||||
assert.equal(agentTask({version:1,cards:[image],links:[]},'image').sourceImage,image.image);
|
||||
});
|
||||
|
||||
const overlaps=(a,b)=>a.x<b.x+(b.width||320)&&a.x+(a.width||320)>b.x&&a.y<b.y+(b.height||350)&&a.y+(a.height||350)>b.y;
|
||||
test('branching the example keeps the source image accessible after image and script derivation',()=>{
|
||||
const scene={version:1,cards:[
|
||||
{...note,x:70,y:140},
|
||||
{...note,id:'reference',kind:'image',x:460,y:80},
|
||||
{...note,id:'shot',kind:'prompt',x:850,y:160},
|
||||
],links:[['brief','reference'],['reference','shot']]};
|
||||
const image=deriveNode(scene,'reference',{...note,id:'hd',kind:'image',operation:'upscale',x:930,y:80,width:360,height:300});
|
||||
const result=deriveNode(image,'brief',{...note,id:'script',kind:'script',operation:'script',x:540,y:140,width:360,height:300});
|
||||
assert.deepEqual(result.cards.slice(0,3),scene.cards,'Existing content and positions stay unchanged');
|
||||
for(const added of result.cards.slice(3))for(const other of result.cards.filter(n=>n.id!==added.id))assert.equal(overlaps(added,other),false,`${added.id} blocks ${other.id}`);
|
||||
assert.equal(referencedNodes(result,'hd')[0].id,'reference');
|
||||
assert.equal(referencedNodes(result,'script')[0].id,'brief');
|
||||
assert.deepEqual(parseCanvasDocument(JSON.stringify(result)),result);
|
||||
});
|
||||
test('a free requested position is retained and large nodes near the coordinate limit can still branch',()=>{
|
||||
const free=deriveNode(doc,'brief',{...note,id:'free',x:2000,y:50});
|
||||
assert.deepEqual({x:free.cards[1].x,y:free.cards[1].y},{x:2000,y:50});
|
||||
const edge={version:1,cards:[{...note,x:90000,y:99600,width:1200,height:1200}],links:[]};
|
||||
const result=deriveNode(edge,'brief',{...note,id:'edge',x:90500,y:99700,width:360,height:300});
|
||||
assert.equal(overlaps(result.cards[0],result.cards[1]),false);
|
||||
assert.deepEqual(parseCanvasDocument(JSON.stringify(result)),result);
|
||||
});
|
||||
|
||||
test('agent and browser parse the same canvas document contract',()=>{
|
||||
assert.equal(readFileSync(new URL('../src/lib/canvas-document.ts',import.meta.url),'utf8'),readFileSync(new URL('../../waterfall-wiki/src/lib/canvas-document.ts',import.meta.url),'utf8'));
|
||||
});
|
||||
|
||||
@@ -59,8 +59,14 @@ export function removeCanvasNode(doc:CanvasDocument,id:string):CanvasDocument {
|
||||
}
|
||||
export function deriveNode(doc:CanvasDocument,source:string,node:CanvasNode):CanvasDocument {
|
||||
if(doc.cards.length>=80||doc.cards.some(n=>n.id===node.id))throw Error('节点数量或标识不正确');
|
||||
const result=connectNodes({...doc,cards:[...doc.cards,node]},source,node.id);
|
||||
return parseCanvasDocument(result);
|
||||
const target=parseCanvasDocument({version:1,cards:[node],links:[]}).cards[0];
|
||||
for(let attempt=0;attempt<=doc.cards.length+1;attempt++){
|
||||
const blockers=doc.cards.filter(n=>target.x<n.x+(n.width||320)+64&&target.x+(target.width||320)+64>n.x&&target.y<n.y+(n.height||350)+64&&target.y+(target.height||350)+64>n.y);
|
||||
if(!blockers.length)return parseCanvasDocument(connectNodes({...doc,cards:[...doc.cards,target]},source,target.id));
|
||||
const nextY=Math.max(...blockers.map(n=>n.y+(n.height||350)+64));
|
||||
target.y=nextY>100000?-100000:nextY;
|
||||
}
|
||||
throw Error('请移动现有节点后再创建引用节点');
|
||||
}
|
||||
export function agentTask(doc:CanvasDocument,id:string){
|
||||
const node=doc.cards.find(n=>n.id===id);if(!node)throw Error('目标节点不存在');
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {randomUUID,createHash} from 'node:crypto';
|
||||
import {createRequire} from 'node:module';
|
||||
import {availableParallelism,loadavg,freemem,totalmem} from 'node:os';
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import {chromium,type Request} from 'playwright-core';
|
||||
import {db} from '../src/lib/db';
|
||||
import {hashPassword,ACCOUNT_COOKIE} from '../src/lib/account-crypto';
|
||||
@@ -10,28 +8,18 @@ export async function verifyCanvasAgentUI(base:string){
|
||||
assert.equal(process.env.PROMPT_INTEGRATION_TEST,'1');
|
||||
const owner=randomUUID(),email=owner+'@example.com',password='Canvas integration example1!';
|
||||
await db().query("INSERT INTO account_users(id,email,password_hash,verified_at) VALUES($1,$2,$3,now())",[owner,email,await hashPassword(password)]);
|
||||
const probe=async(url:string)=>{
|
||||
const started=Date.now();
|
||||
try{const response=await fetch(url,{signal:AbortSignal.timeout(5000)});const body=await response.text();return {status:response.status,type:response.headers.get('content-type'),bytes:Buffer.byteLength(body),sha256:createHash('sha256').update(body).digest('hex'),ms:Date.now()-started}}
|
||||
catch(error){return {error:error instanceof Error?error.message:String(error),ms:Date.now()-started}}
|
||||
};
|
||||
const html=await fetch(base+'/infinite_canvas',{signal:AbortSignal.timeout(10000)}).then(r=>r.text());
|
||||
const scripts=[...html.matchAll(/<script[^>]+src="([^"?]+)[^">]*"/g)].map(match=>match[1]);
|
||||
console.log('Canvas script proxy comparison',JSON.stringify(await Promise.all(scripts.map(async path=>({path,proxy:await probe(base+path),direct:await probe('http://prompt-studio:3000'+path)})))));
|
||||
const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',headless:true,args:['--no-sandbox','--disable-dev-shm-usage']});
|
||||
console.log('Canvas browser environment',JSON.stringify({browser:browser.version(),playwright:createRequire(import.meta.url)('playwright-core/package.json').version,node:process.version,cpu:availableParallelism(),load:loadavg(),freeMemory:freemem(),totalMemory:totalmem()}));
|
||||
const context=await browser.newContext({viewport:{width:1440,height:1000}}),errors:string[]=[];
|
||||
const page=await context.newPage(),canvasRequests:string[]=[],pending=new Set<Request>(),network:string[]=[];
|
||||
const resources:{ms:number;event:string;path:string;status?:number;type?:string;duration?:number}[]=[],requestStarts=new Map<Request,number>();
|
||||
const started=Date.now(),navigation:{ms:number;event:string;path:string}[]=[];
|
||||
const mark=(event:string)=>{navigation.push({ms:Date.now()-started,event,path:new URL(page.url()).pathname});if(navigation.length>60)navigation.shift()};
|
||||
page.on('framenavigated',frame=>{if(frame===page.mainFrame())mark('framenavigated')});
|
||||
page.on('domcontentloaded',()=>mark('domcontentloaded'));page.on('load',()=>mark('load'));
|
||||
const requestName=(request:{method:()=>string;url:()=>string})=>request.method()+' '+new URL(request.url()).pathname;
|
||||
page.on('request',request=>{pending.add(request);requestStarts.set(request,Date.now())});
|
||||
page.on('request',request=>pending.add(request));
|
||||
page.on('requestfinished',request=>pending.delete(request));
|
||||
page.on('requestfailed',request=>{pending.delete(request);network.push(requestName(request)+': '+request.failure()?.errorText)});
|
||||
page.on('response',response=>{const url=new URL(response.url());if(url.pathname.includes('/_next/')||url.pathname==='/api/account/session'){resources.push({ms:Date.now()-started,event:'response',path:url.pathname,status:response.status(),type:response.headers()['content-type'],duration:Date.now()-(requestStarts.get(response.request())||Date.now())});if(resources.length>100)resources.shift()}if(url.pathname.startsWith('/api/canvases'))canvasRequests.push(`${response.request().method()} ${url.pathname} ${response.status()}`)});
|
||||
page.on('response',response=>{const url=new URL(response.url());if(url.pathname.startsWith('/api/canvases'))canvasRequests.push(`${response.request().method()} ${url.pathname} ${response.status()}`)});
|
||||
try{
|
||||
page.setDefaultTimeout(20000);page.on('pageerror',e=>errors.push(e.message));
|
||||
mark('open landing');await page.goto(base+'/',{waitUntil:'domcontentloaded'});
|
||||
@@ -122,8 +110,8 @@ export async function verifyCanvasAgentUI(base:string){
|
||||
}catch(error){
|
||||
const statuses=await page.getByRole('status').allTextContents().catch(()=>[]);
|
||||
const headings=await page.getByRole('heading').allTextContents().catch(()=>[]);
|
||||
const documentState=await page.evaluate(()=>({readyState:document.readyState,navigation:performance.getEntriesByType('navigation').map(entry=>{const timing=entry as PerformanceNavigationTiming;return {type:timing.type,domContentLoadedEventEnd:timing.domContentLoadedEventEnd,loadEventEnd:timing.loadEventEnd}}),nextData:(()=>{const element=document.getElementById('__NEXT_DATA__');const data=element?JSON.parse(element.textContent||'{}'):{};return {page:data.page,buildId:data.buildId,assetPrefix:data.assetPrefix}})(),runtime:(()=>{const state=window as unknown as {next?:{router?:{isReady?:boolean}};__MIDDLEWARE_MATCHERS?:unknown;__SSG_MANIFEST?:unknown};return {routerReady:state.next?.router?.isReady,middleware:!!state.__MIDDLEWARE_MATCHERS,ssgManifest:!!state.__SSG_MANIFEST}})(),resources:performance.getEntriesByType('resource').map(entry=>({path:new URL(entry.name).pathname,start:entry.startTime,duration:entry.duration}))})).catch(()=>null);
|
||||
console.error('Canvas agent UI diagnostics',JSON.stringify({url:page.url(),browserConnected:browser.isConnected(),headings,statuses,documentState,navigation,resources,pending:[...pending].map(requestName),network:network.slice(-20),canvasRequests:canvasRequests.slice(-20),errors}));
|
||||
const documentState=await page.evaluate(()=>({readyState:document.readyState,navigation:performance.getEntriesByType('navigation').map(entry=>{const timing=entry as PerformanceNavigationTiming;return {type:timing.type,domContentLoadedEventEnd:timing.domContentLoadedEventEnd,loadEventEnd:timing.loadEventEnd}})})).catch(()=>null);
|
||||
console.error('Canvas agent UI diagnostics',JSON.stringify({url:page.url(),browserConnected:browser.isConnected(),headings,statuses,documentState,navigation,pending:[...pending].map(requestName),network:network.slice(-20),canvasRequests:canvasRequests.slice(-20),errors}));
|
||||
throw error;
|
||||
}finally{await context.close();await browser.close()}
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import {test} from 'node:test';
|
||||
import {chromium} from 'playwright-core';
|
||||
import {verifyGuidanceWorkspaceUI} from './guidance-workspace-ui';
|
||||
const originalFetch=globalThis.fetch;
|
||||
globalThis.fetch=async(input,init)=>{
|
||||
const url=typeof input==='string'?input:input instanceof URL?input.href:input.url;
|
||||
const path=new URL(url).pathname,started=Date.now();
|
||||
try{const response=await originalFetch(input,init);console.log('Diagnostic HTTP',JSON.stringify({path,method:init?.method||'GET',status:response.status,ms:Date.now()-started}));return response}
|
||||
catch(error){console.error('Diagnostic HTTP failed',JSON.stringify({path,method:init?.method||'GET',ms:Date.now()-started,error:error instanceof Error?error.message:String(error)}));throw error}
|
||||
};
|
||||
test('diagnose guidance session readiness against the same images',{timeout:300000},async()=>{
|
||||
const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',headless:true,args:['--no-sandbox','--disable-dev-shm-usage']});
|
||||
try{await verifyGuidanceWorkspaceUI(process.env.PROMPT_TEST_BASE_URL!,browser)}finally{await browser.close()}
|
||||
});
|
||||
@@ -27,11 +27,11 @@ export async function verifyGuidanceWorkspaceUI(base: string, browser: Browser)
|
||||
requestedAuthor: string | null = null,
|
||||
published: Guidance | null = null;
|
||||
const errors: string[] = [];
|
||||
const started=performance.now(),trace:{ms:number;event:string;path?:string}[]=[];
|
||||
const mark=(event:string,path?:string)=>trace.push({ms:Math.round(performance.now()-started),event,path});
|
||||
let sessionReplies = 0;
|
||||
try {
|
||||
await context.route("**/api/account/session", (route) =>
|
||||
(mark("session route handler"),route.fulfill({
|
||||
await context.route("**/api/account/session", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
json: {
|
||||
user: signedIn
|
||||
? {
|
||||
@@ -41,8 +41,9 @@ export async function verifyGuidanceWorkspaceUI(base: string, browser: Browser)
|
||||
}
|
||||
: null,
|
||||
},
|
||||
})),
|
||||
);
|
||||
});
|
||||
sessionReplies += 1;
|
||||
});
|
||||
await context.route(
|
||||
(url) => url.pathname === "/api/documents",
|
||||
async (route) => {
|
||||
@@ -89,8 +90,6 @@ export async function verifyGuidanceWorkspaceUI(base: string, browser: Browser)
|
||||
);
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(10000);
|
||||
page.on('domcontentloaded',()=>mark('domcontentloaded',new URL(page.url()).pathname));
|
||||
page.on('response',response=>{if(new URL(response.url()).pathname==='/api/account/session')mark('session response '+response.status())});
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
page.on("crash", () => errors.push("Browser renderer crashed"));
|
||||
const pending = new Set<string>(), network: string[] = [];
|
||||
@@ -98,14 +97,13 @@ export async function verifyGuidanceWorkspaceUI(base: string, browser: Browser)
|
||||
page.on("requestfinished", request => pending.delete(request.url()));
|
||||
page.on("requestfailed", request => {pending.delete(request.url());network.push(new URL(request.url()).pathname+": "+request.failure()?.errorText);});
|
||||
async function navigate(url: string, reload = false) {
|
||||
mark("navigate begin",new URL(url).pathname);
|
||||
const session = page.waitForResponse(response => new URL(response.url()).pathname === "/api/account/session" && response.status() === 200);
|
||||
const previousSessionReplies = sessionReplies;
|
||||
try {
|
||||
const [navigation] = await Promise.all([reload ? page.reload({waitUntil:"domcontentloaded"}) : page.goto(url,{waitUntil:"domcontentloaded"}),session]);
|
||||
const navigation = await (reload ? page.reload({waitUntil:"domcontentloaded"}) : page.goto(url,{waitUntil:"domcontentloaded"}));
|
||||
assert.ok(navigation?.ok(), "Navigation returns a successful document");
|
||||
await page.getByText(signedIn ? "editor@example.com" : "登录你的账户",{exact:true}).first().waitFor({state:"attached"});
|
||||
assert.ok(sessionReplies > previousSessionReplies, "The new document requests its account fixture and renders the resulting account state");
|
||||
} catch (error) {
|
||||
console.error("Guidance ordered readiness",JSON.stringify(trace));
|
||||
const probeStarted=Date.now();
|
||||
const server=await fetch(url,{signal:AbortSignal.timeout(3000)}).then(async response=>{await response.body?.cancel();return {status:response.status,elapsedMs:Date.now()-probeStarted}}).catch(error=>({error:error instanceof Error?error.name:"unknown",elapsedMs:Date.now()-probeStarted}));
|
||||
console.error("Workspace UI navigation diagnostics",JSON.stringify({url:page.url(),browserConnected:browser.isConnected(),server,pending:[...pending].map(value=>new URL(value).pathname),network:network.slice(-20),errors,body:(await page.locator("body").innerText().catch(()=>"unavailable")).slice(0,1600)}));
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
# Read-only test instrumentation against the already built review images.
|
||||
services:
|
||||
tests:
|
||||
volumes:
|
||||
- ./apps/waterfall-wiki/tests/canvas-ui-integration.ts:/app/tests/canvas-ui-integration.ts:ro
|
||||
- ./apps/waterfall-wiki/tests/creative-browser-diagnostics.test.ts:/app/tests/integration.test.ts:ro
|
||||
- ./apps/waterfall-wiki/tests/guidance-workspace-ui.ts:/app/tests/guidance-workspace-ui.ts:ro
|
||||
@@ -7,6 +7,8 @@
|
||||
"dev:studio": "pnpm --filter @prompt/studio dev",
|
||||
"build": "pnpm -r build",
|
||||
"typecheck": "pnpm -r typecheck",
|
||||
"test": "pnpm --filter @prompt/waterfall-wiki test"
|
||||
"test": "pnpm --filter @prompt/waterfall-wiki test",
|
||||
"test:brand": "pnpm --filter @endoleap/brand test",
|
||||
"build:brand": "pnpm --filter @endoleap/brand build"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
FROM node:24-bookworm-slim
|
||||
WORKDIR /repo
|
||||
RUN corepack enable && corepack prepare pnpm@11.25.0 --activate
|
||||
COPY packages/brand/package.json packages/brand/pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||
COPY packages/brand/ ./
|
||||
RUN pnpm test && pnpm build && pnpm test:tailwind && pnpm test:types
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends python3 python3-venv \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& python3 -m venv /opt/brand-python \
|
||||
&& /opt/brand-python/bin/pip install --only-binary=:all: Pillow==13.0.0 resvg-py==0.5.0
|
||||
RUN /opt/brand-python/bin/python tests/assets.py
|
||||
@@ -0,0 +1,41 @@
|
||||
# 啡跃 Endoleap 品牌包
|
||||
|
||||
本包把已选定的「摩卡奶霜」配色、四套正式主题和固定轮廓 Logo 放在同一个 workspace 包中。它提供 Tailwind CSS v4 / shadcn/ui 的主题接口及静态图片,不安装组件,也不自动重设应用页面。
|
||||
|
||||
## 文档入口
|
||||
|
||||
- [品牌与包边界](docs/overview.md)
|
||||
- [配色、组件状态与深色主题](docs/colors.md)
|
||||
- [Tailwind CSS、shadcn/ui 与包导入](docs/integration.md)
|
||||
- [Logo、平台图片与修改流程](docs/logo.md)
|
||||
|
||||
`tokens/palette.json` 是色值来源;四份主题 JSON 引用色板键;`tokens/geometry.json` 是唯一 Logo 路径来源。`src/themes.css` 是可直接导入的源样式,一致性由测试核对;`dist/` 仅在远程 CI 构建,不提交。
|
||||
|
||||
```css
|
||||
@import "tailwindcss";
|
||||
@import "@endoleap/brand/themes.css";
|
||||
@import "@endoleap/brand/tailwind.css";
|
||||
```
|
||||
|
||||
```tsx
|
||||
// 在主题根节点选择正式主题;应用负责持久化和切换。
|
||||
<main data-theme="canvas-dark" className="bg-background text-foreground">
|
||||
<button className="bg-primary text-primary-foreground hover:bg-primary-hover">
|
||||
开始创作
|
||||
</button>
|
||||
</main>
|
||||
```
|
||||
|
||||
主题名:`home-light`、`home-dark`、`canvas-light`、`canvas-dark`。没有 `drafts`、`proposed` 或隐式跟随系统的规则。`.dark` 兼容默认首页深色;显式 `data-theme` 优先。使用 `dark:` 工具类时,一个文档根节点只选择一个明暗主题,不在 `.dark` 祖先下嵌套浅色 `dark:` 变体。
|
||||
|
||||
## 验证
|
||||
|
||||
可进行的本地轻量检查:`node --test packages/brand/tests/brand.test.mjs`。它只读取源码和资产,检查主题、对比度、SVG 路径及 PNG/ICO 文件头,不编译和输出产物。
|
||||
|
||||
完整验证在已确认的 ces-control Docker runner 执行:
|
||||
|
||||
```sh
|
||||
docker build -f packages/brand/Dockerfile -t endoleap-brand-check .
|
||||
```
|
||||
|
||||
包含真实 Tailwind 编译、TypeScript 导入检查、品牌导出和 72 个 PNG 与 SVG 逐像素比较。不要在本机运行该 Docker 构建或 `build` / `test:tailwind` / `test:types` / `test:assets`。
|
||||
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"version": "1.2",
|
||||
"brand": "啡跃 Endoleap",
|
||||
"colors": {
|
||||
"mocha": "#A47764",
|
||||
"brown": "#49372E",
|
||||
"cream": "#FFF6EF"
|
||||
},
|
||||
"geometry": "tokens/geometry.json",
|
||||
"svg_files": [
|
||||
"assets/svg/logo-light.svg",
|
||||
"assets/svg/logo-dark.svg",
|
||||
"assets/svg/mono-dark.svg",
|
||||
"assets/svg/mono-light.svg",
|
||||
"assets/svg/icon-dark.svg",
|
||||
"assets/svg/icon-light.svg",
|
||||
"assets/svg/app-square.svg",
|
||||
"assets/svg/avatar-circle.svg"
|
||||
],
|
||||
"png_sizes": [
|
||||
16,
|
||||
24,
|
||||
32,
|
||||
48,
|
||||
64,
|
||||
128,
|
||||
180,
|
||||
192,
|
||||
256,
|
||||
512,
|
||||
1024,
|
||||
2048
|
||||
],
|
||||
"ico_sizes": [
|
||||
16,
|
||||
24,
|
||||
32,
|
||||
48,
|
||||
64,
|
||||
128,
|
||||
256
|
||||
],
|
||||
"variants": {
|
||||
"logo-light": {
|
||||
"rim": "#A47764",
|
||||
"bowl": "#49372E"
|
||||
},
|
||||
"logo-dark": {
|
||||
"rim": "#A47764",
|
||||
"bowl": "#FFF6EF"
|
||||
},
|
||||
"mono-dark": {
|
||||
"rim": "#49372E",
|
||||
"bowl": "#49372E"
|
||||
},
|
||||
"mono-light": {
|
||||
"rim": "#FFF6EF",
|
||||
"bowl": "#FFF6EF"
|
||||
},
|
||||
"icon-dark": {
|
||||
"rim": "#A47764",
|
||||
"bowl": "#FFF6EF",
|
||||
"background": "#49372E"
|
||||
},
|
||||
"icon-light": {
|
||||
"rim": "#A47764",
|
||||
"bowl": "#49372E",
|
||||
"background": "#FFF6EF"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 527 B |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 755 B |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 981 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 513 B |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 742 B |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 943 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 430 B |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 613 B |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 780 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 451 B |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 665 B |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 849 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 416 B |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 646 B |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 808 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 364 B |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 536 B |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 681 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 83 KiB |