Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 150e4b3af8 | |||
| e36b8aea6c | |||
| 517c31ca04 | |||
| fafdae66bb |
@@ -30,3 +30,39 @@ 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) {
|
||||
return navigateBrandDocument(async timeout => {
|
||||
const started = Date.now();
|
||||
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()) {
|
||||
const remaining = timeout - (Date.now() - started);
|
||||
if (remaining <= 0) throw new Error('Document hydration budget exhausted: ' + url);
|
||||
try {
|
||||
// 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});
|
||||
} catch (error) {
|
||||
if (failures.length) throw new Error('Document bootstrap script request failed: ' + failures.join('; '), {cause: error});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return response;
|
||||
} finally {
|
||||
page.off('request', requested);
|
||||
page.off('requestfailed', failed);
|
||||
}
|
||||
}, url);
|
||||
}
|
||||
|
||||
@@ -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,42 @@ test('recovery cannot extend the total navigation budget', async () => {
|
||||
}, '/canvas', () => elapsed), /budget exhausted/);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
function browserFixture(failBootstrap = false, failHydration = false, blockedOnce = 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 || (blockedOnce && calls.length === 2)) 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, false, true);
|
||||
await visitBrowserDocument(page, '/account');
|
||||
assert.deepEqual(calls, ['domcontentloaded', 'hydrated', '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']);
|
||||
});
|
||||
|
||||
test('cancelled script does not reject an already hydrated document', async () => {
|
||||
const {page, calls} = browserFixture(true);
|
||||
await visitBrowserDocument(page, '/account');
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user