Files
cieanyau 651c36367f
前端验证 / verify (push) Failing after 15m2s
接入 ESLint 与 Prettier 并加入 CI 静态门禁
2026-11-07 01:24:36 +08:00

376 lines
13 KiB
JavaScript

/**
* 通过浏览器验证实际生产构建,不替换内部模块、不预置 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)
? "<redacted>"
: 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, "<app>")
.replaceAll(backendOrigin, "<api>")
.replaceAll(loginPassword, "<password>")
.replaceAll("e2e-session-token", "<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");
}