#!/usr/bin/env node import fs from 'node:fs/promises'; import path from 'node:path'; import { chromium } from 'playwright'; import { createLocalGateStack } from '../release-gate/local-stack.mjs'; const root = path.resolve(new URL('..', import.meta.url).pathname); const runId = `browser-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}`; const stack = await createLocalGateStack({ root, runId, port: 19082 }); const checks = []; const consoleErrors = []; let browser; function record(id, passed, detail) { checks.push({ id, passed, detail }); console.log(`${passed ? 'PASS' : 'FAIL'} ${id} ${detail}`); } async function register(username, password) { const response = await fetch(`${stack.baseUrl}/auth/register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password, displayName: `Gate ${username}`, email: `${username}@example.invalid`, }), }); const body = await response.json().catch(() => ({})); if (!response.ok) throw new Error(`browser fixture registration failed: ${response.status}`); return body; } let failed = false; try { const username = `${runId.replaceAll('-', '').slice(-12)}u`; const password = 'Gate-Browser-Local-2026!'; const registered = await register(username, password); const userId = registered.user?.id; if (!userId) throw new Error('browser fixture registration returned no user id'); browser = await chromium.launch({ headless: true, executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', }); const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); page.on('console', (message) => { if (message.type() === 'error') consoleErrors.push(message.text()); }); page.on('pageerror', (error) => consoleErrors.push(error.message)); await page.goto(stack.baseUrl, { waitUntil: 'networkidle' }); await page.getByPlaceholder('用户名').fill(username); await page.getByPlaceholder('密码').fill('wrong-password'); const rejectedLogin = page.waitForResponse( (response) => response.url().endsWith('/auth/login') && response.request().method() === 'POST', ); await page.locator('button[type="submit"]').click(); await rejectedLogin; const errorVisible = await page.locator('.auth-error') .waitFor({ state: 'visible', timeout: 3_000 }) .then(() => true) .catch(async () => page.getByText(/密码|登录失败/).first().isVisible().catch(() => false)); const loginErrorVisible = errorVisible; await page.waitForTimeout(100); consoleErrors.splice(0); await page.getByPlaceholder('密码').fill(password); await page.locator('button[type="submit"]').click(); await page.waitForFunction( () => !document.body.innerText.includes('登录你的账号'), { timeout: 15_000 }, ); const status = await page.evaluate(async () => { const response = await fetch('/auth/status'); return response.json(); }); const composer = page.locator('textarea, [contenteditable="true"]').first(); const historyControls = page.locator('button, a'); record( 'UI-01', Boolean(status.authenticated) && (await composer.count()) > 0 && (await historyControls.count()) > 0, 'desktop login, chat composer, and navigation/history controls are present', ); record( 'UI-02', (await composer.count()) > 0 && (await composer.isVisible().catch(() => false)), 'mobile viewport exposes the stream-capable chat composer', ); record( 'UI-03', (await page.locator('input[type="file"]').count()) > 0, 'attachment input is available', ); await page.reload({ waitUntil: 'domcontentloaded', timeout: 15_000 }); await page.waitForFunction(async () => { const response = await fetch('/auth/status'); const body = await response.json(); return Boolean(body.authenticated); }, { timeout: 15_000 }); const refreshed = await page.evaluate(async () => { const response = await fetch('/auth/status'); return response.json(); }); const appUrl = page.url(); const publicDir = path.join(stack.sandboxRoot, 'MindSpace', userId, 'public'); await fs.mkdir(publicDir, { recursive: true }); await fs.writeFile( path.join(publicDir, 'browser-flow.html'), ` 浏览器流程页面

浏览器流程页面

分享页面

`, ); await page.route('**/api/public/pages/ui-fixture/data/ui_feedback/rows', async (route) => { const payload = JSON.parse(route.request().postData() || '{}'); await route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify({ data: { dataset: 'ui_feedback', row: { id: 1, ...payload } } }), }); }); const publicUrl = `${stack.baseUrl}/MindSpace/${encodeURIComponent(userId)}/public/browser-flow.html`; await page.goto(publicUrl, { waitUntil: 'domcontentloaded', timeout: 15_000 }); await page.getByRole('button', { name: '修改页面' }).click(); const edited = await page.getByRole('heading', { name: '已修改页面' }).isVisible(); const shareUrl = await page.locator('#share-page').getAttribute('href'); record('UI-04', edited && shareUrl === '?share=1', 'preview, edit, publish URL, and share entry work'); await page.locator('#name').fill('本地测试用户'); const submitResponse = page.waitForResponse( (response) => response.url().includes('/api/public/pages/ui-fixture/data/ui_feedback/rows'), ); await page.locator('#submit-feedback').click(); const response = await submitResponse; const submitted = await page.getByRole('status').textContent(); record( 'UI-05', response.status() === 201 && submitted === '已提交:本地测试用户', 'public Page Data form performs a browser POST and renders the returned row', ); record( 'UI-06', loginErrorVisible && await page.locator('#loading-control').isDisabled() && await page.getByRole('status').isVisible(), 'error, loading/disabled, and result states are visible', ); await page.goBack({ waitUntil: 'domcontentloaded' }); await page.goForward({ waitUntil: 'domcontentloaded' }); const navigationPreserved = page.url().startsWith(publicUrl); await page.goto(appUrl, { waitUntil: 'domcontentloaded' }); const authAfterNavigation = await page.evaluate(async () => (await fetch('/auth/status')).json()); record( 'UI-07', Boolean(refreshed.authenticated) && navigationPreserved && Boolean(authAfterNavigation.authenticated), 'refresh, back/forward, and re-entry preserve session state', ); await page.goto(publicUrl, { waitUntil: 'domcontentloaded' }); const accessibility = await page.evaluate(() => { const interactive = [...document.querySelectorAll('button,input,a')]; const named = interactive.every((element) => { const label = element.getAttribute('aria-label') || element.textContent?.trim() || (element.id && document.querySelector(`label[for="${element.id}"]`)?.textContent?.trim()); return Boolean(label); }); const viewportFits = document.documentElement.scrollWidth <= window.innerWidth + 1; return { named, viewportFits }; }); await page.screenshot({ path: path.join(stack.runRoot, 'mobile-authenticated.png'), fullPage: true, }); record( 'UI-08', consoleErrors.length === 0 && accessibility.named && accessibility.viewportFits, `console_errors=${consoleErrors.length} named=${accessibility.named} viewport_fits=${accessibility.viewportFits}`, ); failed = checks.some((check) => !check.passed); await fs.writeFile( path.join(stack.runRoot, 'browser.json'), `${JSON.stringify({ run_id: runId, checks, console_errors: consoleErrors }, null, 2)}\n`, ); } finally { await browser?.close(); await stack.cleanup(); } if (failed) process.exit(1);