Compare commits

..

5 Commits

Author SHA1 Message Date
codex-bot 80d23f0e3d Improve Docker dependency caching and isolate Python runtime
Endoleap brand checks / verify (pull_request) Successful in 2m27s
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 3m10s
creative workspace review checks / verify (pull_request) Successful in 42m47s
python CI and deployment / verify-and-deploy (pull_request) Successful in 9m51s
2026-11-02 21:54:14 +08:00
Zayden-Jung ef290f769b Merge pull request 'fix(ci): 以应用就绪验证生产页面并前置完整线上验收' (#45) from codex/production-navigation-ready into main
creative workspace CI and deployment / verify-and-deploy (push) Successful in 17m23s
Reviewed-on: #45
2026-11-02 09:06:22 +00:00
codex-bot 38a71e04ca fix(ci): accept hydrated markers hidden by responsive layouts
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 3m24s
creative workspace review checks / verify (pull_request) Successful in 25m23s
2026-11-02 16:32:37 +08:00
codex-bot 7f397fd3a2 fix(ci): verify production readiness without network idle
creative workspace review checks / verify-deployed-canvas (pull_request) Failing after 2m45s
creative workspace review checks / verify (pull_request) Has been cancelled
2026-11-02 16:20:08 +08:00
Zayden-Jung 5a317008df Merge pull request 'feat(home): 手绘场景与编辑式内容丰富首页' (#44) from codex/landing-editorial into main
creative workspace CI and deployment / verify-and-deploy (push) Failing after 15m54s
Reviewed-on: #44
2026-11-02 07:56:43 +00:00
14 changed files with 135 additions and 36 deletions
+15
View File
@@ -22,3 +22,18 @@ coverage
# Local authoring assets are uploaded via APIs, never application build inputs.
/content
# Apply local metadata, secrets and generated-output exclusions at every depth.
**/.git
**/.worktrees
**/.pnpm-store
**/.turbo
**/.nx
**/coverage
**/artifacts
**/.pytest_cache
**/*.egg-info
**/*.tsbuildinfo
**/.env
**/.env.*
!**/.env.example
+2
View File
@@ -3,6 +3,7 @@ on:
pull_request:
branches: [main]
paths:
- '.dockerignore'
- 'packages/brand/**'
- 'pnpm-workspace.yaml'
- 'package.json'
@@ -11,6 +12,7 @@ on:
push:
branches: [main]
paths:
- '.dockerignore'
- 'packages/brand/**'
- 'pnpm-workspace.yaml'
- '.gitea/workflows/brand.yml'
+21 -11
View File
@@ -3,7 +3,11 @@ on:
pull_request:
branches: [main]
paths:
- '.dockerignore'
- 'Dockerfile'
- 'pnpm-workspace.yaml'
- 'apps/studio/**'
- 'packages/brand/**'
- 'apps/waterfall-wiki/**'
- '.gitea/workflows/**'
- 'scripts/*studio*'
@@ -19,21 +23,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
+8 -1
View File
@@ -1,15 +1,22 @@
name: python CI and deployment
on:
pull_request:
branches: [main]
paths:
- '.dockerignore'
- 'apps/python/**'
- 'scripts/deploy-python.sh'
- '.gitea/workflows/python.yml'
push:
branches: [main]
paths:
- '.dockerignore'
- 'apps/python/**'
- 'scripts/deploy-python.sh'
- '.gitea/workflows/python.yml'
jobs:
verify-and-deploy:
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
runs-on: ces-control
timeout-minutes: 45
steps:
+2
View File
@@ -3,8 +3,10 @@ on:
push:
branches: [main]
paths:
- '.dockerignore'
- 'apps/waterfall-wiki/**'
- 'apps/studio/**'
- 'packages/brand/**'
- 'scripts/*studio*'
- 'content/studio-examples/**'
- 'content/studio-tools/layouts-api.md'
+3 -1
View File
@@ -12,12 +12,14 @@ LABEL io.entroia.wiki.build="true"
RUN corepack enable && corepack prepare pnpm@11.25.0 --activate
COPY apps/waterfall-wiki/pnpm-lock.yaml ./
COPY pnpm-workspace.yaml /repo/pnpm-workspace.yaml
COPY packages/brand/ /repo/packages/brand/
COPY packages/brand/package.json /repo/packages/brand/package.json
COPY apps/waterfall-wiki/package.json ./
RUN pnpm --filter @prompt/waterfall-wiki install --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8
FROM dependencies AS build
# .dockerignore excludes docs and operational notes without extra legacy COPY snapshots.
# Brand source changes invalidate compilation, not dependency installation.
COPY packages/brand/ /repo/packages/brand/
COPY apps/waterfall-wiki/ .
RUN pnpm typecheck && pnpm test && pnpm build && pnpm test:smoke
+16 -2
View File
@@ -1,8 +1,22 @@
FROM python:3.12-slim
FROM python:3.12-slim AS base
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg fonts-noto-cjk && rm -rf /var/lib/apt/lists/*
FROM base AS dependencies
ENV PATH="/opt/venv/bin:$PATH"
WORKDIR /app
RUN python -m venv /opt/venv
COPY apps/python/pyproject.toml ./
# Read the pinned requirements from the package metadata before copying source.
RUN python -c 'import subprocess, tomllib; p = tomllib.load(open("pyproject.toml", "rb")); subprocess.check_call(["python", "-m", "pip", "install", "--no-cache-dir", *p["build-system"]["requires"], *p["project"]["dependencies"]])'
FROM dependencies AS build
COPY apps/python/ .
RUN pip install --no-cache-dir . && python -m unittest discover -s tests -v
RUN pip install --no-cache-dir --no-deps --no-build-isolation . && python -m unittest discover -s tests -v
FROM base AS runtime
WORKDIR /app
ENV PATH="/opt/venv/bin:$PATH"
COPY --from=build /opt/venv /opt/venv
RUN useradd --create-home exporter
USER exporter
ENTRYPOINT ["prompt-export"]
+3 -1
View File
@@ -3,9 +3,11 @@ WORKDIR /repo/apps/studio
RUN corepack enable && corepack prepare pnpm@11.25.0 --activate
COPY apps/studio/pnpm-lock.yaml ./
COPY pnpm-workspace.yaml /repo/pnpm-workspace.yaml
COPY packages/brand/ /repo/packages/brand/
COPY packages/brand/package.json /repo/packages/brand/package.json
COPY apps/studio/package.json ./
RUN pnpm --filter @prompt/studio install --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8
# Brand source changes invalidate compilation, not dependency installation.
COPY packages/brand/ /repo/packages/brand/
COPY apps/studio/ .
# Compare the two public parameter validators inside the isolated CI build.
COPY apps/waterfall-wiki/src/lib/layout-parameters.ts /repo/apps/waterfall-wiki/src/lib/layout-parameters.ts
+3 -1
View File
@@ -10,12 +10,14 @@ WORKDIR /repo/apps/waterfall-wiki
RUN corepack enable && corepack prepare pnpm@11.25.0 --activate
COPY apps/waterfall-wiki/pnpm-lock.yaml ./
COPY pnpm-workspace.yaml /repo/pnpm-workspace.yaml
COPY packages/brand/ /repo/packages/brand/
COPY packages/brand/package.json /repo/packages/brand/package.json
COPY apps/waterfall-wiki/package.json ./
RUN pnpm --filter @prompt/waterfall-wiki install --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8
FROM dependencies AS build
# .dockerignore excludes docs and operational notes without extra legacy COPY snapshots.
# Brand source changes invalidate compilation, not dependency installation.
COPY packages/brand/ /repo/packages/brand/
COPY apps/waterfall-wiki/ .
RUN pnpm typecheck && pnpm test && pnpm build && pnpm test:smoke
@@ -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;
@@ -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']);
});
+23
View File
@@ -0,0 +1,23 @@
# Docker 构建上下文与缓存
## 入口与忽略规则
所有 CI 构建命令都在仓库根目录使用 `.` 作为 context,包括以 `-f` 选择的子项目 Dockerfile。统一使用根 `.dockerignore`,递归排除依赖目录、Git 元数据、本机环境文件、编译输出、测试证据及缓存;保留 `.env.example` 供 Compose 校验使用。
普通的子目录 `.dockerignore` 不会自动叠加到根 context。当前 runner 使用 legacy builder,不依赖 Dockerfile 专属忽略文件或 BuildKit cache mount。若以后改用子目录 context,必须同时调整共享包与跨应用文件的输入路径,不能只在子目录添加忽略文件。
## 依赖层与源码层
- Wiki 的根 Dockerfile 是实际 CI 入口,`apps/waterfall-wiki/Dockerfile` 是对应副本,两者的功能内容保持同步;根文件额外保留镜像生命周期标签。
- Web 应用安装前只复制自身清单、锁文件、workspace 配置和品牌包清单,满足 `workspace:*` 依赖发现。完整品牌包在安装后复制;颜色、Logo 或品牌脚本变化仍会重新编译应用,但不会仅因此重新下载依赖。
- Python 从 `pyproject.toml` 读取固定运行依赖与构建后端要求,在独立依赖阶段安装。源码阶段安装当前包并运行单测,最终镜像仅继承系统运行环境并复制已验证的虚拟环境,不携带应用源码目录和测试目录。虚拟环境仍包含 pip 和构建后端,未声称完全剥离构建工具。
- 品牌检查镜像只用于 CI。其 Python 图片校验依赖安装放在品牌源码之前,避免源码变化重新下载这些依赖。
- 不因共享包缓存命中而漏掉消费者:品牌变更需触发 Wiki/Studio PR 检查和 main 发布。根忽略规则变化也触发相关检查。
## 验证与限制
构建、打包与产物验证只在已确认的 `ces-control` runner 执行。Python PR 运行镜像构建和单测,但只有 main push 才会发布 latest 标签。Web PR 与品牌检查沿用现有远程 Docker 验证流程。
本次没有升级 Docker builder,也没有承诺消除 legacy builder 的镜像层提交耗时;缓存层调整后的实际收益需比较后续 CI 日志。源码变化仍需构建,持久测试环境和按影响范围选择测试属于后续 CI 调整。
本机不新增 Dev Containers/Tilt 启动方式;这类方案需要单独确认本地运行和编译授权。
+2 -2
View File
@@ -3,10 +3,10 @@ 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
COPY packages/brand/ ./
RUN pnpm test && pnpm build && pnpm test:tailwind && pnpm test:types
RUN /opt/brand-python/bin/python tests/assets.py
+26 -14
View File
@@ -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&&current.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&&current.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)});