From 7f397fd3a262882e8e25ca5ce440e73c932cdc9f Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Mon, 2 Nov 2026 16:20:08 +0800 Subject: [PATCH 1/2] fix(ci): verify production readiness without network idle --- .gitea/workflows/creative-workspace.yml | 28 ++++++++++------- scripts/verify-studio-visual.cjs | 40 ++++++++++++++++--------- 2 files changed, 43 insertions(+), 25 deletions(-) diff --git a/.gitea/workflows/creative-workspace.yml b/.gitea/workflows/creative-workspace.yml index 740a735..ee90d9a 100644 --- a/.gitea/workflows/creative-workspace.yml +++ b/.gitea/workflows/creative-workspace.yml @@ -19,21 +19,27 @@ concurrency: jobs: verify-deployed-canvas: runs-on: ces-control - timeout-minutes: 10 + timeout-minutes: 15 steps: - uses: actions/checkout@v4 - - name: Verify canvas acceptance script against the designated deployed server + - name: Verify production acceptance against the designated deployed server run: | + node --check scripts/verify-studio-visual.cjs node --check apps/studio/scripts/infinite-canvas-smoke.cjs - wiki_image=$(docker inspect --format '{{.Config.Image}}' prompt-waterfall-wiki-1) - [[ "$wiki_image" =~ ^waterfall-wiki:[0-9a-f]{40}$ ]] - test_image="waterfall-wiki-tests:${wiki_image#waterfall-wiki:}" - docker image inspect "$test_image" >/dev/null - docker run --rm \ - --mount "type=bind,src=$PWD,dst=/delivery,readonly" \ - -e NODE_PATH=/repo/apps/waterfall-wiki/node_modules \ - -e CHROMIUM_PATH=/usr/bin/chromium \ - --entrypoint node "$test_image" /delivery/apps/studio/scripts/infinite-canvas-smoke.cjs + bash -n scripts/verify-studio-production.sh + bash scripts/verify-studio-production.sh + - name: Save server acceptance evidence + id: acceptance_evidence + if: always() + uses: actions/upload-artifact@v3 + with: + name: studio-production-acceptance + path: artifacts/studio-acceptance/ + if-no-files-found: ignore + retention-days: 7 + - name: Remove temporary acceptance output after upload + if: always() && steps.acceptance_evidence.outcome == 'success' + run: rm -rf artifacts/studio-acceptance verify: runs-on: ces-control timeout-minutes: 60 diff --git a/scripts/verify-studio-visual.cjs b/scripts/verify-studio-visual.cjs index 4a8c3d6..f5a631b 100644 --- a/scripts/verify-studio-visual.cjs +++ b/scripts/verify-studio-visual.cjs @@ -8,25 +8,35 @@ const assert=require('node:assert/strict'); const now=Math.floor(Date.now()/1000),part=o=>Buffer.from(JSON.stringify(o)).toString('base64url'); const content=part({alg:'HS256',typ:'JWT'})+'.'+part({iss:'prompt',aud:'prompt-web',sub:'shared',iat:now,exp:now+1200,jti:randomBytes(16).toString('hex')}); const token=content+'.'+createHmac('sha256',Buffer.from(config.jwtKey,'hex')).update(content).digest('base64url'); + const {navigateBrandDocument,visitBrowserDocument}=await import('../apps/waterfall-wiki/tests/brand-navigation.mjs'); const browser=await chromium.launch({executablePath:'/usr/bin/chromium',headless:true,args:['--no-sandbox','--disable-dev-shm-usage']}); const context=await browser.newContext({viewport:{width:1100,height:1000},deviceScaleFactor:1}); await context.route('**/*',async route=>{const u=new URL(route.request().url());if(u.origin===origin)return route.continue({headers:{...route.request().headers(),Authorization:'Bearer '+token}});if(['data:','blob:'].includes(u.protocol))return route.continue();return route.abort()}); const page=await context.newPage(),errors=[],images=[]; - page.on('pageerror',error=>errors.push({url:page.url(),message:error.message})); + page.setDefaultTimeout(60000); + page.on('pageerror',error=>errors.push({url:page.url(),message:error.message,stack:error.stack})); page.on('response',async response=>{if(response.request().resourceType()==='image'){const url=new URL(response.url());images.push({path:url.pathname+url.search,status:response.status(),type:response.headers()['content-type'],bytes:(await response.body().catch(()=>Buffer.alloc(0))).length})}}); + // Wait for the actual document and application, not an idle background network. + // Shared navigation recovery is limited to safe GETs; assertions are never retried. + const visitDocument=async(url,interactive=false,reload=false)=>{ + if(interactive)await visitBrowserDocument(page,url,reload); + else await navigateBrandDocument(timeout=>page.goto(url,{waitUntil:'domcontentloaded',timeout}),url); + await page.waitForFunction(()=>Array.from(document.images).every(img=>img.complete&&img.naturalWidth>0)); + console.log('Production document ready:',new URL(url).pathname); + }; // The retired catalogue is retained for existing API consumers, not a release UI gate. // Explicit legacy/diagnostic runs still exercise its full overflow assertions. const checkLegacy=process.argv.includes('--legacy-layouts')||process.argv.includes('--diagnose'); const metrics=[]; for(let i=1;i<=27&&checkLegacy;i++){ - const id='ref-'+String(i).padStart(2,'0');await page.goto(base+'/studio/layouts/'+id,{waitUntil:'networkidle'});await page.locator('article.native-layout').screenshot({path:dir+'/'+id+'.png',style:'.page-tools{visibility:hidden}'}); + const id='ref-'+String(i).padStart(2,'0');await visitDocument(base+'/studio/layouts/'+id);await page.locator('article.native-layout').screenshot({path:dir+'/'+id+'.png',style:'.page-tools{visibility:hidden}'}); const m=await page.locator('article.native-layout').evaluate(el=>{ const overflow=[...el.querySelectorAll('.native-copy')].filter(e=>e.scrollHeight>e.clientHeight+2||e.scrollWidth>e.clientWidth+2); return {id:el.dataset.layoutId,elements:el.querySelectorAll('[data-element]').length,width:el.clientWidth,height:el.clientHeight,overflow:overflow.map(e=>e.closest('[data-element]').dataset.element),overflowDetails:overflow.map(e=>{const style=getComputedStyle(e);return {id:e.closest('[data-element]').dataset.element,text:e.textContent,clientWidth:e.clientWidth,scrollWidth:e.scrollWidth,clientHeight:e.clientHeight,scrollHeight:e.scrollHeight,fontSize:style.fontSize,lineHeight:style.lineHeight,letterSpacing:style.letterSpacing,fontFamily:style.fontFamily,fontWeight:style.fontWeight,writingMode:style.writingMode}})}; });metrics.push(m);await writeFile(dir+'/metrics.json',JSON.stringify({metrics,images,errors},null,2)); } if(checkLegacy&&!process.argv.includes('--diagnose'))assert.deepEqual(metrics.filter(m=>m.overflow.length),[],'Default copy must fit every native slot'); - await page.goto(base+'/',{waitUntil:'networkidle'});await page.getByRole('heading',{name:/想法不该停在对话里/}).waitFor();await page.screenshot({path:dir+'/landing-desktop.png',fullPage:true}); + await visitDocument(base+'/',true);await page.getByRole('heading',{name:/想法不该停在对话里/}).waitFor();await page.screenshot({path:dir+'/landing-desktop.png',fullPage:true}); // Capture the complete editorial home at both narrow breakpoints on the deployed server. await page.getByRole('heading',{name:/给好奇心一点空间/}).waitFor(); for(const [width,name] of [[390,'landing-mobile'],[320,'landing-mobile-compact']]){ @@ -38,7 +48,7 @@ const assert=require('node:assert/strict'); } // Verify the public account flow in this fresh context, without submitting credentials. await page.setViewportSize({width:1128,height:959}); - await page.goto(base+'/account?next=/workspace',{waitUntil:'domcontentloaded'}); + await visitDocument(base+'/account?next=/workspace',true); await page.locator('button[data-theme-ready="true"]').waitFor(); await page.getByLabel('邮箱地址',{exact:true}).waitFor(); assert.equal(await page.locator('aside').count(),0,'Authentication must not include a workspace sidebar'); @@ -53,43 +63,45 @@ const assert=require('node:assert/strict'); await page.screenshot({path:dir+'/account-desktop.png',fullPage:true}); await page.getByRole('button',{name:'创建账户',exact:true}).click(); await page.getByRole('heading',{name:'创建你的账户',exact:true}).waitFor(); + await page.waitForFunction(()=>document.querySelector('#account-content').getAnimations({subtree:true}).every(animation=>animation.playState!=='running')); await page.screenshot({path:dir+'/account-register.png',fullPage:true}); await page.getByRole('button',{name:'登录',exact:true}).click(); + await page.waitForFunction(()=>document.querySelector('#account-content').getAnimations({subtree:true}).every(animation=>animation.playState!=='running')); await page.setViewportSize({width:390,height:844}); await accountFits();await accountControlsFit(); await page.screenshot({path:dir+'/account-mobile.png',fullPage:true}); await page.setViewportSize({width:320,height:568});await accountFits();await accountControlsFit(); await page.screenshot({path:dir+'/account-mobile-compact.png',fullPage:true}); await page.setViewportSize({width:1100,height:1000}); - await page.goto(base+'/graphic',{waitUntil:'networkidle'}); + await visitDocument(base+'/graphic',true); assert.equal(new URL(page.url()).pathname,'/graphic'); await page.getByRole('button',{name:'添加文字',exact:true}).waitFor({state:'visible'}); assert.equal(await page.locator('.library-item').count(),0,'Old catalog is retired from the entry page'); await page.getByRole('button',{name:'添加文字',exact:true}).click(); await page.getByRole('textbox',{name:'文字内容',exact:true}).fill('Studio 线上验收'); - await page.reload({waitUntil:'networkidle'}); + await visitDocument(page.url(),true,true); assert.ok(await page.evaluate(()=>JSON.parse(localStorage.getItem('studio.graphic.v1')).items.some(i=>i.text==='Studio 线上验收'))); await page.screenshot({path:dir+'/graphic-desktop.png',fullPage:true}); - await page.goto(base+'/infinite_canvas',{waitUntil:'networkidle'}); + await visitDocument(base+'/infinite_canvas',true); assert.equal(new URL(page.url()).pathname,'/infinite_canvas'); await page.getByRole('button',{name:'添加笔记',exact:true}).waitFor({state:'visible'}); await page.screenshot({path:dir+'/infinite-canvas-desktop.png',fullPage:true}); const verify=JSON.parse(await readFile('/tmp/studio-library/verification.json','utf8')); - for(const d of verify.results){await page.goto(base+d.url,{waitUntil:'networkidle'});assert.equal(await page.locator('img').count(),1);const current=await page.locator('img').evaluate(img=>({src:img.currentSrc,complete:img.complete,width:img.naturalWidth}));assert.ok(current.complete&¤t.width>0);assert.equal(new URL(current.src).pathname,'/api/preview');await page.locator('article.native-layout').screenshot({path:dir+'/'+d.layoutId+'-saved.png',style:'.page-tools{visibility:hidden}'}); + for(const d of verify.results){await visitDocument(base+d.url);assert.equal(await page.locator('img').count(),1);const current=await page.locator('img').evaluate(img=>({src:img.currentSrc,complete:img.complete,width:img.naturalWidth}));assert.ok(current.complete&¤t.width>0);assert.equal(new URL(current.src).pathname,'/api/preview');await page.locator('article.native-layout').screenshot({path:dir+'/'+d.layoutId+'-saved.png',style:'.page-tools{visibility:hidden}'}); assert.equal(new URL(page.url()).pathname,'/studio/template-layout'); assert.equal(await page.locator('button,input,textarea,select,[contenteditable],.page-tools,.workbench-top').count(),0); await page.locator('.native-copy').first().dblclick();await page.locator('.native-copy').first().dispatchEvent('contextmenu'); assert.equal(await page.locator('button,input,textarea,select,[contenteditable],.workbench-top').count(),0); const plain=await browser.newContext({viewport:{width:1100,height:1000},javaScriptEnabled:false}); await plain.route('**/*',async route=>{const u=new URL(route.request().url());if(u.origin===origin)return route.continue({headers:{...route.request().headers(),Authorization:'Bearer '+token}});return route.abort()}); - const direct=await plain.newPage();await direct.goto(base+d.url,{waitUntil:'networkidle'});assert.equal(await direct.locator('article[data-design-id="'+d.id+'"]').count(),1);assert.ok((await direct.locator('article').innerText()).includes('API 验收'));await plain.close(); + const direct=await plain.newPage();await navigateBrandDocument(timeout=>direct.goto(base+d.url,{waitUntil:'domcontentloaded',timeout}),base+d.url);assert.equal(await direct.locator('article[data-design-id="'+d.id+'"]').count(),1);assert.ok((await direct.locator('article').innerText()).includes('API 验收'));await plain.close(); } - for(const slug of ['learning-skills-template','layout-selection']){await page.goto(base+'/studio/ideas/'+slug,{waitUntil:'networkidle'});await page.screenshot({path:dir+'/tool-'+slug+'.png',fullPage:true})} - await page.setViewportSize({width:390,height:844});await page.goto(base+'/graphic',{waitUntil:'networkidle'});assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await page.screenshot({path:dir+'/graphic-mobile.png',fullPage:true}); - await page.goto(base+verify.results[0].url,{waitUntil:'networkidle'});await page.screenshot({path:dir+'/standard-mobile.png',fullPage:true}); - await page.goto(base+'/categories?category='+encodeURIComponent('文档入口'),{waitUntil:'domcontentloaded',timeout:60000});await page.getByRole('navigation',{name:'系统标签分类',exact:true}).waitFor({state:'visible'});assert.equal(await page.locator('.discovery-card').count(),3);await page.waitForFunction(()=>Array.from(document.querySelectorAll('.discovery-card img')).every(img=>img.complete&&img.naturalWidth>0),null,{timeout:60000});await page.screenshot({path:dir+'/document-entries.png',fullPage:true}); + for(const slug of ['learning-skills-template','layout-selection']){await visitDocument(base+'/studio/ideas/'+slug);await page.screenshot({path:dir+'/tool-'+slug+'.png',fullPage:true})} + await page.setViewportSize({width:390,height:844});await visitDocument(base+'/graphic',true);assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await page.screenshot({path:dir+'/graphic-mobile.png',fullPage:true}); + await visitDocument(base+verify.results[0].url);await page.screenshot({path:dir+'/standard-mobile.png',fullPage:true}); + await visitDocument(base+'/categories?category='+encodeURIComponent('文档入口'),true);await page.getByRole('navigation',{name:'系统标签分类',exact:true}).waitFor({state:'visible'});assert.equal(await page.locator('.discovery-card').count(),3);await page.waitForFunction(()=>Array.from(document.querySelectorAll('.discovery-card img')).every(img=>img.complete&&img.naturalWidth>0),null,{timeout:60000});await page.screenshot({path:dir+'/document-entries.png',fullPage:true}); await writeFile(dir+'/metrics.json',JSON.stringify({metrics,images,errors},null,2)); assert.equal(images.filter(r=>new URL(r.path,base).pathname.startsWith('/api/media/')).length,0,'Display must never fetch originals');if(!process.argv.includes('--diagnose'))assert.equal(errors.length,0,JSON.stringify(errors));for(const image of images){if(/^\/(?:studio\/)?brand\/(?:logo-light|logo-dark|icon-dark)\.svg$/.test(new URL(image.path,base).pathname)){assert.equal(image.status,200);assert.match(image.type,/image\/svg\+xml/);continue;}assert.equal(new URL(image.path,base).pathname,'/api/preview');assert.equal(image.status,200);assert.equal(image.type,'image/webp');assert.ok(image.bytes>0&&image.bytes<=220000)} await writeFile(dir+'/metrics.json',JSON.stringify({metrics,images,errors},null,2));await browser.close(); console.log(JSON.stringify({diagnostic:process.argv.includes('--diagnose'),legacyLayoutsChecked:checkLegacy,legacyPages:metrics.length,errors:errors.length,displayImages:images.length,originalImageRequests:0,overflow:metrics.filter(m=>m.overflow.length)})); -})().catch(e=>{console.error(e.message);process.exit(1)}); +})().catch(e=>{console.error(e.stack||e.message);process.exit(1)}); -- 2.52.0 From 38a71e04ca72ca086fb136b89b72c48309c49f0a Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Mon, 2 Nov 2026 16:32:37 +0800 Subject: [PATCH 2/2] fix(ci): accept hydrated markers hidden by responsive layouts --- apps/waterfall-wiki/tests/brand-navigation.mjs | 4 +++- apps/waterfall-wiki/tests/brand-navigation.test.mjs | 10 ++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/waterfall-wiki/tests/brand-navigation.mjs b/apps/waterfall-wiki/tests/brand-navigation.mjs index c6d359e..b7bc03d 100644 --- a/apps/waterfall-wiki/tests/brand-navigation.mjs +++ b/apps/waterfall-wiki/tests/brand-navigation.mjs @@ -51,9 +51,11 @@ export async function visitBrowserDocument(page, url, reload = false) { const remaining = timeout - (Date.now() - started); if (remaining <= 0) throw new Error('Document hydration budget exhausted: ' + url); try { + // Responsive layouts may hide the toggle. Its mounted ready marker + // proves hydration; each caller still checks the controls it needs. // A cancelled script may be a preload duplicate. Hydration is the // authority; recover only when cancellation actually blocks startup. - await page.locator('button[data-theme-ready="true"]').first().waitFor({timeout: failures.length ? Math.min(5000, remaining) : remaining}); + await page.locator('button[data-theme-ready="true"]').first().waitFor({state: 'attached', timeout: failures.length ? Math.min(5000, remaining) : remaining}); } catch (error) { if (failures.length) throw new Error('Document bootstrap script request failed: ' + failures.join('; '), {cause: error}); throw error; diff --git a/apps/waterfall-wiki/tests/brand-navigation.test.mjs b/apps/waterfall-wiki/tests/brand-navigation.test.mjs index b3ebd02..2502f73 100644 --- a/apps/waterfall-wiki/tests/brand-navigation.test.mjs +++ b/apps/waterfall-wiki/tests/brand-navigation.test.mjs @@ -49,7 +49,7 @@ test('recovery cannot extend the total navigation budget', async () => { assert.equal(calls, 1); }); -function browserFixture(failBootstrap = false, failHydration = false, blockedOnce = false) { +function browserFixture(failBootstrap = false, failHydration = false, blockedOnce = false, readyVisible = true) { const listeners = new Map(), calls = []; const page = { on(event, fn) { if (!listeners.has(event)) listeners.set(event, new Set()); listeners.get(event).add(fn); }, @@ -61,7 +61,7 @@ function browserFixture(failBootstrap = false, failHydration = false, blockedOnc if (failBootstrap && calls.length === 1) for (const fn of listeners.get('requestfailed') || []) fn(request); return response(200); }, - locator() { return {first: () => ({waitFor: async () => { calls.push('hydrated'); if (failHydration || (blockedOnce && calls.length === 2)) throw Error('Hydration timeout'); }})}; }, + locator() { return {first: () => ({waitFor: async options => { calls.push('hydrated'); if (!readyVisible && options.state !== 'attached') throw Error('Ready marker is hidden by responsive CSS'); if (failHydration || (blockedOnce && calls.length === 2)) throw Error('Hydration timeout'); }})}; }, }; return {page, calls, listeners}; } @@ -87,3 +87,9 @@ test('cancelled script does not reject an already hydrated document', async () = await visitBrowserDocument(page, '/account'); assert.deepEqual(calls, ['domcontentloaded', 'hydrated']); }); + +test('hydrated pages are ready even when responsive CSS hides the marker', async () => { + const {page, calls} = browserFixture(false, false, false, false); + await visitBrowserDocument(page, '/graphic'); + assert.deepEqual(calls, ['domcontentloaded', 'hydrated']); +}); -- 2.52.0