From 53a4103308ba69411a26382ce8a5028b7ee444e2 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 30 Oct 2026 16:57:48 +0800 Subject: [PATCH 1/8] Sequence creative workspace releases and verify document discovery at its new entry --- .gitea/workflows/creative-workspace.yml | 17 +++++-- .gitea/workflows/studio.yml | 51 ------------------- .gitea/workflows/waterfall-wiki.yml | 32 +++++++++++- README.md | 4 +- .../tests/guidance-integration.ts | 4 +- scripts/deploy-studio.sh | 2 +- scripts/verify-studio-production.sh | 13 +++-- 7 files changed, 54 insertions(+), 69 deletions(-) delete mode 100644 .gitea/workflows/studio.yml diff --git a/.gitea/workflows/creative-workspace.yml b/.gitea/workflows/creative-workspace.yml index ca7fc48..a5eb2aa 100644 --- a/.gitea/workflows/creative-workspace.yml +++ b/.gitea/workflows/creative-workspace.yml @@ -5,14 +5,15 @@ on: paths: - 'apps/studio/**' - 'apps/waterfall-wiki/**' - - '.gitea/workflows/creative-workspace.yml' + - '.gitea/workflows/**' + - 'scripts/*studio*' concurrency: group: creative-workspace-${{ gitea.event.pull_request.number }} cancel-in-progress: true jobs: verify: runs-on: ces-control - timeout-minutes: 45 + timeout-minutes: 60 steps: - uses: actions/checkout@v4 with: @@ -21,5 +22,13 @@ jobs: run: git diff --check HEAD^ HEAD - name: Verify Studio types, tests, build and smoke run: docker build -f apps/studio/Dockerfile -t "creative-studio-review:${{ gitea.sha }}" . - - name: Verify Wiki types, tests, build and smoke - run: docker build --target build -f apps/waterfall-wiki/Dockerfile -t "creative-wiki-review:${{ gitea.sha }}" . + - name: Build Wiki integration and runtime images + run: | + docker build --target integration -t "creative-wiki-tests:${{ gitea.sha }}" . + docker build --target runtime -t "creative-wiki-review:${{ gitea.sha }}" . + - name: Verify isolated PostgreSQL, S3 and browser integration + env: + WATERFALL_WIKI_IMAGE: creative-wiki-review:${{ gitea.sha }} + WATERFALL_WIKI_TEST_IMAGE: creative-wiki-tests:${{ gitea.sha }} + GITEA_RUN_ID: ${{ gitea.run_id }} + run: bash scripts/integration.sh diff --git a/.gitea/workflows/studio.yml b/.gitea/workflows/studio.yml deleted file mode 100644 index b7bc0a3..0000000 --- a/.gitea/workflows/studio.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: studio CI and deployment -on: - push: - branches: [main] - paths: - - 'apps/studio/**' - - 'scripts/deploy-studio.sh' - - 'scripts/verify-studio-production.sh' - - 'scripts/verify-studio-library.mjs' - - 'scripts/verify-studio-visual.cjs' - - '.gitea/workflows/studio.yml' - - 'scripts/*studio*' - - 'content/studio-examples/**' - - 'content/studio-tools/layouts-api.md' - - 'pnpm-workspace.yaml' - -jobs: - verify-and-deploy: - if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push' - runs-on: ces-control - timeout-minutes: 45 - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Check shared content and documentation contracts - run: | - node scripts/check-studio-contracts.mjs - node --check scripts/export-studio-series.cjs - node --check scripts/save-studio-series.mjs - bash -n scripts/publish-studio-guide.sh scripts/deliver-studio-series.sh - - name: Build and verify studio - run: docker build -f apps/studio/Dockerfile -t "prompt-studio:${{ gitea.sha }}" . - - name: Release studio - if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push' - run: bash scripts/deploy-studio.sh "${{ gitea.sha }}" - - name: Verify the deployed Studio API and native pages - if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push' - run: 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 diff --git a/.gitea/workflows/waterfall-wiki.yml b/.gitea/workflows/waterfall-wiki.yml index 945aae9..5172da9 100644 --- a/.gitea/workflows/waterfall-wiki.yml +++ b/.gitea/workflows/waterfall-wiki.yml @@ -1,9 +1,13 @@ -name: waterfall-wiki CI and deployment +name: creative workspace CI and deployment on: push: branches: [main] paths: - 'apps/waterfall-wiki/**' + - 'apps/studio/**' + - 'scripts/*studio*' + - 'content/studio-examples/**' + - 'content/studio-tools/layouts-api.md' - '.gitea/workflows/waterfall-wiki.yml' - 'Dockerfile' - 'compose.yml' @@ -18,11 +22,19 @@ jobs: verify-and-deploy: if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push' runs-on: ces-control - timeout-minutes: 45 + timeout-minutes: 60 steps: - uses: actions/checkout@v4 with: fetch-depth: 0 + - name: Check shared content and documentation contracts + run: | + node scripts/check-studio-contracts.mjs + node --check scripts/export-studio-series.cjs + node --check scripts/save-studio-series.mjs + bash -n scripts/publish-studio-guide.sh scripts/deliver-studio-series.sh + - name: Build and verify Studio + run: docker build -f apps/studio/Dockerfile -t "prompt-studio:${{ gitea.sha }}" . - name: Build, verify, deploy and retain wiki images env: RELEASE_SHA: ${{ gitea.sha }} @@ -32,3 +44,19 @@ jobs: else sudo -n python3 scripts/wiki-image-lifecycle.py ci --sha "$RELEASE_SHA" fi + - name: Release Studio after Wiki routes are deployed + run: bash scripts/deploy-studio.sh "${{ gitea.sha }}" + - name: Verify the deployed creative workspace + run: 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 diff --git a/README.md b/README.md index e3e490d..1b6a690 100644 --- a/README.md +++ b/README.md @@ -81,14 +81,14 @@ bash -n scripts/deploy.sh scripts/integration.sh scripts/deploy-studio.sh ## 部署与持久化 -Gitea 工作流按应用路径触发:`waterfall-wiki.yml`、`studio.yml` 和 `python.yml`。waterfall-wiki 完成类型检查、单元测试、生产构建、冒烟和真实 PostgreSQL/S3 集成测试后部署;Studio 独立构建和部署。文档与作者数据本身不触发应用部署。 +Web 应用由 `waterfall-wiki.yml` 统一发布,两个应用的路径都能触发它:先检查并构建 Studio,再完成 Wiki 的类型检查、单元测试、生产构建、冒烟及隔离 PostgreSQL/S3 集成测试并部署 Wiki,随后部署 Studio,最后在指定服务器验收。顺序在同一个 ces-control 作业内保证,避免独立作业互相等待。PR 检查也运行隔离集成测试,但不部署。Python 检查仍由 `python.yml` 承担;文档与作者数据本身不触发应用部署。 | 服务 | 入口/端口 | | --- | --- | | `waterfall-wiki` | 公网 `18131`,也是 Studio 的统一入口 | | PostgreSQL | `127.0.0.1:18132` | | 对象存储 | `127.0.0.1:18133` | -| Studio | `127.0.0.1:18134`;公网通过 `/studio` 代理 | +| Studio | `127.0.0.1:18134`;编辑器公网入口为 `/graphic`、`/infinite_canvas`,资源和 API 保留 `/studio` 代理 | | Python | 无网络端口,按需运行 | 生产 Compose 项目仍为 `prompt`,部署根目录仍为 `/opt/prompt`。既有 `prompt_postgres_data`、`prompt_objects`、`prompt_auth_data` 卷与数据库身份保留;应用更名不会重新创建一套空存储。Compose 应用服务改为 `waterfall-wiki` 和 `waterfall-wiki-worker`,镜像参数为 `WATERFALL_WIKI_IMAGE` / `WATERFALL_WIKI_TEST_IMAGE`。 diff --git a/apps/waterfall-wiki/tests/guidance-integration.ts b/apps/waterfall-wiki/tests/guidance-integration.ts index 373dbe7..ff8f3ea 100644 --- a/apps/waterfall-wiki/tests/guidance-integration.ts +++ b/apps/waterfall-wiki/tests/guidance-integration.ts @@ -30,12 +30,12 @@ export async function verifyGuidancePublishing(base:string){ const own=await fetch(base+'/api/documents?author='+owner).then(r=>r.json());assert.ok(own.documents.some((d:{id:string})=>d.id===httpDoc.id));assert.ok(own.documents.every((d:{author:string})=>d.author===owner));assert.equal((await fetch(base+'/api/documents?author=invalid')).status,400); const xml=await fetch(base+'/api/documents/'+httpDoc.id+'?revision=1&format=xml');assert.equal(xml.status,200);assert.match(await xml.text(),/往返标签<\/tag>/); const page=await fetch(base+'/documents/'+httpDoc.id+'?revision=1');assert.equal(page.status,200);assert.ok((await page.text()).includes('实际数据库文档')); - // The homepage and discovery list hydrate from the real API, so verify them in Chromium on the CI runner. + // The document discovery list hydrates from the real API; the homepage is the product landing page. const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',headless:true,args:['--no-sandbox','--disable-dev-shm-usage']}); try{ const page=await browser.newPage(); const link=`a[href="/documents/${httpDoc.id}?revision=1"]`; - await page.goto(base+'/',{waitUntil:'domcontentloaded'}); + await page.goto(base+'/documents',{waitUntil:'domcontentloaded'}); await page.locator(link).waitFor({state:'visible'});assert.equal(await page.locator(link).getByRole('heading').textContent(),httpDoc.title); // Published matches must not inherit the examples' empty state; submitted filters survive reload. const search=page.getByRole('textbox',{name:'搜索文档'}); diff --git a/scripts/deploy-studio.sh b/scripts/deploy-studio.sh index 7b11d3e..9df86c3 100644 --- a/scripts/deploy-studio.sh +++ b/scripts/deploy-studio.sh @@ -8,7 +8,7 @@ flock -w 300 9 latest=$(git -c safe.directory="$PWD" ls-remote origin refs/heads/main | cut -f1) if [ "$latest" != "$sha" ]; then git -c safe.directory="$PWD" fetch origin main - git -c safe.directory="$PWD" diff --quiet "$sha" origin/main -- apps/studio scripts/deploy-studio.sh .gitea/workflows/studio.yml pnpm-workspace.yaml || exit 0 + git -c safe.directory="$PWD" diff --quiet "$sha" origin/main -- apps/studio scripts/deploy-studio.sh .gitea/workflows/waterfall-wiki.yml pnpm-workspace.yaml || exit 0 fi mkdir -p /opt/prompt-studio previous=$(cat /opt/prompt-studio/image 2>/dev/null || true) diff --git a/scripts/verify-studio-production.sh b/scripts/verify-studio-production.sh index 35ef390..72c934a 100644 --- a/scripts/verify-studio-production.sh +++ b/scripts/verify-studio-production.sh @@ -1,13 +1,12 @@ #!/usr/bin/env bash # Run on ces-control after deployment; never starts a preview web server. set -Eeuo pipefail -# Wiki and Studio deploy independently; wait for Wiki's canonical route proxy. -canonical_ready=false -for attempt in {1..120}; do - if curl -fsS http://8.211.154.134:18131/ | grep -q '想法不该停在对话里'; then canonical_ready=true; break; fi - sleep 5 -done -$canonical_ready || { echo 'Wiki landing and canonical route release is not ready'; exit 1; } +# The shared release job deploys Wiki before Studio; a missing route is an error, +# not a reason to occupy this runner while another queued job tries to deploy it. +if ! curl -fsS http://8.211.154.134:18131/ | grep -q '想法不该停在对话里'; then + echo 'The shared release has not deployed the Wiki landing and canonical routes' + exit 1 +fi REPO_ROOT=$(git rev-parse --show-toplevel) RESULT_DIR="$REPO_ROOT/artifacts/studio-acceptance" MODE=${1:-verify} -- 2.52.0 From 40a607696132818d6de0e6223723dce89d830fd6 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 30 Oct 2026 17:18:42 +0800 Subject: [PATCH 2/8] Wait for document navigation before asserting its rendered heading --- apps/waterfall-wiki/tests/guidance-integration.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/waterfall-wiki/tests/guidance-integration.ts b/apps/waterfall-wiki/tests/guidance-integration.ts index ff8f3ea..e2e908c 100644 --- a/apps/waterfall-wiki/tests/guidance-integration.ts +++ b/apps/waterfall-wiki/tests/guidance-integration.ts @@ -54,7 +54,10 @@ export async function verifyGuidancePublishing(base:string){ await page.locator(link).waitFor({state:'visible'}); assert.equal(await search.inputValue(),''); await page.getByRole('navigation',{name:'主导航',exact:true}).getByRole('link',{name:'社区文档',exact:true}).click();await page.locator(link).waitFor({state:'visible'}); - await page.locator(link).click();await page.locator('h1').waitFor();assert.equal(await page.locator('h1').textContent(),httpDoc.title); + await page.locator(link).click(); + await page.waitForURL(url=>url.pathname==='/documents/'+httpDoc.id&&url.searchParams.get('revision')==='1'); + await page.getByRole('heading',{name:httpDoc.title,exact:true}).waitFor(); + assert.equal(await page.locator('h1').textContent(),httpDoc.title); await page.goto(base+'/write',{waitUntil:'domcontentloaded'});assert.equal(await page.locator('h1').textContent(),'发布独立文档'); }finally{await browser.close();} assert.equal((await request('/'+httpDoc.id,'DELETE',undefined,{cookie:''})).status,401); -- 2.52.0 From cfc668690469acd32ef7b378458c682070d04479 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 30 Oct 2026 17:44:05 +0800 Subject: [PATCH 3/8] Capture document client navigation failures in CI --- apps/waterfall-wiki/tests/guidance-integration.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/waterfall-wiki/tests/guidance-integration.ts b/apps/waterfall-wiki/tests/guidance-integration.ts index e2e908c..7dfc8be 100644 --- a/apps/waterfall-wiki/tests/guidance-integration.ts +++ b/apps/waterfall-wiki/tests/guidance-integration.ts @@ -34,6 +34,11 @@ export async function verifyGuidancePublishing(base:string){ const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',headless:true,args:['--no-sandbox','--disable-dev-shm-usage']}); try{ const page=await browser.newPage(); + const navigationDiagnostics:string[]=[]; + const record=(value:string)=>{navigationDiagnostics.push(value.slice(0,1800));if(navigationDiagnostics.length>12)navigationDiagnostics.shift();}; + page.on('pageerror',error=>record('pageerror: '+error.message)); + page.on('console',message=>{if(message.type()==='error')record('console: '+message.text());}); + page.on('response',response=>{if(response.url().includes('/_next/data/'))void response.text().then(body=>record(`data ${response.status()} ${new URL(response.url()).pathname}: ${body.slice(0,1400)}`)).catch(()=>{});}); const link=`a[href="/documents/${httpDoc.id}?revision=1"]`; await page.goto(base+'/documents',{waitUntil:'domcontentloaded'}); await page.locator(link).waitFor({state:'visible'});assert.equal(await page.locator(link).getByRole('heading').textContent(),httpDoc.title); @@ -56,7 +61,8 @@ export async function verifyGuidancePublishing(base:string){ await page.getByRole('navigation',{name:'主导航',exact:true}).getByRole('link',{name:'社区文档',exact:true}).click();await page.locator(link).waitFor({state:'visible'}); await page.locator(link).click(); await page.waitForURL(url=>url.pathname==='/documents/'+httpDoc.id&&url.searchParams.get('revision')==='1'); - await page.getByRole('heading',{name:httpDoc.title,exact:true}).waitFor(); + try{await page.getByRole('heading',{name:httpDoc.title,exact:true}).waitFor();} + catch(error){console.error('Document navigation diagnostics',JSON.stringify({url:page.url(),headings:await page.locator('h1').allTextContents(),events:navigationDiagnostics}));throw error;} assert.equal(await page.locator('h1').textContent(),httpDoc.title); await page.goto(base+'/write',{waitUntil:'domcontentloaded'});assert.equal(await page.locator('h1').textContent(),'发布独立文档'); }finally{await browser.close();} -- 2.52.0 From 33405b7d8b9c472f0dd9b74d55f624c877953cf6 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 30 Oct 2026 18:07:21 +0800 Subject: [PATCH 4/8] Exercise document directory at its canonical entry in UI regression tests --- apps/waterfall-wiki/tests/guidance-workspace.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/waterfall-wiki/tests/guidance-workspace.test.ts b/apps/waterfall-wiki/tests/guidance-workspace.test.ts index a8f8c33..9122a83 100644 --- a/apps/waterfall-wiki/tests/guidance-workspace.test.ts +++ b/apps/waterfall-wiki/tests/guidance-workspace.test.ts @@ -100,11 +100,11 @@ test( const page = await context.newPage(); page.setDefaultTimeout(10000); page.on("pageerror", (error) => errors.push(error.message)); - await page.goto(base!, { waitUntil: "networkidle" }); + await page.goto(base + "/documents", { waitUntil: "networkidle" }); await page .getByRole("heading", { name: authored.title, exact: true }) .waitFor(); - assert.equal(await page.title(), "探索文档 · 织页"); + assert.equal(await page.title(), "社区文档 · 织页"); const search = page.getByRole("textbox", { name: "搜索文档" }); await search.fill("不存在的文档"); await search.press("Enter"); @@ -248,7 +248,7 @@ test( "通过 XML 更新的文档", ); await page.setViewportSize({ width: 390, height: 844 }); - await page.goto(base!, { waitUntil: "networkidle" }); + await page.goto(base + "/documents", { waitUntil: "networkidle" }); assert.equal( await page.evaluate( () => document.documentElement.scrollWidth <= window.innerWidth, -- 2.52.0 From cad84f8a6730f7780cc8c4d851e1c6f9814cb355 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 30 Oct 2026 18:13:58 +0800 Subject: [PATCH 5/8] Run nested UI checks and align authentication assertions with public workspace routes --- apps/waterfall-wiki/scripts/auth-cli.ts | 6 ++++-- apps/waterfall-wiki/tests/guidance-integration.ts | 2 +- apps/waterfall-wiki/tests/integration.test.ts | 4 +++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/waterfall-wiki/scripts/auth-cli.ts b/apps/waterfall-wiki/scripts/auth-cli.ts index e79ae89..240402a 100644 --- a/apps/waterfall-wiki/scripts/auth-cli.ts +++ b/apps/waterfall-wiki/scripts/auth-cli.ts @@ -47,13 +47,15 @@ async function main() { if (command === 'verify' || command === 'exercise') { const request = (path:string,init:RequestInit={}) => fetch(base+path,{...init,redirect:'manual'}); const buildId = readFileSync('.next/BUILD_ID','utf8').trim(); - assert.equal((await request('/')).status,200,'Public knowledge homepage'); + for(const path of ['/','/workspace','/documents','/account'])assert.equal((await request(path)).status,200,'Public shell '+path); + const studio=await request('/studio');assert.equal(studio.status,307);assert.equal(studio.headers.get('location'),'/workspace'); + for(const [from,to] of [['/studio/graphic','/graphic'],['/studio/infinite_canvas','/infinite_canvas']]){const r=await request(from);assert.equal(r.status,308);assert.equal(r.headers.get('location'),to)} assert.equal((await request('/account')).status,200,'Public account page'); const homeData = await request(`/_next/data/${buildId}/index.json`); assert.ok([200,404].includes(homeData.status),'Public homepage data is readable or absent for static pages'); assert.ok(!isLoginRedirect(homeData.headers.get('location') || homeData.headers.get('x-nextjs-redirect'),base)); assert.equal((await request('/api/posts',{method:'POST',headers:{Cookie:'wiki_account='+ 'a'.repeat(64)},body:'{}'})).status,401,'Account cookies never grant admin rights'); - for (const path of ['/library/import','/skills/shadow-puppet/egret-example.png','/studio',`/_next/data/${buildId}/library/import.json`]) { + for (const path of ['/library/import','/skills/shadow-puppet/egret-example.png','/studio/layouts/ref-01',`/_next/data/${buildId}/library/import.json`]) { const response = await request(path); assert.equal(response.status,307,path); const destination = response.headers.get('location') || response.headers.get('x-nextjs-redirect'); assert.ok(isLoginRedirect(destination,base),path+' must redirect to login: '+destination); } for (const path of ['/api/feed','/api/upload','/studio/api/export/test','/api/media/missing']) assert.equal((await request(path)).status,401,path); diff --git a/apps/waterfall-wiki/tests/guidance-integration.ts b/apps/waterfall-wiki/tests/guidance-integration.ts index 7dfc8be..aa2f694 100644 --- a/apps/waterfall-wiki/tests/guidance-integration.ts +++ b/apps/waterfall-wiki/tests/guidance-integration.ts @@ -61,7 +61,7 @@ export async function verifyGuidancePublishing(base:string){ await page.getByRole('navigation',{name:'主导航',exact:true}).getByRole('link',{name:'社区文档',exact:true}).click();await page.locator(link).waitFor({state:'visible'}); await page.locator(link).click(); await page.waitForURL(url=>url.pathname==='/documents/'+httpDoc.id&&url.searchParams.get('revision')==='1'); - try{await page.getByRole('heading',{name:httpDoc.title,exact:true}).waitFor();} + try{await page.getByRole('heading',{name:httpDoc.title,exact:true,level:1}).waitFor();} catch(error){console.error('Document navigation diagnostics',JSON.stringify({url:page.url(),headings:await page.locator('h1').allTextContents(),events:navigationDiagnostics}));throw error;} assert.equal(await page.locator('h1').textContent(),httpDoc.title); await page.goto(base+'/write',{waitUntil:'domcontentloaded'});assert.equal(await page.locator('h1').textContent(),'发布独立文档'); diff --git a/apps/waterfall-wiki/tests/integration.test.ts b/apps/waterfall-wiki/tests/integration.test.ts index 256b88a..b5b6ceb 100644 --- a/apps/waterfall-wiki/tests/integration.test.ts +++ b/apps/waterfall-wiki/tests/integration.test.ts @@ -33,7 +33,9 @@ test('real PostgreSQL + S3 notes, flat tags, resource sharing and video processi 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); await verifyEmailAccounts(); await verifyGuidancePublishing(base); - const workspaceUI=spawn(process.execPath,['--import','tsx','--test','tests/guidance-workspace.test.ts'],{env:{...process.env,WORKSPACE_TEST_URL:base},timeout:90000,stdio:'inherit'}); + const workspaceEnv:NodeJS.ProcessEnv={...process.env,WORKSPACE_TEST_URL:base}; + delete workspaceEnv.NODE_TEST_CONTEXT; + const workspaceUI=spawn(process.execPath,['--import','tsx','--test','tests/guidance-workspace.test.ts'],{env:workspaceEnv,timeout:90000,stdio:'inherit'}); const [workspaceExit]=await once(workspaceUI,'exit');assert.equal(workspaceExit,0,'SaaS workspace UI regression checks'); await verifyFlatNoteMigration(); const root=await request('/api/tags',{name:'线条',slug:'lines'});const child=await request('/api/tags',{name:'白色',slug:'white'}); -- 2.52.0 From d8c06d6cc66b2b60813940332a54fc4a1344827f Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 30 Oct 2026 18:37:02 +0800 Subject: [PATCH 6/8] Capture document directory network and hydration failures in CI --- .../tests/guidance-integration.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/waterfall-wiki/tests/guidance-integration.ts b/apps/waterfall-wiki/tests/guidance-integration.ts index aa2f694..71f422a 100644 --- a/apps/waterfall-wiki/tests/guidance-integration.ts +++ b/apps/waterfall-wiki/tests/guidance-integration.ts @@ -30,17 +30,22 @@ export async function verifyGuidancePublishing(base:string){ const own=await fetch(base+'/api/documents?author='+owner).then(r=>r.json());assert.ok(own.documents.some((d:{id:string})=>d.id===httpDoc.id));assert.ok(own.documents.every((d:{author:string})=>d.author===owner));assert.equal((await fetch(base+'/api/documents?author=invalid')).status,400); const xml=await fetch(base+'/api/documents/'+httpDoc.id+'?revision=1&format=xml');assert.equal(xml.status,200);assert.match(await xml.text(),/往返标签<\/tag>/); const page=await fetch(base+'/documents/'+httpDoc.id+'?revision=1');assert.equal(page.status,200);assert.ok((await page.text()).includes('实际数据库文档')); + const directory=await fetch(base+'/api/documents');assert.equal(directory.status,200);assert.ok((await directory.json()).documents.some((d:{id:string})=>d.id===httpDoc.id),'Published document must appear in the public API'); // The document discovery list hydrates from the real API; the homepage is the product landing page. const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',headless:true,args:['--no-sandbox','--disable-dev-shm-usage']}); try{ const page=await browser.newPage(); - const navigationDiagnostics:string[]=[]; - const record=(value:string)=>{navigationDiagnostics.push(value.slice(0,1800));if(navigationDiagnostics.length>12)navigationDiagnostics.shift();}; + const navigationDiagnostics:string[]=[],inflight=new Set(); + const record=(value:string)=>{navigationDiagnostics.push(value.slice(0,1800));if(navigationDiagnostics.length>24)navigationDiagnostics.shift();}; page.on('pageerror',error=>record('pageerror: '+error.message)); page.on('console',message=>{if(message.type()==='error')record('console: '+message.text());}); - page.on('response',response=>{if(response.url().includes('/_next/data/'))void response.text().then(body=>record(`data ${response.status()} ${new URL(response.url()).pathname}: ${body.slice(0,1400)}`)).catch(()=>{});}); + page.on('request',request=>inflight.add(request.url())); + page.on('requestfinished',request=>inflight.delete(request.url())); + page.on('requestfailed',request=>{inflight.delete(request.url());record('requestfailed: '+new URL(request.url()).pathname+' '+request.failure()?.errorText)}); + page.on('response',response=>{const path=new URL(response.url()).pathname;if(path.startsWith('/api/')||path.includes('/_next/data/'))void response.text().then(body=>record(`data ${response.status()} ${path}: ${body.slice(0,1400)}`)).catch(()=>{});else if(response.status()>=400||response.request().resourceType()==='script')record(`resource ${response.status()} ${path}`);}); + try{ const link=`a[href="/documents/${httpDoc.id}?revision=1"]`; - await page.goto(base+'/documents',{waitUntil:'domcontentloaded'}); + const navigation=await page.goto(base+'/documents',{waitUntil:'domcontentloaded'});record('directory HTTP '+navigation?.status()); await page.locator(link).waitFor({state:'visible'});assert.equal(await page.locator(link).getByRole('heading').textContent(),httpDoc.title); // Published matches must not inherit the examples' empty state; submitted filters survive reload. const search=page.getByRole('textbox',{name:'搜索文档'}); @@ -61,10 +66,10 @@ export async function verifyGuidancePublishing(base:string){ await page.getByRole('navigation',{name:'主导航',exact:true}).getByRole('link',{name:'社区文档',exact:true}).click();await page.locator(link).waitFor({state:'visible'}); await page.locator(link).click(); await page.waitForURL(url=>url.pathname==='/documents/'+httpDoc.id&&url.searchParams.get('revision')==='1'); - try{await page.getByRole('heading',{name:httpDoc.title,exact:true,level:1}).waitFor();} - catch(error){console.error('Document navigation diagnostics',JSON.stringify({url:page.url(),headings:await page.locator('h1').allTextContents(),events:navigationDiagnostics}));throw error;} + await page.getByRole('heading',{name:httpDoc.title,exact:true,level:1}).waitFor(); assert.equal(await page.locator('h1').textContent(),httpDoc.title); await page.goto(base+'/write',{waitUntil:'domcontentloaded'});assert.equal(await page.locator('h1').textContent(),'发布独立文档'); + }catch(error){console.error('Document navigation diagnostics',JSON.stringify({url:page.url(),headings:await page.locator('h1').allTextContents(),body:(await page.locator('body').innerText()).slice(0,2200),inflight:[...inflight].map(url=>new URL(url).pathname),events:navigationDiagnostics}));throw error;} }finally{await browser.close();} assert.equal((await request('/'+httpDoc.id,'DELETE',undefined,{cookie:''})).status,401); assert.equal((await request('/'+httpDoc.id,'DELETE')).status,200); -- 2.52.0 From 4faef8b766a87017dfb5d838c6ed89eff132fad8 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 30 Oct 2026 19:07:20 +0800 Subject: [PATCH 7/8] Wait for visible application readiness in workspace UI regression checks --- .../tests/guidance-workspace.test.ts | 54 +++++++++++-------- apps/waterfall-wiki/tests/integration.test.ts | 2 +- 2 files changed, 33 insertions(+), 23 deletions(-) diff --git a/apps/waterfall-wiki/tests/guidance-workspace.test.ts b/apps/waterfall-wiki/tests/guidance-workspace.test.ts index 9122a83..d0d9cc6 100644 --- a/apps/waterfall-wiki/tests/guidance-workspace.test.ts +++ b/apps/waterfall-wiki/tests/guidance-workspace.test.ts @@ -100,7 +100,23 @@ test( const page = await context.newPage(); page.setDefaultTimeout(10000); page.on("pageerror", (error) => errors.push(error.message)); - await page.goto(base + "/documents", { waitUntil: "networkidle" }); + const pending = new Set(), network: string[] = []; + page.on("request", request => pending.add(request.url())); + 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) { + const session = page.waitForResponse(response => new URL(response.url()).pathname === "/api/account/session" && response.status() === 200); + try { + const [navigation] = await Promise.all([reload ? page.reload({waitUntil:"domcontentloaded"}) : page.goto(url,{waitUntil:"domcontentloaded"}),session]); + assert.ok(navigation?.ok(), "Navigation returns a successful document"); + await page.getByText(signedIn ? "editor@example.com" : "登录你的账户",{exact:true}).first().waitFor({state:"attached"}); + } catch (error) { + console.error("Workspace UI navigation diagnostics",JSON.stringify({url:page.url(),pending:[...pending].map(value=>new URL(value).pathname),network:network.slice(-20),errors,body:(await page.locator("body").innerText().catch(()=>"unavailable")).slice(0,1600)})); + throw error; + } + } + + await navigate(base + "/documents"); await page .getByRole("heading", { name: authored.title, exact: true }) .waitFor(); @@ -111,7 +127,8 @@ test( await page .getByRole("heading", { name: "没有匹配文档", exact: true }) .waitFor(); - await page.reload({ waitUntil: "networkidle" }); + await navigate(page.url(), true); + await page.getByRole("heading",{name:"没有匹配文档",exact:true}).waitFor(); assert.equal(await search.inputValue(), "不存在的文档"); await page .getByRole("button", { name: "清除所有筛选", exact: true }) @@ -129,9 +146,7 @@ test( fullPage: true, }); } - await page.goto(base + "/guide/review-changes?revision=1", { - waitUntil: "networkidle", - }); + await navigate(base + "/guide/review-changes?revision=1"); await page.getByRole("tab", { name: "XML 源文档" }).click(); assert.match(await page.locator("pre").innerText(), /linked-guidance/); await page.getByRole("tab", { name: /版本记录/ }).click(); @@ -142,7 +157,7 @@ test( path: process.env.WORKSPACE_SCREENSHOT_DIR + "/reader.png", fullPage: true, }); - await page.goto(base + "/account", { waitUntil: "networkidle" }); + await navigate(base + "/account"); await page.getByRole("button", { name: "创建账户", exact: true }).click(); await page.getByRole("heading", { name: "创建你的账户" }).waitFor(); await page @@ -160,10 +175,10 @@ test( path: process.env.WORKSPACE_SCREENSHOT_DIR + "/account.png", fullPage: true, }); - await page.goto(base + "/write", { waitUntil: "networkidle" }); + await navigate(base + "/write"); await page.getByRole("heading", { name: "登录后开始编写" }).waitFor(); signedIn = true; - await page.reload({ waitUntil: "networkidle" }); + await navigate(page.url(), true); await page.getByLabel("文档短名", { exact: true }).fill("workspace-test"); await page.getByLabel("标题", { exact: false }).fill("界面回归验证文档"); await page @@ -213,16 +228,13 @@ test( (published as Guidance).id, "user-" + owner + "-workspace-test", ); - await page.goto(base + "/documents?scope=mine", { - waitUntil: "networkidle", - }); - assert.equal(requestedAuthor, owner); + await navigate(base + "/documents?scope=mine"); await page .getByRole("heading", { name: authored.title, exact: true }) .waitFor(); - await page.goto(base + "/write?id=" + authored.id, { - waitUntil: "networkidle", - }); + assert.equal(requestedAuthor, owner); + await navigate(base + "/write?id=" + authored.id); + await page.waitForFunction(title=>Array.from(document.querySelectorAll("input")).some(input=>input.value===title),authored.title); assert.equal( await page.getByLabel("文档短名", { exact: true }).isDisabled(), true, @@ -248,7 +260,7 @@ test( "通过 XML 更新的文档", ); await page.setViewportSize({ width: 390, height: 844 }); - await page.goto(base + "/documents", { waitUntil: "networkidle" }); + await navigate(base + "/documents"); assert.equal( await page.evaluate( () => document.documentElement.scrollWidth <= window.innerWidth, @@ -269,9 +281,7 @@ test( path: process.env.WORKSPACE_SCREENSHOT_DIR + "/mobile-directory.png", fullPage: true, }); - await page.goto(base + "/guide/review-changes?revision=1", { - waitUntil: "networkidle", - }); + await navigate(base + "/guide/review-changes?revision=1"); assert.equal( await page.evaluate( () => document.documentElement.scrollWidth <= window.innerWidth, @@ -283,14 +293,14 @@ test( path: process.env.WORKSPACE_SCREENSHOT_DIR + "/mobile-reader.png", fullPage: true, }); - await page.goto(base + "/account", { waitUntil: "networkidle" }); + await navigate(base + "/account"); assert.equal( await page.evaluate( () => document.documentElement.scrollWidth <= window.innerWidth, ), true, ); - await page.goto(base + "/write", { waitUntil: "networkidle" }); + await navigate(base + "/write"); await page.getByLabel("文档短名", { exact: true }).waitFor(); await page.getByRole("button", { name: "协议 XML", exact: true }).click(); await page.getByLabel("XML 输入").waitFor(); @@ -305,7 +315,7 @@ test( path: process.env.WORKSPACE_SCREENSHOT_DIR + "/mobile-editor.png", fullPage: true, }); - await page.goto(base + "/protocol", { waitUntil: "networkidle" }); + await navigate(base + "/protocol"); assert.equal( await page.evaluate( () => document.documentElement.scrollWidth <= window.innerWidth, diff --git a/apps/waterfall-wiki/tests/integration.test.ts b/apps/waterfall-wiki/tests/integration.test.ts index b5b6ceb..b425335 100644 --- a/apps/waterfall-wiki/tests/integration.test.ts +++ b/apps/waterfall-wiki/tests/integration.test.ts @@ -35,7 +35,7 @@ test('real PostgreSQL + S3 notes, flat tags, resource sharing and video processi await verifyGuidancePublishing(base); const workspaceEnv:NodeJS.ProcessEnv={...process.env,WORKSPACE_TEST_URL:base}; delete workspaceEnv.NODE_TEST_CONTEXT; - const workspaceUI=spawn(process.execPath,['--import','tsx','--test','tests/guidance-workspace.test.ts'],{env:workspaceEnv,timeout:90000,stdio:'inherit'}); + const workspaceUI=spawn(process.execPath,['--import','tsx','--test','tests/guidance-workspace.test.ts'],{env:workspaceEnv,timeout:180000,stdio:'inherit'}); const [workspaceExit]=await once(workspaceUI,'exit');assert.equal(workspaceExit,0,'SaaS workspace UI regression checks'); await verifyFlatNoteMigration(); const root=await request('/api/tags',{name:'线条',slug:'lines'});const child=await request('/api/tags',{name:'白色',slug:'white'}); -- 2.52.0 From 429e78c7222207854b0db5a0eddf9cec69406054 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 30 Oct 2026 19:37:59 +0800 Subject: [PATCH 8/8] Run complete workspace UI assertions in an isolated context of the integration browser --- .../tests/guidance-integration.ts | 4 + .../tests/guidance-workspace-ui.ts | 325 +++++++++++++++++ .../tests/guidance-workspace.test.ts | 343 +----------------- apps/waterfall-wiki/tests/integration.test.ts | 4 - 4 files changed, 339 insertions(+), 337 deletions(-) create mode 100644 apps/waterfall-wiki/tests/guidance-workspace-ui.ts diff --git a/apps/waterfall-wiki/tests/guidance-integration.ts b/apps/waterfall-wiki/tests/guidance-integration.ts index 71f422a..395210c 100644 --- a/apps/waterfall-wiki/tests/guidance-integration.ts +++ b/apps/waterfall-wiki/tests/guidance-integration.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import {verifyGuidanceWorkspaceUI} from './guidance-workspace-ui'; import {chromium} from 'playwright-core'; import {randomUUID} from 'node:crypto'; import {newToken,tokenDigest,ACCOUNT_COOKIE} from '../src/lib/account-crypto'; @@ -70,6 +71,9 @@ export async function verifyGuidancePublishing(base:string){ assert.equal(await page.locator('h1').textContent(),httpDoc.title); await page.goto(base+'/write',{waitUntil:'domcontentloaded'});assert.equal(await page.locator('h1').textContent(),'发布独立文档'); }catch(error){console.error('Document navigation diagnostics',JSON.stringify({url:page.url(),headings:await page.locator('h1').allTextContents(),body:(await page.locator('body').innerText()).slice(0,2200),inflight:[...inflight].map(url=>new URL(url).pathname),events:navigationDiagnostics}));throw error;} + await page.close(); + await verifyGuidanceWorkspaceUI(base,browser); + console.log('Workspace desktop/mobile, filters, reading, account and publishing assertions passed in a fresh browser context'); }finally{await browser.close();} assert.equal((await request('/'+httpDoc.id,'DELETE',undefined,{cookie:''})).status,401); assert.equal((await request('/'+httpDoc.id,'DELETE')).status,200); diff --git a/apps/waterfall-wiki/tests/guidance-workspace-ui.ts b/apps/waterfall-wiki/tests/guidance-workspace-ui.ts new file mode 100644 index 0000000..a1b8f6e --- /dev/null +++ b/apps/waterfall-wiki/tests/guidance-workspace-ui.ts @@ -0,0 +1,325 @@ +/** UI fixtures run in a fresh context; real storage and authentication have separate coverage. */ +import assert from "node:assert/strict"; +import { mkdir } from "node:fs/promises"; +import type { Browser } from "playwright-core"; +import { documentInput, parseDocumentXml } from "../src/lib/guidance-input"; +import { guidanceXml, type Guidance } from "../src/content/guides/catalog"; +const owner = "11111111-1111-4111-8111-111111111111"; +const authored: Guidance = { + id: "user-" + owner + "-review", + revision: 1, + kind: "guide", + title: "准备一次清晰的代码审查", + summary: "用明确的问题和证据,完成一次有用的代码审查。", + applies: "提交合并请求前", + body: "先理解改动目标,再核对实现与验证。", + steps: [], + checks: [], + links: [], + tags: [], +}; +export async function verifyGuidanceWorkspaceUI(base: string, browser: Browser) { + const context = await browser.newContext({ + viewport: { width: 1440, height: 1000 }, + }); + let signedIn = false, + previewError = false, + requestedAuthor: string | null = null, + published: Guidance | null = null; + const errors: string[] = []; + try { + await context.route("**/api/account/session", (route) => + route.fulfill({ + json: { + user: signedIn + ? { + id: owner, + email: "editor@example.com", + verified_at: "2026-10-29T00:00:00Z", + } + : null, + }, + }), + ); + await context.route( + (url) => url.pathname === "/api/documents", + async (route) => { + if (route.request().method() === "GET") { + requestedAuthor = new URL(route.request().url()).searchParams.get( + "author", + ); + return route.fulfill({ + json: { documents: [{ ...authored, author: owner }] }, + }); + } + published = documentInput.parse(route.request().postDataJSON()); + return route.fulfill({ status: 201, json: { document: published } }); + }, + ); + await context.route("**/api/documents/preview", (route) => { + if (previewError) + return route.fulfill({ + status: 400, + json: { error: "输入不符合协议", fields: ["applies"] }, + }); + let document: Guidance; + try { + document = + route.request().headers()["content-type"] === "application/xml" + ? parseDocumentXml(route.request().postData() || "") + : documentInput.parse(route.request().postDataJSON()); + } catch { + return route.fulfill({ + status: 400, + json: { error: "输入不符合协议" }, + }); + } + return route.fulfill({ + json: { + document, + xml: guidanceXml(document), + links: document.links.map((link) => ({ ...link, resolved: true })), + }, + }); + }); + await context.route("**/api/documents/" + authored.id, (route) => + route.fulfill({ json: { document: authored } }), + ); + const page = await context.newPage(); + page.setDefaultTimeout(10000); + page.on("pageerror", (error) => errors.push(error.message)); + page.on("crash", () => errors.push("Browser renderer crashed")); + const pending = new Set(), network: string[] = []; + page.on("request", request => pending.add(request.url())); + 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) { + const session = page.waitForResponse(response => new URL(response.url()).pathname === "/api/account/session" && response.status() === 200); + try { + const [navigation] = await Promise.all([reload ? page.reload({waitUntil:"domcontentloaded"}) : page.goto(url,{waitUntil:"domcontentloaded"}),session]); + assert.ok(navigation?.ok(), "Navigation returns a successful document"); + await page.getByText(signedIn ? "editor@example.com" : "登录你的账户",{exact:true}).first().waitFor({state:"attached"}); + } catch (error) { + 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)})); + throw error; + } + } + + await navigate(base + "/documents"); + await page + .getByRole("heading", { name: authored.title, exact: true }) + .waitFor(); + assert.equal(await page.title(), "社区文档 · 织页"); + const search = page.getByRole("textbox", { name: "搜索文档" }); + await search.fill("不存在的文档"); + await search.press("Enter"); + await page + .getByRole("heading", { name: "没有匹配文档", exact: true }) + .waitFor(); + await navigate(page.url(), true); + await page.getByRole("heading",{name:"没有匹配文档",exact:true}).waitFor(); + assert.equal(await search.inputValue(), "不存在的文档"); + await page + .getByRole("button", { name: "清除所有筛选", exact: true }) + .click(); + await page + .getByRole("heading", { name: authored.title, exact: true }) + .waitFor(); + await page.getByRole("tab", { name: /事实依据/ }).focus(); + await page.keyboard.press("Home"); + await page.getByRole("tab", { name: /全部文档/ }).waitFor(); + if (process.env.WORKSPACE_SCREENSHOT_DIR) { + await mkdir(process.env.WORKSPACE_SCREENSHOT_DIR, { recursive: true }); + await page.screenshot({ + path: process.env.WORKSPACE_SCREENSHOT_DIR + "/directory.png", + fullPage: true, + }); + } + await navigate(base + "/guide/review-changes?revision=1"); + await page.getByRole("tab", { name: "XML 源文档" }).click(); + assert.match(await page.locator("pre").innerText(), /linked-guidance/); + await page.getByRole("tab", { name: /版本记录/ }).click(); + await page.getByText("当前阅读", { exact: true }).waitFor(); + await page.getByRole("tab", { name: "文档概览" }).click(); + if (process.env.WORKSPACE_SCREENSHOT_DIR) + await page.screenshot({ + path: process.env.WORKSPACE_SCREENSHOT_DIR + "/reader.png", + fullPage: true, + }); + await navigate(base + "/account"); + await page.getByRole("button", { name: "创建账户", exact: true }).click(); + await page.getByRole("heading", { name: "创建你的账户" }).waitFor(); + await page + .getByLabel("密码", { exact: true }) + .fill("a-long-example-password"); + await page.getByRole("button", { name: "显示密码" }).click(); + assert.equal( + await page.getByLabel("密码", { exact: true }).getAttribute("type"), + "text", + ); + await page.getByRole("button", { name: "隐藏密码" }).click(); + await page.getByLabel("密码", { exact: true }).fill(""); + if (process.env.WORKSPACE_SCREENSHOT_DIR) + await page.screenshot({ + path: process.env.WORKSPACE_SCREENSHOT_DIR + "/account.png", + fullPage: true, + }); + await navigate(base + "/write"); + await page.getByRole("heading", { name: "登录后开始编写" }).waitFor(); + signedIn = true; + await navigate(page.url(), true); + await page.getByLabel("文档短名", { exact: true }).fill("workspace-test"); + await page.getByLabel("标题", { exact: false }).fill("界面回归验证文档"); + await page + .getByLabel("一句话摘要", { exact: false }) + .fill("可独立使用的指导"); + await page + .getByLabel("适用条件", { exact: false }) + .fill("检查界面流程时"); + await page + .getByLabel("正文", { exact: false }) + .fill("先判断适用条件,再阅读正文。"); + await page + .getByLabel("建议步骤", { exact: true }) + .fill("第一步\n\n第二步"); + previewError = true; + await page + .getByRole("button", { name: "校验并预览", exact: true }) + .click(); + await page.getByRole("alert").filter({ hasText: "适用条件" }).waitFor(); + previewError = false; + await page + .getByRole("button", { name: "校验并预览", exact: true }) + .click(); + await page.getByRole("dialog").waitFor({ state: "visible" }); + await page.getByRole("button", { name: "返回编辑" }).click(); + assert.equal( + await page.getByLabel("正文", { exact: false }).inputValue(), + "先判断适用条件,再阅读正文。", + ); + await page.getByRole("button", { name: "添加条件链接" }).click(); + await page.getByRole("button", { name: "移除关联文档 1" }).click(); + if (process.env.WORKSPACE_SCREENSHOT_DIR) { + await page.evaluate(() => window.scrollTo(0, 0)); + await page.screenshot({ + path: process.env.WORKSPACE_SCREENSHOT_DIR + "/editor.png", + fullPage: true, + }); + } + await page.getByRole("button", { name: "发布文档", exact: true }).click(); + await page + .getByRole("status") + .filter({ hasText: "已公开发布" }) + .waitFor(); + assert.ok(published); + assert.deepEqual((published as Guidance).steps, ["第一步", "第二步"]); + assert.equal( + (published as Guidance).id, + "user-" + owner + "-workspace-test", + ); + await navigate(base + "/documents?scope=mine"); + await page + .getByRole("heading", { name: authored.title, exact: true }) + .waitFor(); + assert.equal(requestedAuthor, owner); + await navigate(base + "/write?id=" + authored.id); + await page.waitForFunction(title=>Array.from(document.querySelectorAll("input")).some(input=>input.value===title),authored.title); + assert.equal( + await page.getByLabel("文档短名", { exact: true }).isDisabled(), + true, + ); + assert.equal( + await page.getByLabel("标题", { exact: false }).inputValue(), + authored.title, + ); + await page.getByRole("button", { name: "协议 XML", exact: true }).click(); + const xmlInput = page.getByLabel("XML 输入"); + assert.match(await xmlInput.inputValue(), /revision="2"/); + await xmlInput.fill( + (await xmlInput.inputValue()).replace( + authored.title, + "通过 XML 更新的文档", + ), + ); + await page + .getByRole("button", { name: "可视化编辑", exact: true }) + .click(); + assert.equal( + await page.getByLabel("标题", { exact: false }).inputValue(), + "通过 XML 更新的文档", + ); + await page.setViewportSize({ width: 390, height: 844 }); + await navigate(base + "/documents"); + assert.equal( + await page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth, + ), + true, + ); + await page.getByRole("button", { name: "打开导航" }).click(); + await page + .getByRole("dialog", { name: "工作区导航" }) + .waitFor({ state: "visible" }); + await page.keyboard.press("Escape"); + assert.equal( + await page.getByRole("dialog", { name: "工作区导航" }).isVisible(), + false, + ); + if (process.env.WORKSPACE_SCREENSHOT_DIR) + await page.screenshot({ + path: process.env.WORKSPACE_SCREENSHOT_DIR + "/mobile-directory.png", + fullPage: true, + }); + await navigate(base + "/guide/review-changes?revision=1"); + assert.equal( + await page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth, + ), + true, + ); + if (process.env.WORKSPACE_SCREENSHOT_DIR) + await page.screenshot({ + path: process.env.WORKSPACE_SCREENSHOT_DIR + "/mobile-reader.png", + fullPage: true, + }); + await navigate(base + "/account"); + assert.equal( + await page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth, + ), + true, + ); + await navigate(base + "/write"); + await page.getByLabel("文档短名", { exact: true }).waitFor(); + await page.getByRole("button", { name: "协议 XML", exact: true }).click(); + await page.getByLabel("XML 输入").waitFor(); + assert.equal( + await page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth, + ), + true, + ); + if (process.env.WORKSPACE_SCREENSHOT_DIR) + await page.screenshot({ + path: process.env.WORKSPACE_SCREENSHOT_DIR + "/mobile-editor.png", + fullPage: true, + }); + await navigate(base + "/protocol"); + assert.equal( + await page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth, + ), + true, + ); + if (process.env.WORKSPACE_SCREENSHOT_DIR) + await page.screenshot({ + path: process.env.WORKSPACE_SCREENSHOT_DIR + "/mobile-protocol.png", + fullPage: true, + }); + assert.deepEqual(errors, []); + } finally { + await context.close(); + } +} diff --git a/apps/waterfall-wiki/tests/guidance-workspace.test.ts b/apps/waterfall-wiki/tests/guidance-workspace.test.ts index d0d9cc6..474316f 100644 --- a/apps/waterfall-wiki/tests/guidance-workspace.test.ts +++ b/apps/waterfall-wiki/tests/guidance-workspace.test.ts @@ -1,336 +1,13 @@ -/** UI contract checks use isolated API fixtures; real storage/auth remain covered by guidance-integration.ts. */ -import assert from "node:assert/strict"; import { test } from "node:test"; -import { mkdir } from "node:fs/promises"; import { chromium } from "playwright-core"; -import { documentInput, parseDocumentXml } from "../src/lib/guidance-input"; -import { guidanceXml, type Guidance } from "../src/content/guides/catalog"; +import { verifyGuidanceWorkspaceUI } from "./guidance-workspace-ui"; const base = process.env.WORKSPACE_TEST_URL; -const owner = "11111111-1111-4111-8111-111111111111"; -const authored: Guidance = { - id: "user-" + owner + "-review", - revision: 1, - kind: "guide", - title: "准备一次清晰的代码审查", - summary: "用明确的问题和证据,完成一次有用的代码审查。", - applies: "提交合并请求前", - body: "先理解改动目标,再核对实现与验证。", - steps: [], - checks: [], - links: [], - tags: [], -}; -test( - "workspace filters, reading tabs, account states and editor work on desktop and mobile", - { skip: !base }, - async () => { - const browser = await chromium.launch({ - executablePath: - process.env.CHROMIUM_PATH || - "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", - headless: true, - args: ["--no-sandbox"], - }); - const context = await browser.newContext({ - viewport: { width: 1440, height: 1000 }, - }); - let signedIn = false, - previewError = false, - requestedAuthor: string | null = null, - published: Guidance | null = null; - const errors: string[] = []; - try { - await context.route("**/api/account/session", (route) => - route.fulfill({ - json: { - user: signedIn - ? { - id: owner, - email: "editor@example.com", - verified_at: "2026-10-29T00:00:00Z", - } - : null, - }, - }), - ); - await context.route( - (url) => url.pathname === "/api/documents", - async (route) => { - if (route.request().method() === "GET") { - requestedAuthor = new URL(route.request().url()).searchParams.get( - "author", - ); - return route.fulfill({ - json: { documents: [{ ...authored, author: owner }] }, - }); - } - published = documentInput.parse(route.request().postDataJSON()); - return route.fulfill({ status: 201, json: { document: published } }); - }, - ); - await context.route("**/api/documents/preview", (route) => { - if (previewError) - return route.fulfill({ - status: 400, - json: { error: "输入不符合协议", fields: ["applies"] }, - }); - let document: Guidance; - try { - document = - route.request().headers()["content-type"] === "application/xml" - ? parseDocumentXml(route.request().postData() || "") - : documentInput.parse(route.request().postDataJSON()); - } catch { - return route.fulfill({ - status: 400, - json: { error: "输入不符合协议" }, - }); - } - return route.fulfill({ - json: { - document, - xml: guidanceXml(document), - links: document.links.map((link) => ({ ...link, resolved: true })), - }, - }); - }); - await context.route("**/api/documents/" + authored.id, (route) => - route.fulfill({ json: { document: authored } }), - ); - const page = await context.newPage(); - page.setDefaultTimeout(10000); - page.on("pageerror", (error) => errors.push(error.message)); - const pending = new Set(), network: string[] = []; - page.on("request", request => pending.add(request.url())); - 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) { - const session = page.waitForResponse(response => new URL(response.url()).pathname === "/api/account/session" && response.status() === 200); - try { - const [navigation] = await Promise.all([reload ? page.reload({waitUntil:"domcontentloaded"}) : page.goto(url,{waitUntil:"domcontentloaded"}),session]); - assert.ok(navigation?.ok(), "Navigation returns a successful document"); - await page.getByText(signedIn ? "editor@example.com" : "登录你的账户",{exact:true}).first().waitFor({state:"attached"}); - } catch (error) { - console.error("Workspace UI navigation diagnostics",JSON.stringify({url:page.url(),pending:[...pending].map(value=>new URL(value).pathname),network:network.slice(-20),errors,body:(await page.locator("body").innerText().catch(()=>"unavailable")).slice(0,1600)})); - throw error; - } - } - - await navigate(base + "/documents"); - await page - .getByRole("heading", { name: authored.title, exact: true }) - .waitFor(); - assert.equal(await page.title(), "社区文档 · 织页"); - const search = page.getByRole("textbox", { name: "搜索文档" }); - await search.fill("不存在的文档"); - await search.press("Enter"); - await page - .getByRole("heading", { name: "没有匹配文档", exact: true }) - .waitFor(); - await navigate(page.url(), true); - await page.getByRole("heading",{name:"没有匹配文档",exact:true}).waitFor(); - assert.equal(await search.inputValue(), "不存在的文档"); - await page - .getByRole("button", { name: "清除所有筛选", exact: true }) - .click(); - await page - .getByRole("heading", { name: authored.title, exact: true }) - .waitFor(); - await page.getByRole("tab", { name: /事实依据/ }).focus(); - await page.keyboard.press("Home"); - await page.getByRole("tab", { name: /全部文档/ }).waitFor(); - if (process.env.WORKSPACE_SCREENSHOT_DIR) { - await mkdir(process.env.WORKSPACE_SCREENSHOT_DIR, { recursive: true }); - await page.screenshot({ - path: process.env.WORKSPACE_SCREENSHOT_DIR + "/directory.png", - fullPage: true, - }); - } - await navigate(base + "/guide/review-changes?revision=1"); - await page.getByRole("tab", { name: "XML 源文档" }).click(); - assert.match(await page.locator("pre").innerText(), /linked-guidance/); - await page.getByRole("tab", { name: /版本记录/ }).click(); - await page.getByText("当前阅读", { exact: true }).waitFor(); - await page.getByRole("tab", { name: "文档概览" }).click(); - if (process.env.WORKSPACE_SCREENSHOT_DIR) - await page.screenshot({ - path: process.env.WORKSPACE_SCREENSHOT_DIR + "/reader.png", - fullPage: true, - }); - await navigate(base + "/account"); - await page.getByRole("button", { name: "创建账户", exact: true }).click(); - await page.getByRole("heading", { name: "创建你的账户" }).waitFor(); - await page - .getByLabel("密码", { exact: true }) - .fill("a-long-example-password"); - await page.getByRole("button", { name: "显示密码" }).click(); - assert.equal( - await page.getByLabel("密码", { exact: true }).getAttribute("type"), - "text", - ); - await page.getByRole("button", { name: "隐藏密码" }).click(); - await page.getByLabel("密码", { exact: true }).fill(""); - if (process.env.WORKSPACE_SCREENSHOT_DIR) - await page.screenshot({ - path: process.env.WORKSPACE_SCREENSHOT_DIR + "/account.png", - fullPage: true, - }); - await navigate(base + "/write"); - await page.getByRole("heading", { name: "登录后开始编写" }).waitFor(); - signedIn = true; - await navigate(page.url(), true); - await page.getByLabel("文档短名", { exact: true }).fill("workspace-test"); - await page.getByLabel("标题", { exact: false }).fill("界面回归验证文档"); - await page - .getByLabel("一句话摘要", { exact: false }) - .fill("可独立使用的指导"); - await page - .getByLabel("适用条件", { exact: false }) - .fill("检查界面流程时"); - await page - .getByLabel("正文", { exact: false }) - .fill("先判断适用条件,再阅读正文。"); - await page - .getByLabel("建议步骤", { exact: true }) - .fill("第一步\n\n第二步"); - previewError = true; - await page - .getByRole("button", { name: "校验并预览", exact: true }) - .click(); - await page.getByRole("alert").filter({ hasText: "适用条件" }).waitFor(); - previewError = false; - await page - .getByRole("button", { name: "校验并预览", exact: true }) - .click(); - await page.getByRole("dialog").waitFor({ state: "visible" }); - await page.getByRole("button", { name: "返回编辑" }).click(); - assert.equal( - await page.getByLabel("正文", { exact: false }).inputValue(), - "先判断适用条件,再阅读正文。", - ); - await page.getByRole("button", { name: "添加条件链接" }).click(); - await page.getByRole("button", { name: "移除关联文档 1" }).click(); - if (process.env.WORKSPACE_SCREENSHOT_DIR) { - await page.evaluate(() => window.scrollTo(0, 0)); - await page.screenshot({ - path: process.env.WORKSPACE_SCREENSHOT_DIR + "/editor.png", - fullPage: true, - }); - } - await page.getByRole("button", { name: "发布文档", exact: true }).click(); - await page - .getByRole("status") - .filter({ hasText: "已公开发布" }) - .waitFor(); - assert.ok(published); - assert.deepEqual((published as Guidance).steps, ["第一步", "第二步"]); - assert.equal( - (published as Guidance).id, - "user-" + owner + "-workspace-test", - ); - await navigate(base + "/documents?scope=mine"); - await page - .getByRole("heading", { name: authored.title, exact: true }) - .waitFor(); - assert.equal(requestedAuthor, owner); - await navigate(base + "/write?id=" + authored.id); - await page.waitForFunction(title=>Array.from(document.querySelectorAll("input")).some(input=>input.value===title),authored.title); - assert.equal( - await page.getByLabel("文档短名", { exact: true }).isDisabled(), - true, - ); - assert.equal( - await page.getByLabel("标题", { exact: false }).inputValue(), - authored.title, - ); - await page.getByRole("button", { name: "协议 XML", exact: true }).click(); - const xmlInput = page.getByLabel("XML 输入"); - assert.match(await xmlInput.inputValue(), /revision="2"/); - await xmlInput.fill( - (await xmlInput.inputValue()).replace( - authored.title, - "通过 XML 更新的文档", - ), - ); - await page - .getByRole("button", { name: "可视化编辑", exact: true }) - .click(); - assert.equal( - await page.getByLabel("标题", { exact: false }).inputValue(), - "通过 XML 更新的文档", - ); - await page.setViewportSize({ width: 390, height: 844 }); - await navigate(base + "/documents"); - assert.equal( - await page.evaluate( - () => document.documentElement.scrollWidth <= window.innerWidth, - ), - true, - ); - await page.getByRole("button", { name: "打开导航" }).click(); - await page - .getByRole("dialog", { name: "工作区导航" }) - .waitFor({ state: "visible" }); - await page.keyboard.press("Escape"); - assert.equal( - await page.getByRole("dialog", { name: "工作区导航" }).isVisible(), - false, - ); - if (process.env.WORKSPACE_SCREENSHOT_DIR) - await page.screenshot({ - path: process.env.WORKSPACE_SCREENSHOT_DIR + "/mobile-directory.png", - fullPage: true, - }); - await navigate(base + "/guide/review-changes?revision=1"); - assert.equal( - await page.evaluate( - () => document.documentElement.scrollWidth <= window.innerWidth, - ), - true, - ); - if (process.env.WORKSPACE_SCREENSHOT_DIR) - await page.screenshot({ - path: process.env.WORKSPACE_SCREENSHOT_DIR + "/mobile-reader.png", - fullPage: true, - }); - await navigate(base + "/account"); - assert.equal( - await page.evaluate( - () => document.documentElement.scrollWidth <= window.innerWidth, - ), - true, - ); - await navigate(base + "/write"); - await page.getByLabel("文档短名", { exact: true }).waitFor(); - await page.getByRole("button", { name: "协议 XML", exact: true }).click(); - await page.getByLabel("XML 输入").waitFor(); - assert.equal( - await page.evaluate( - () => document.documentElement.scrollWidth <= window.innerWidth, - ), - true, - ); - if (process.env.WORKSPACE_SCREENSHOT_DIR) - await page.screenshot({ - path: process.env.WORKSPACE_SCREENSHOT_DIR + "/mobile-editor.png", - fullPage: true, - }); - await navigate(base + "/protocol"); - assert.equal( - await page.evaluate( - () => document.documentElement.scrollWidth <= window.innerWidth, - ), - true, - ); - if (process.env.WORKSPACE_SCREENSHOT_DIR) - await page.screenshot({ - path: process.env.WORKSPACE_SCREENSHOT_DIR + "/mobile-protocol.png", - fullPage: true, - }); - assert.deepEqual(errors, []); - } finally { - await context.close(); - await browser.close(); - } - }, -); +test("workspace filters, reading tabs, account states and editor work on desktop and mobile", {skip: !base}, async () => { + const browser = await chromium.launch({ + executablePath: process.env.CHROMIUM_PATH || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + headless: true, + args: ["--no-sandbox", "--disable-dev-shm-usage"], + }); + try { await verifyGuidanceWorkspaceUI(base!, browser); } + finally { await browser.close(); } +}); diff --git a/apps/waterfall-wiki/tests/integration.test.ts b/apps/waterfall-wiki/tests/integration.test.ts index b425335..44e3f46 100644 --- a/apps/waterfall-wiki/tests/integration.test.ts +++ b/apps/waterfall-wiki/tests/integration.test.ts @@ -33,10 +33,6 @@ test('real PostgreSQL + S3 notes, flat tags, resource sharing and video processi 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); await verifyEmailAccounts(); await verifyGuidancePublishing(base); - const workspaceEnv:NodeJS.ProcessEnv={...process.env,WORKSPACE_TEST_URL:base}; - delete workspaceEnv.NODE_TEST_CONTEXT; - const workspaceUI=spawn(process.execPath,['--import','tsx','--test','tests/guidance-workspace.test.ts'],{env:workspaceEnv,timeout:180000,stdio:'inherit'}); - const [workspaceExit]=await once(workspaceUI,'exit');assert.equal(workspaceExit,0,'SaaS workspace UI regression checks'); await verifyFlatNoteMigration(); const root=await request('/api/tags',{name:'线条',slug:'lines'});const child=await request('/api/tags',{name:'白色',slug:'white'}); const sameTag=await request('/api/tags',{name:'#线条'});assert.equal(sameTag.id,root.id,'Hashtag creation reuses the globally unique tag'); -- 2.52.0