Compare commits

...

2 Commits

Author SHA1 Message Date
codex-bot 517c31ca04 fix(ci): wait for document bootstrap before browser interactions
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 1m23s
creative workspace review checks / verify (pull_request) Successful in 24m43s
2026-11-01 21:36:24 +08:00
Zayden-Jung fafdae66bb Merge pull request 'fix(auth): 独立登录注册页面并弱化主题切换' (#39) from codex/auth-page-fix into main
creative workspace CI and deployment / verify-and-deploy (push) Failing after 9m31s
Reviewed-on: #39
2026-11-01 13:16:22 +00:00
6 changed files with 80 additions and 24 deletions
@@ -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;
}
@@ -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']);
});
@@ -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) {
@@ -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});
@@ -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<HTMLInputElement>('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);
@@ -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();