223 lines
9.2 KiB
JavaScript
223 lines
9.2 KiB
JavaScript
#!/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'),
|
|
`<!doctype html><html lang="zh-CN"><head>
|
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<meta name="description" content="Release gate browser flow">
|
|
<meta name="mindspace-cover" content='{"tag":"测试","subtitle":"浏览器流程"}'>
|
|
<title>浏览器流程页面</title>
|
|
<style>body{margin:0;font:16px system-ui}.wrap{max-width:720px;margin:auto;padding:20px}
|
|
label,input,button,a{display:block;margin:10px 0;max-width:100%}</style></head><body>
|
|
<main class="wrap"><h1 id="page-title">浏览器流程页面</h1>
|
|
<button id="edit-page" type="button">修改页面</button>
|
|
<a id="share-page" href="?share=1">分享页面</a>
|
|
<form id="feedback"><label for="name">姓名</label><input id="name" name="name" required>
|
|
<button id="submit-feedback" type="submit">提交</button></form>
|
|
<button id="loading-control" disabled aria-busy="true">加载中</button>
|
|
<p id="result" role="status" aria-live="polite"></p></main>
|
|
<script>
|
|
document.querySelector('#edit-page').onclick=()=>{document.querySelector('#page-title').textContent='已修改页面'};
|
|
document.querySelector('#feedback').onsubmit=async(event)=>{event.preventDefault();
|
|
const name=document.querySelector('#name').value;
|
|
const response=await fetch('/api/public/pages/ui-fixture/data/ui_feedback/rows',{method:'POST',
|
|
headers:{'Content-Type':'application/json','Idempotency-Key':'ui-fixture-001'},
|
|
body:JSON.stringify({name})});
|
|
const result=await response.json();document.querySelector('#result').textContent='已提交:'+result.data.row.name};
|
|
</script></body></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);
|