#!/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'), `