Files
memind/scripts/run-release-gate-browser-smoke.mjs
john b1577a16e9
Memind CI / Test, build, and release guards (push) Failing after 12m3s
fix: harden release gate and page delivery
2026-07-26 14:32:01 +08:00

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);