From 517c31ca046b7b853c62479cd8ace43860f8bf35 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Sun, 1 Nov 2026 21:36:24 +0800 Subject: [PATCH] fix(ci): wait for document bootstrap before browser interactions --- .../waterfall-wiki/tests/brand-navigation.mjs | 29 +++++++++++++++ .../tests/brand-navigation.test.mjs | 35 ++++++++++++++++++- .../tests/brand-ui-integration.ts | 18 ++-------- .../tests/canvas-ui-integration.ts | 11 ++++-- .../tests/guidance-integration.ts | 7 ++-- .../tests/guidance-workspace-ui.ts | 4 ++- 6 files changed, 80 insertions(+), 24 deletions(-) diff --git a/apps/waterfall-wiki/tests/brand-navigation.mjs b/apps/waterfall-wiki/tests/brand-navigation.mjs index 21a022d..7079839 100644 --- a/apps/waterfall-wiki/tests/brand-navigation.mjs +++ b/apps/waterfall-wiki/tests/brand-navigation.mjs @@ -30,3 +30,32 @@ export async function navigateBrandDocument(navigate, url, now = Date.now) { } throw new Error(`Document navigation failed: ${url}`); } + +/** Load a document fully before relying on server-rendered text or leaving it. + * Only document GETs may recover; application assertions remain outside retry. + * @param {import('playwright-core').Page} page + * @param {string} url + * @param {boolean} reload + */ +export async function visitBrowserDocument(page, url, reload = false) { + const started = Date.now(); + const response = await navigateBrandDocument(async timeout => { + const scripts = new Set(), failures = []; + const requested = request => { if (request.resourceType() === 'script') scripts.add(request); }; + const failed = request => { if (scripts.has(request)) failures.push(request.failure()?.errorText || 'unknown script failure'); }; + page.on('request', requested); + page.on('requestfailed', failed); + try { + const response = await (reload ? page.reload({waitUntil: 'domcontentloaded', timeout}) : page.goto(url, {waitUntil: 'domcontentloaded', timeout})); + if (response?.ok() && failures.length) throw new Error('Document bootstrap script request failed: ' + failures.join('; ')); + return response; + } finally { + page.off('request', requested); + page.off('requestfailed', failed); + } + }, url); + const remaining = 60000 - (Date.now() - started); + if (remaining <= 0) throw new Error('Document hydration budget exhausted: ' + url); + await page.locator('button[data-theme-ready="true"]').first().waitFor({timeout: remaining}); + return response; +} diff --git a/apps/waterfall-wiki/tests/brand-navigation.test.mjs b/apps/waterfall-wiki/tests/brand-navigation.test.mjs index 3a04f74..bdcc5a6 100644 --- a/apps/waterfall-wiki/tests/brand-navigation.test.mjs +++ b/apps/waterfall-wiki/tests/brand-navigation.test.mjs @@ -1,6 +1,6 @@ import {test} from 'node:test'; import assert from 'node:assert/strict'; -import {navigateBrandDocument} from './brand-navigation.mjs'; +import {navigateBrandDocument, visitBrowserDocument} from './brand-navigation.mjs'; const response = status => ({ok: () => status === 200, status: () => status}); test('successful documents are fetched once', async () => { @@ -48,3 +48,36 @@ test('recovery cannot extend the total navigation budget', async () => { }, '/canvas', () => elapsed), /budget exhausted/); assert.equal(calls, 1); }); + +function browserFixture(failBootstrap = false, failHydration = false) { + const listeners = new Map(), calls = []; + const page = { + on(event, fn) { if (!listeners.has(event)) listeners.set(event, new Set()); listeners.get(event).add(fn); }, + off(event, fn) { listeners.get(event)?.delete(fn); }, + async goto(url, options) { + calls.push(options.waitUntil); + const request = {resourceType: () => 'script', failure: () => ({errorText: 'net::ERR_ABORTED'})}; + for (const fn of listeners.get('request') || []) fn(request); + 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) throw Error('Hydration timeout'); }})}; }, + }; + return {page, calls, listeners}; +} +test('document visit completes deferred scripts before checking hydration and removes listeners', async () => { + const {page, calls, listeners} = browserFixture(); + await visitBrowserDocument(page, '/account'); + assert.deepEqual(calls, ['domcontentloaded', 'hydrated']); + assert.ok([...listeners.values()].every(set => set.size === 0)); +}); +test('interrupted bootstrap retries only document loading before hydration', async () => { + const {page, calls} = browserFixture(true); + await visitBrowserDocument(page, '/account'); + assert.deepEqual(calls, ['domcontentloaded', 'domcontentloaded', 'hydrated']); +}); +test('hydration failure is surfaced without retrying the application', async () => { + const {page, calls} = browserFixture(false, true); + await assert.rejects(visitBrowserDocument(page, '/account'), /Hydration timeout/); + assert.deepEqual(calls, ['domcontentloaded', 'hydrated']); +}); diff --git a/apps/waterfall-wiki/tests/brand-ui-integration.ts b/apps/waterfall-wiki/tests/brand-ui-integration.ts index dd0c87f..17fe415 100644 --- a/apps/waterfall-wiki/tests/brand-ui-integration.ts +++ b/apps/waterfall-wiki/tests/brand-ui-integration.ts @@ -1,7 +1,7 @@ /** CI-only checks against the disposable stack, never against personal documents. */ import assert from 'node:assert/strict'; import { chromium, type Request } from 'playwright-core'; -import { navigateBrandDocument } from './brand-navigation.mjs'; +import { visitBrowserDocument } from './brand-navigation.mjs'; export async function verifyBrandUI(base: string) { for (const prefix of process.env.VERIFY_CANVAS_UI === '1' ? ['', '/studio'] : ['']) { const icon = await fetch(`${base}${prefix}/favicon.ico`); @@ -28,21 +28,7 @@ export async function verifyBrandUI(base: string) { const visit = async (path: string, theme: string) => { const started = Date.now(); try { - // DOMContentLoaded also waits for this document's deferred scripts, unlike commit. - await navigateBrandDocument(async timeout => { - const attemptStarted=Date.now(), bootstrapFailures:string[]=[]; - const failed=(request:Request)=>{ - if(request.resourceType()==='script'&&(scriptStarted.get(request)??0)>=attemptStarted) - bootstrapFailures.push(request.failure()?.errorText||'unknown script failure'); - }; - page.on('requestfailed',failed); - try { - const response=await page.goto(base+path,{waitUntil:'domcontentloaded',timeout}); - if(response?.ok()&&bootstrapFailures.length)throw new Error('Document bootstrap script request failed: '+bootstrapFailures.join('; ')); - return response; - } finally {page.off('requestfailed',failed);} - }, base + path); - await page.locator('button[data-theme-ready="true"]').first().waitFor(); + await visitBrowserDocument(page, base + path); await page.waitForFunction(expected => document.documentElement.dataset.theme === expected, theme); console.log('Brand UI route ready:', path, theme, Date.now() - started); } catch (error) { diff --git a/apps/waterfall-wiki/tests/canvas-ui-integration.ts b/apps/waterfall-wiki/tests/canvas-ui-integration.ts index d868c70..ce8434c 100644 --- a/apps/waterfall-wiki/tests/canvas-ui-integration.ts +++ b/apps/waterfall-wiki/tests/canvas-ui-integration.ts @@ -1,3 +1,4 @@ +import { visitBrowserDocument } from "./brand-navigation.mjs"; import assert from 'node:assert/strict'; import {randomUUID} from 'node:crypto'; import {chromium,type Request} from 'playwright-core'; @@ -22,12 +23,13 @@ export async function verifyCanvasAgentUI(base:string){ page.on('response',response=>{const url=new URL(response.url());if(url.pathname.startsWith('/api/canvases'))canvasRequests.push(`${response.request().method()} ${url.pathname} ${response.status()}`)}); try{ page.setDefaultTimeout(60000);page.setDefaultNavigationTimeout(60000);page.on('pageerror',e=>errors.push(e.message)); - mark('open landing');await page.goto(base+'/',{waitUntil:'commit'}); + mark('open landing');await visitBrowserDocument(page,base+'/'); await page.getByRole('heading',{name:/想法不该停在对话里/,level:1}).waitFor(); const accountSession=page.waitForResponse(response=>new URL(response.url()).pathname==='/api/account/session'&&response.request().method()==='GET'); const [,sessionResponse]=await Promise.all([page.getByRole('link',{name:'开始创作',exact:true}).click(),accountSession]); assert.equal(sessionResponse.status(),200);assert.equal((await sessionResponse.json()).user,null); await page.getByRole('heading',{name:'欢迎回来',exact:true,level:2}).waitFor(); + await page.locator('button[data-theme-ready="true"]').first().waitFor(); mark('login form ready'); await page.getByLabel('邮箱地址',{exact:true}).fill(email); await page.getByLabel('密码',{exact:true}).fill(password); @@ -36,10 +38,13 @@ export async function verifyCanvasAgentUI(base:string){ await page.waitForURL(url=>url.pathname==='/workspace',{waitUntil:'commit'}); mark('workspace URL accepted'); await page.getByRole('heading',{name:'今天,让哪个想法成形?',exact:true}).waitFor(); + await page.locator('button[data-theme-ready="true"]').first().waitFor(); mark('workspace rendered'); const session=(await context.cookies()).find(c=>c.name===ACCOUNT_COOKIE);assert.ok(session?.httpOnly,'The actual login response establishes an HTTP-only account session'); const ownerHeaders={cookie:ACCOUNT_COOKIE+'='+session.value}; - await page.getByRole('link',{name:'无限画布',exact:true}).click(); + const canvasLink=page.getByRole('link',{name:'无限画布',exact:true}); + assert.equal(await canvasLink.getAttribute('href'),'/infinite_canvas'); + await visitBrowserDocument(page,base+'/infinite_canvas'); await page.getByRole('link',{name:email,exact:true}).waitFor({state:'attached'}); await page.getByRole('button',{name:'从示例开始 ↗',exact:true}).click(); const shot=page.locator('[data-node-id="shot"] [data-node-content]'); @@ -97,7 +102,7 @@ export async function verifyCanvasAgentUI(base:string){ assert.equal((await fetch(base+root,{headers:{authorization:second.authorization}})).status,404); await page.setViewportSize({width:390,height:844}); const bounds=await panel.boundingBox();assert.ok(bounds&&bounds.x>=0&&bounds.x+bounds.width<=390,'Agent panel stays within the mobile viewport'); - await page.reload({waitUntil:'commit'}); + await visitBrowserDocument(page,page.url(),true); await page.getByRole('link',{name:email,exact:true,includeHidden:true}).waitFor({state:'attached'}); await page.getByRole('button',{name:'Agent',exact:true}).click(); const reopened=page.getByRole('region',{name:'Agent 连接',exact:true}); diff --git a/apps/waterfall-wiki/tests/guidance-integration.ts b/apps/waterfall-wiki/tests/guidance-integration.ts index af69096..4c49835 100644 --- a/apps/waterfall-wiki/tests/guidance-integration.ts +++ b/apps/waterfall-wiki/tests/guidance-integration.ts @@ -1,3 +1,4 @@ +import { visitBrowserDocument } from "./brand-navigation.mjs"; import assert from 'node:assert/strict'; import {verifyGuidanceWorkspaceUI} from './guidance-workspace-ui'; import {chromium} from 'playwright-core'; @@ -46,7 +47,7 @@ export async function verifyGuidancePublishing(base:string){ 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"]`; - const navigation=await page.goto(base+'/documents',{waitUntil:'commit'});record('directory HTTP '+navigation?.status()); + const navigation=await visitBrowserDocument(page,base+'/documents');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:'搜索文档'}); @@ -54,7 +55,7 @@ export async function verifyGuidancePublishing(base:string){ await page.waitForURL(url=>url.searchParams.get('q')===httpDoc.title,{waitUntil:'commit'}); await page.getByRole('status').filter({hasText:'找到 2 篇文档'}).waitFor(); assert.equal(await page.getByText('没有匹配文档',{exact:false}).count(),0); - await page.reload({waitUntil:'commit'}); + await visitBrowserDocument(page,page.url(),true); await page.getByRole('status').filter({hasText:'找到 2 篇文档'}).waitFor(); // The matching count can also describe the unfiltered list before router hydration. await page.waitForFunction(title=>document.querySelector('input[aria-label="搜索文档"]')?.value===title,httpDoc.title); @@ -72,7 +73,7 @@ export async function verifyGuidancePublishing(base:string){ await page.waitForURL(url=>url.pathname==='/documents/'+httpDoc.id&&url.searchParams.get('revision')==='1',{waitUntil:'commit'}); 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:'commit'});await page.getByRole('heading',{name:'发布独立文档',exact:true,level:1}).waitFor();assert.equal(await page.locator('h1').textContent(),'发布独立文档'); + await visitBrowserDocument(page,base+'/write');await page.getByRole('heading',{name:'发布独立文档',exact:true,level:1}).waitFor();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); diff --git a/apps/waterfall-wiki/tests/guidance-workspace-ui.ts b/apps/waterfall-wiki/tests/guidance-workspace-ui.ts index 8c86dd0..dbd7264 100644 --- a/apps/waterfall-wiki/tests/guidance-workspace-ui.ts +++ b/apps/waterfall-wiki/tests/guidance-workspace-ui.ts @@ -1,5 +1,6 @@ /** UI fixtures run in a fresh context; real storage and authentication have separate coverage. */ import assert from "node:assert/strict"; +import { visitBrowserDocument } from "./brand-navigation.mjs"; import { mkdir } from "node:fs/promises"; import type { Browser } from "playwright-core"; import { documentInput, parseDocumentXml } from "../src/lib/guidance-input"; @@ -100,13 +101,14 @@ export async function verifyGuidanceWorkspaceUI(base: string, browser: Browser) async function navigate(url: string, reload = false) { const previousSessionReplies = sessionReplies; try { - const navigation = await (reload ? page.reload({waitUntil:"commit"}) : page.goto(url,{waitUntil:"commit"})); + const navigation = await visitBrowserDocument(page, url, reload); assert.ok(navigation?.ok(), "Navigation returns a successful document"); if (new URL(url).pathname === "/account") { await page.getByRole("heading", { name: signedIn ? "你的账户" : "欢迎回来", exact: true }).waitFor(); } else { await page.getByText(signedIn ? "editor@example.com" : "登录你的账户",{exact:true}).first().waitFor({state:"attached"}); } + await page.getByText("正在检查账户状态…", {exact: true}).waitFor({state: "hidden"}); assert.ok(sessionReplies > previousSessionReplies, "The new document requests its account fixture and renders the resulting account state"); } catch (error) { const probeStarted=Date.now(); -- 2.52.0