/** * 通过浏览器验证实际生产构建,不替换内部模块、不预置 JWT。 * 测试只连接独立本地 HTTP 假后端,覆盖页面行为和接口契约,不代表真实支付/供应商已验收。 * 每个场景保存截图、脱敏请求形状及稳定结果,方便失败后复核。 */ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { access, mkdir, mkdtemp, writeFile } from "node:fs/promises"; import { createServer } from "node:net"; import { dirname, join, resolve } from "node:path"; import { createInterface } from "node:readline"; import { after, before } from "node:test"; import { fileURLToPath } from "node:url"; import { setTimeout as delay } from "node:timers/promises"; import { chromium } from "playwright"; const testDirectory = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const appRoot = resolve(process.env.E2E_APP_ROOT || join(testDirectory, "..")); // 默认复用系统 Chrome;CI/其他平台可指定可执行文件,不自动下载或修改用户浏览器。 const chromePath = process.env.CHROME_PATH || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; export const loginAccount = "audit@example.invalid"; export const loginPassword = "test-only-password"; const manifest = { schemaVersion: 1, scenarios: [] }; const children = []; export let backendOrigin; export let appOrigin; let browser; let runDirectory; export async function eventually(read, accept, message, timeout = 10000) { // 等待可观察结果稳定,而不是假设渲染/网络会在固定休眠后完成;失败信息过滤敏感字段。 const deadline = Date.now() + timeout; let value; do { value = await read(); if (accept(value)) return value; await delay(50); } while (Date.now() < deadline); const lastValue = JSON.stringify(value, (key, entry) => ["authorization", "password", "jwt", "token"].includes(key) ? "" : entry, ); assert.fail(`${message}; last value: ${lastValue}`); } function startProcess(arguments_, options = {}) { // 统一收集子进程日志和句柄,异常退出可诊断,after 钩子负责结束所有本地服务。 const child = spawn(process.execPath, arguments_, { ...options, stdio: ["ignore", "pipe", "pipe"], }); child.log = ""; child.stdout.on("data", (chunk) => { child.log += chunk.toString(); }); child.stderr.on("data", (chunk) => { child.log += chunk.toString(); }); children.push(child); return child; } export async function fixtureRequest(path, body) { // 控制口只属于本地测试后端,调整外部响应,不给产品源码增加测试专用分支。 const response = await fetch(`${backendOrigin}/__control/${path}`, { method: body === undefined ? "GET" : "POST", headers: { "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(5000), }); assert.equal(response.status, 200); return (await response.json()).data; } async function freePort() { // 让系统分配回环端口,降低并行运行多个验收进程时的固定端口冲突风险。 const server = createServer(); await new Promise((resolve_, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve_); }); const port = server.address().port; await new Promise((resolve_) => server.close(resolve_)); return port; } // node --test 并行运行各验收文件:freePort 释放端口到预览进程绑定之间,其他文件的进程可能占用同一端口。 // Nitro 绑定失败时只记录 EADDRINUSE 而不退出,HTTP 探测还会命中别的文件的预览服务, // 因此只以本进程输出的监听地址为准,冲突时换端口重试。 async function startApp(attempts = 5) { for (let attempt = 1; attempt <= attempts; attempt += 1) { const port = await freePort(); const origin = `http://127.0.0.1:${port}`; const app = startProcess( [join(appRoot, ".output", "server", "index.mjs")], { cwd: appRoot, env: { // 使用生产已有的公开 API 配置接入外部边界,不读取或修改项目 .env。 ...process.env, NODE_ENV: "production", HOST: "127.0.0.1", PORT: String(port), NITRO_HOST: "127.0.0.1", NITRO_PORT: String(port), NUXT_PUBLIC_API_BASE: backendOrigin, }, }, ); const state = await eventually( async () => { if (app.exitCode !== null || app.signalCode !== null) return "exited"; if (app.log.includes("EADDRINUSE")) return "collision"; if (!app.log.includes(`Listening on ${origin}`)) return false; try { const response = await fetch(`${origin}/login`, { signal: AbortSignal.timeout(1000), }); return response.ok && "ready"; } catch { return false; } }, Boolean, "Built app did not start", 30000, ); if (state === "ready") return origin; if (state === "exited") throw new Error(`Preview exited: ${app.log}`); children.splice(children.indexOf(app), 1); const exited = new Promise((resolve_) => app.once("exit", resolve_)); app.kill("SIGKILL"); await exited; } throw new Error(`Built app could not bind a free port in ${attempts} tries`); } before( async () => { // 必须先构建;此处运行 .output,而非开发服务器,确保测试走实际交付入口。 const runs = join(testDirectory, "fixtures", "runs"); await mkdir(runs, { recursive: true }); runDirectory = await mkdtemp(join(runs, "run-")); await access(chromePath); await access(join(appRoot, ".output", "server", "index.mjs")); const api = startProcess([ join(testDirectory, "fixtures", "api-server.mjs"), ]); // 假后端首行只报告监听地址,避免在启动协议中输出账号密码或请求内容。 backendOrigin = await new Promise((resolve_, reject) => { const lines = createInterface({ input: api.stdout }); const timer = setTimeout( () => reject(new Error("Fixture API did not start")), 10000, ); lines.once("line", (line) => { clearTimeout(timer); lines.close(); try { resolve_(JSON.parse(line).origin); } catch (error) { reject(error); } }); api.once("error", reject); api.once("exit", (code) => reject(new Error(`Fixture API exited: ${code}`)), ); }); appOrigin = await startApp(); browser = await chromium.launch({ executablePath: chromePath, headless: true, }); }, { timeout: 45000 }, ); after(async () => { // 先关闭浏览器,再逆序结束服务;挂起接口也不能让验收进程永久留在后台。 await browser?.close(); for (const child of children.reverse()) { if (child.exitCode !== null) continue; child.kill("SIGTERM"); await Promise.race([ new Promise((resolve_) => child.once("exit", resolve_)), delay(1500), ]); if (child.exitCode === null) child.kill("SIGKILL"); } if (runDirectory) { manifest.scenarios.sort((left, right) => left.name.localeCompare(right.name), ); await writeFile( join(runDirectory, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, ); console.log(`E2E artifacts: ${join(runDirectory, "manifest.json")}`); } }); function normalizeRequests(requests) { // 只记录字段名和是否认证;去重、排序移除并发请求顺序等非业务差异,不保存凭证值。 const distinct = requests.map( ({ method, path, query, body, authorization }) => ({ method, path, queryKeys: Object.keys(query).sort(), bodyKeys: Object.keys(body ?? {}).sort(), authenticated: authorization !== null, }), ); return [ ...new Map( distinct.map((entry) => [JSON.stringify(entry), entry]), ).values(), ].sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)), ); } export async function scenario(t, options, exercise) { // 每个场景重新初始化后端状态和浏览器上下文,账号与列表缓存不能污染下一场景。 await fixtureRequest("reset", options); const context = await browser.newContext({ viewport: { width: 1680, height: 1050 }, serviceWorkers: "block", }); const errors = []; const networkViolations = []; const allowedOrigins = new Set([appOrigin, backendOrigin]); await context.route("**/*", async (route) => { // 只允许两个本地 origin;意外的生产/CDN访问会被阻断,并在场景结束时作为失败报告。 const url = new URL(route.request().url()); if ( !["http:", "https:"].includes(url.protocol) || allowedOrigins.has(url.origin) ) return route.continue(); networkViolations.push( `${route.request().method()} ${url.origin}${url.pathname}`, ); await route.abort("blockedbyclient"); }); context.on("page", (openedPage) => { openedPage.setDefaultTimeout(10000); openedPage.on("pageerror", (error) => errors.push(error.message)); openedPage.on("console", (message) => { if (message.type() === "error") errors.push(message.text()); }); }); const page = await context.newPage(); const screenshot = `${t.name.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}.png`; let failure; let observed; let requests; try { observed = await exercise(page); } catch (error) { failure = error; } finally { // 即使业务断言失败,也尽量保存截图与请求诊断,不能只留下“超时”这一条错误。 try { assert.equal( await page .getByText("Invalid PrimeUI License", { exact: true }) .isVisible(), false, "The delivered UI must not display an invalid vendor license warning", ); } catch (error) { failure ??= error; } try { await page.screenshot({ path: join(runDirectory, screenshot), fullPage: true, animations: "disabled", timeout: 5000, }); } catch (error) { failure ??= error; } await context.close(); requests = await fixtureRequest("requests"); // 诊断附件保留先后顺序,但不落盘 body、Authorization 或 referrer 原文。 await writeFile( join(runDirectory, screenshot.replace(/\.png$/, ".requests.json")), `${JSON.stringify( requests.map( ( { method, path, authorization, handled, invalidAuthorization }, index, ) => ({ sequence: index + 1, method, path, authenticated: authorization !== null, handled, invalidAuthorization: invalidAuthorization === true, }), ), null, 2, )}\n`, ); try { assert.deepEqual( networkViolations, [], "Browser attempted a non-local network request", ); assert.deepEqual( errors, [], "Browser reported runtime or console errors", ); assert.deepEqual( requests.filter((request) => !request.handled), [], "Fixture is missing a production endpoint", ); assert.deepEqual( requests.filter((request) => request.invalidAuthorization), [], "API calls lost the session token", ); } catch (error) { failure ??= error; } // 主清单保留稳定观察结果;失败文本另外替换随机 origin、测试密码和令牌。 manifest.scenarios.push({ name: t.name, result: failure ? "failed" : "passed", ...(observed ? { observed } : {}), screenshot, requests: normalizeRequests(requests), browserErrors: [...new Set(errors)].sort(), networkViolations: [...new Set(networkViolations)].sort(), ...(failure ? { failure: { name: failure.name, message: failure.message .replaceAll(appOrigin, "") .replaceAll(backendOrigin, "") .replaceAll(loginPassword, "") .replaceAll("e2e-session-token", ""), }, } : {}), }); } if (failure) throw failure; } export async function login(page, redirect = "/security/ssh-keys") { // 所有普通场景经真实登录表单建立会话,顺带验证密码输入框没有退化成明文框。 await page.goto( `${appOrigin}/login?redirect=${encodeURIComponent(redirect)}`, ); const password = page.getByLabel("密码", { exact: true }); assert.equal(await password.getAttribute("type"), "password"); await page.getByLabel("账号", { exact: true }).fill(loginAccount); await password.fill(loginPassword); await page.getByRole("button", { name: "登录", exact: true }).click(); await page.waitForURL((url) => url.pathname !== "/login"); }