测试:提取共享浏览器验收入口
This commit is contained in:
1 parent
0bddc203de
commit
22163a8b51
2 files changed
+371
-356
No files matched your search
+12
-356
@@ -1,363 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* 通过浏览器验证实际生产构建,不替换内部模块、不预置 JWT。
|
* 通过真实生产构建和独立本地 HTTP 边界验收认证、SSH 与付款。
|
||||||
* 测试只连接独立本地 HTTP 假后端,覆盖页面行为和接口契约,不代表真实支付/供应商已验收。
|
* 各业务回归文件复用同一套浏览器启动、凭证脱敏、外网拦截和结果清单协议。
|
||||||
* 每个场景保存截图、脱敏请求形状及稳定结果,方便失败后复核。
|
|
||||||
*/
|
*/
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { spawn } from "node:child_process";
|
|
||||||
import { createDecipheriv } from "node:crypto";
|
import { createDecipheriv } from "node:crypto";
|
||||||
import { access, mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
import { test } from "node:test";
|
||||||
import { createServer } from "node:net";
|
import {
|
||||||
import { dirname, join, resolve } from "node:path";
|
appOrigin,
|
||||||
import { createInterface } from "node:readline";
|
eventually,
|
||||||
import { after, before, test } from "node:test";
|
fixtureRequest,
|
||||||
import { fileURLToPath } from "node:url";
|
login,
|
||||||
import { setTimeout as delay } from "node:timers/promises";
|
loginAccount,
|
||||||
import { chromium } from "playwright";
|
loginPassword,
|
||||||
|
scenario,
|
||||||
const testDirectory = dirname(fileURLToPath(import.meta.url));
|
} from "./helpers/console-browser.mjs";
|
||||||
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";
|
|
||||||
const loginAccount = "audit@example.invalid";
|
|
||||||
const loginPassword = "test-only-password";
|
|
||||||
const manifest = { schemaVersion: 1, scenarios: [] };
|
|
||||||
const children = [];
|
|
||||||
let backendOrigin;
|
|
||||||
let appOrigin;
|
|
||||||
let browser;
|
|
||||||
let runDirectory;
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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}`)),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const port = await freePort();
|
|
||||||
appOrigin = `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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await eventually(
|
|
||||||
async () => {
|
|
||||||
if (app.exitCode !== null)
|
|
||||||
throw new Error(`Preview exited: ${app.log}`);
|
|
||||||
try {
|
|
||||||
return (
|
|
||||||
await fetch(`${appOrigin}/login`, {
|
|
||||||
signal: AbortSignal.timeout(1000),
|
|
||||||
})
|
|
||||||
).ok;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Boolean,
|
|
||||||
"Built app did not start",
|
|
||||||
30000,
|
|
||||||
);
|
|
||||||
|
|
||||||
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)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function visibleKeyIds(page) {
|
async function visibleKeyIds(page) {
|
||||||
// 从用户实际看到的表格读取 ID,而不是访问组件状态或请求客户端的内部缓存。
|
// 从用户实际看到的表格读取 ID,而不是访问组件状态或请求客户端的内部缓存。
|
||||||
|
|||||||
@@ -0,0 +1,359 @@
|
|||||||
|
/**
|
||||||
|
* 通过浏览器验证实际生产构建,不替换内部模块、不预置 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const port = await freePort();
|
||||||
|
appOrigin = `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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await eventually(
|
||||||
|
async () => {
|
||||||
|
if (app.exitCode !== null)
|
||||||
|
throw new Error(`Preview exited: ${app.log}`);
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
await fetch(`${appOrigin}/login`, {
|
||||||
|
signal: AbortSignal.timeout(1000),
|
||||||
|
})
|
||||||
|
).ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Boolean,
|
||||||
|
"Built app did not start",
|
||||||
|
30000,
|
||||||
|
);
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
Reference in new issue
Block a user