docs: introduce project and keep local reports out of source delivery
This commit is contained in:
1 parent
db5762abd4
commit
d37ce57d09
45 files changed
+232
-14820
No files matched your search
+3
-3
@@ -1,6 +1,6 @@
|
||||
# 浏览器端到端验收
|
||||
|
||||
先在 `frontend` 安装项目依赖和 `playwright`,执行 `npm run build`,再执行 `npm test`。
|
||||
先在项目根目录执行 `npm ci` 安装锁定依赖,执行 `npm run build`,再执行 `npm test`。
|
||||
测试使用 Node 自带的 `node:test`,直接操作实际构建后的页面,不替换项目内部模块。
|
||||
|
||||
测试会自动启动两个仅监听 `127.0.0.1` 的独立进程:构建后的 Nuxt 预览服务和 HTTP 假后端。
|
||||
@@ -14,8 +14,8 @@
|
||||
CHROME_PATH=/absolute/path/to/chrome npm test
|
||||
```
|
||||
|
||||
默认测试当前 `frontend/.output`。如需验证其他工作树中已经构建的前端,可设置
|
||||
`E2E_APP_ROOT=/absolute/path/to/frontend`;测试依赖仍从当前工作树解析。
|
||||
默认测试当前项目的 `.output`。如需验证其他工作树中已经构建的前端,可设置
|
||||
`E2E_APP_ROOT=/absolute/path/to/project`;测试依赖仍从当前工作树解析。
|
||||
|
||||
每次运行在 `tests/fixtures/runs/run-*` 下生成截图和 `manifest.json`。
|
||||
清单只包含稳定的场景结果、行为观察、请求形状以及浏览器错误/网络越界;不包含随机端口、
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* 通过浏览器验证实际生产构建,不替换内部模块、不预置 JWT。
|
||||
* 测试只连接独立本地 HTTP 假后端,覆盖页面行为和接口契约,不代表真实支付/供应商已验收。
|
||||
* 每个场景保存截图、脱敏请求形状及稳定结果,方便失败后复核。
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import { createDecipheriv } from "node:crypto";
|
||||
@@ -12,6 +17,7 @@ import { chromium } from "playwright";
|
||||
|
||||
const testDirectory = 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";
|
||||
@@ -25,6 +31,7 @@ let browser;
|
||||
let runDirectory;
|
||||
|
||||
async function eventually(read, accept, message, timeout = 10000) {
|
||||
// 等待可观察结果稳定,而不是假设渲染/网络会在固定休眠后完成;失败信息过滤敏感字段。
|
||||
const deadline = Date.now() + timeout;
|
||||
let value;
|
||||
do {
|
||||
@@ -41,6 +48,7 @@ async function eventually(read, accept, message, timeout = 10000) {
|
||||
}
|
||||
|
||||
function startProcess(arguments_, options = {}) {
|
||||
// 统一收集子进程日志和句柄,异常退出可诊断,after 钩子负责结束所有本地服务。
|
||||
const child = spawn(process.execPath, arguments_, {
|
||||
...options,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
@@ -57,6 +65,7 @@ function startProcess(arguments_, options = {}) {
|
||||
}
|
||||
|
||||
async function fixtureRequest(path, body) {
|
||||
// 控制口只属于本地测试后端,调整外部响应,不给产品源码增加测试专用分支。
|
||||
const response = await fetch(`${backendOrigin}/__control/${path}`, {
|
||||
method: body === undefined ? "GET" : "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -68,6 +77,7 @@ async function fixtureRequest(path, body) {
|
||||
}
|
||||
|
||||
async function freePort() {
|
||||
// 让系统分配回环端口,降低并行运行多个验收进程时的固定端口冲突风险。
|
||||
const server = createServer();
|
||||
await new Promise((resolve_, reject) => {
|
||||
server.once("error", reject);
|
||||
@@ -80,6 +90,7 @@ async function freePort() {
|
||||
|
||||
before(
|
||||
async () => {
|
||||
// 必须先构建;此处运行 .output,而非开发服务器,确保测试走实际交付入口。
|
||||
const runs = join(testDirectory, "fixtures", "runs");
|
||||
await mkdir(runs, { recursive: true });
|
||||
runDirectory = await mkdtemp(join(runs, "run-"));
|
||||
@@ -89,6 +100,7 @@ before(
|
||||
const api = startProcess([
|
||||
join(testDirectory, "fixtures", "api-server.mjs"),
|
||||
]);
|
||||
// 假后端首行只报告监听地址,避免在启动协议中输出账号密码或请求内容。
|
||||
backendOrigin = await new Promise((resolve_, reject) => {
|
||||
const lines = createInterface({ input: api.stdout });
|
||||
const timer = setTimeout(
|
||||
@@ -117,6 +129,7 @@ before(
|
||||
{
|
||||
cwd: appRoot,
|
||||
env: {
|
||||
// 使用生产已有的公开 API 配置接入外部边界,不读取或修改项目 .env。
|
||||
...process.env,
|
||||
NODE_ENV: "production",
|
||||
HOST: "127.0.0.1",
|
||||
@@ -155,6 +168,7 @@ before(
|
||||
);
|
||||
|
||||
after(async () => {
|
||||
// 先关闭浏览器,再逆序结束服务;挂起接口也不能让验收进程永久留在后台。
|
||||
await browser?.close();
|
||||
for (const child of children.reverse()) {
|
||||
if (child.exitCode !== null) continue;
|
||||
@@ -178,6 +192,7 @@ after(async () => {
|
||||
});
|
||||
|
||||
function normalizeRequests(requests) {
|
||||
// 只记录字段名和是否认证;去重、排序移除并发请求顺序等非业务差异,不保存凭证值。
|
||||
const distinct = requests.map(
|
||||
({ method, path, query, body, authorization }) => ({
|
||||
method,
|
||||
@@ -197,6 +212,7 @@ function normalizeRequests(requests) {
|
||||
}
|
||||
|
||||
async function scenario(t, options, exercise) {
|
||||
// 每个场景重新初始化后端状态和浏览器上下文,账号与列表缓存不能污染下一场景。
|
||||
await fixtureRequest("reset", options);
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1680, height: 1050 },
|
||||
@@ -206,6 +222,7 @@ async function scenario(t, options, exercise) {
|
||||
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) ||
|
||||
@@ -234,6 +251,7 @@ async function scenario(t, options, exercise) {
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
} finally {
|
||||
// 即使业务断言失败,也尽量保存截图与请求诊断,不能只留下“超时”这一条错误。
|
||||
try {
|
||||
assert.equal(
|
||||
await page
|
||||
@@ -257,6 +275,7 @@ async function scenario(t, options, exercise) {
|
||||
}
|
||||
await context.close();
|
||||
requests = await fixtureRequest("requests");
|
||||
// 诊断附件保留先后顺序,但不落盘 body、Authorization 或 referrer 原文。
|
||||
await writeFile(
|
||||
join(runDirectory, screenshot.replace(/\.png$/, ".requests.json")),
|
||||
`${JSON.stringify(
|
||||
@@ -301,6 +320,7 @@ async function scenario(t, options, exercise) {
|
||||
} catch (error) {
|
||||
failure ??= error;
|
||||
}
|
||||
// 主清单保留稳定观察结果;失败文本另外替换随机 origin、测试密码和令牌。
|
||||
manifest.scenarios.push({
|
||||
name: t.name,
|
||||
result: failure ? "failed" : "passed",
|
||||
@@ -327,6 +347,7 @@ async function scenario(t, options, exercise) {
|
||||
}
|
||||
|
||||
async function login(page, redirect = "/security/ssh-keys") {
|
||||
// 所有普通场景经真实登录表单建立会话,顺带验证密码输入框没有退化成明文框。
|
||||
await page.goto(
|
||||
`${appOrigin}/login?redirect=${encodeURIComponent(redirect)}`,
|
||||
);
|
||||
@@ -339,6 +360,7 @@ async function login(page, redirect = "/security/ssh-keys") {
|
||||
}
|
||||
|
||||
async function visibleKeyIds(page) {
|
||||
// 从用户实际看到的表格读取 ID,而不是访问组件状态或请求客户端的内部缓存。
|
||||
const rows = await page
|
||||
.getByRole("row")
|
||||
.filter({ hasText: /#\d+/ })
|
||||
@@ -351,6 +373,7 @@ test(
|
||||
{ timeout: 20000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
// 访客导航保留查询串和锚点,但不能先发受保护的列表请求再跳转。
|
||||
await page.goto(`${appOrigin}/security/ssh-keys?m=7#details`);
|
||||
await page.waitForURL((url) => url.pathname === "/login");
|
||||
const url = new URL(page.url());
|
||||
@@ -394,6 +417,7 @@ test(
|
||||
"Email accounts must not carry a phone prefix",
|
||||
);
|
||||
assert.notEqual(loginRequest.body.password, loginPassword);
|
||||
// 固定 AES 参数是既有接口协议;这里核对发送字段,不把它误当安全的密码存储方案。
|
||||
const decipher = createDecipheriv(
|
||||
"aes-128-cbc",
|
||||
Buffer.from("idcsmart.finance"),
|
||||
@@ -422,6 +446,7 @@ test(
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, { loginMode: "sms-only" }, async (page) => {
|
||||
// 外部浏览器时钟推进验证码倒计时,不向产品增加测试时间开关。
|
||||
await page.clock.install();
|
||||
await page.goto(
|
||||
`${appOrigin}/login?redirect=${encodeURIComponent("/security/ssh-keys")}`,
|
||||
@@ -629,6 +654,7 @@ test(
|
||||
ordinarySecond,
|
||||
Array.from({ length: 10 }, (_, index) => index + 11),
|
||||
);
|
||||
// 普通后端分页与前端搜索分页分别验证,防止二次切片或每页重复显示全部匹配项。
|
||||
await page.getByRole("textbox", { name: "搜索 SSH 密钥" }).fill("audit");
|
||||
await page.getByRole("button", { name: "搜索", exact: true }).click();
|
||||
const first = await eventually(
|
||||
@@ -716,6 +742,7 @@ test(
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, { sshLastPageSingleRow: true }, async (page) => {
|
||||
// 通过真实新增/编辑/删除制造末页消失,验证列表能回到合法页,而不只验证按钮关闭。
|
||||
await login(page);
|
||||
await eventually(
|
||||
() => visibleKeyIds(page),
|
||||
@@ -848,6 +875,7 @@ test(
|
||||
{ timeout: 25000 },
|
||||
(t) =>
|
||||
scenario(t, { hangSshBody: true }, async (page) => {
|
||||
// HTTP 头已返回但 JSON 体挂起,证明请求截止时间覆盖了解码阶段而非只覆盖 fetch。
|
||||
await page.clock.install();
|
||||
const headers = page.waitForResponse(
|
||||
(response) =>
|
||||
@@ -888,6 +916,7 @@ test(
|
||||
{ timeout: 25000 },
|
||||
(t) =>
|
||||
scenario(t, { hangLogout: true }, async (page) => {
|
||||
// 后端退出故意不返回;本地会话仍须立即撤销,不能把安全退出绑在网络成功上。
|
||||
await login(page);
|
||||
await page.getByRole("button", { name: "用户菜单", exact: true }).click();
|
||||
await page
|
||||
@@ -945,6 +974,7 @@ test(
|
||||
);
|
||||
|
||||
for (const [name, redirect] of [
|
||||
// 三种危险返回地址经真实登录入口验证,不能跳出本站 origin。
|
||||
["protocol relative", "//external.invalid/steal"],
|
||||
["encoded slash", "/%2fexternal.invalid/steal"],
|
||||
["backslash", "/\\external.invalid/steal"],
|
||||
@@ -987,6 +1017,7 @@ test(
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, { hangPaymentStatus: true }, async (page) => {
|
||||
// 支付状态接口永久挂起时,独立五分钟期限仍应终止支付等待。
|
||||
await login(page, "/orders/order/pay?id=42");
|
||||
await page.clock.install();
|
||||
await page.getByRole("button", { name: "确认支付", exact: true }).click();
|
||||
@@ -1098,6 +1129,7 @@ test(
|
||||
{ timeout: 25000 },
|
||||
(t) =>
|
||||
scenario(t, { expiredLegacy: true }, async (page) => {
|
||||
// 临时凭证首次拒绝后仍留在内存供原页重试,但不得重新进入地址栏或持久化存储。
|
||||
await page.goto(`${appOrigin}/home.htm?queryParam=e2e-session-token`);
|
||||
await page
|
||||
.getByRole("heading", { name: "暂时无法进入会员中心", exact: true })
|
||||
@@ -1214,6 +1246,7 @@ test(
|
||||
"Payment initialization must settle before expiry",
|
||||
);
|
||||
const baseline = (await fixtureRequest("requests")).length;
|
||||
// 付款前预检发现401后,不应因响应式身份变更重新触发支付初始化。
|
||||
await fixtureRequest("options", { expiredPayment: true });
|
||||
await page.getByRole("button", { name: "确认支付", exact: true }).click();
|
||||
await page.waitForURL((url) => url.pathname === "/login");
|
||||
@@ -1256,6 +1289,7 @@ test(
|
||||
"The first tab must show an authenticated page",
|
||||
);
|
||||
const secondTab = await page.context().newPage();
|
||||
// 使用同一浏览器真实 localStorage/storage 事件,不伪造跨标签页退出通知。
|
||||
await secondTab.goto(`${appOrigin}/security/ssh-keys`);
|
||||
await eventually(
|
||||
() => visibleKeyIds(secondTab),
|
||||
|
||||
Vendored
+19
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* 独立进程的 HTTP 假后端,仅监听回环地址,按既有 /console/v1 信封响应。
|
||||
* 只模拟测试需要的外部接口,不是可部署的业务后端,也不执行真实付款或短信投递。
|
||||
* 控制口允许场景重置数据/制造挂起和失效;它不进入产品接口实现。
|
||||
*/
|
||||
import { createServer } from "node:http";
|
||||
|
||||
const token = "e2e-session-token";
|
||||
@@ -8,6 +13,7 @@ const account = {
|
||||
credit: "100.00",
|
||||
};
|
||||
const initialKeys = Array.from({ length: 28 }, (_, index) => ({
|
||||
// 25个audit匹配项 + 3个other项,覆盖搜索结果10/10/5分页与切换过滤条件。
|
||||
id: index + 1,
|
||||
name:
|
||||
index < 25
|
||||
@@ -23,11 +29,13 @@ let paid = false;
|
||||
let keys = initialKeys.map((key) => ({ ...key }));
|
||||
|
||||
function reply(response, data = {}, extra = {}, httpStatus = 200) {
|
||||
// HTTP状态与业务信封可以独立覆盖,确保前端同时处理传输401和信封401。
|
||||
response.writeHead(httpStatus, { "Content-Type": "application/json" });
|
||||
response.end(JSON.stringify({ status: httpStatus, msg: "", data, ...extra }));
|
||||
}
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
// 预览服务和假后端端口不同,需要允许测试浏览器发送JSON与Bearer请求。
|
||||
response.setHeader("Access-Control-Allow-Origin", "*");
|
||||
response.setHeader(
|
||||
"Access-Control-Allow-Headers",
|
||||
@@ -51,6 +59,7 @@ const server = createServer(async (request, response) => {
|
||||
}
|
||||
|
||||
if (url.pathname === "/__control/reset" && request.method === "POST") {
|
||||
// 场景从全新列表和未付款状态开始;11行变体用于删除最后一页唯一记录。
|
||||
options = body ?? {};
|
||||
requests = [];
|
||||
paid = false;
|
||||
@@ -67,6 +76,7 @@ const server = createServer(async (request, response) => {
|
||||
|
||||
const path = url.pathname.replace(/^\/console\/v1/, "");
|
||||
const record = {
|
||||
// 请求原文仅在测试进程内存供断言使用,测试输出端另做脱敏,不能直接导出此对象。
|
||||
method: request.method,
|
||||
path,
|
||||
query: Object.fromEntries(url.searchParams),
|
||||
@@ -78,6 +88,7 @@ const server = createServer(async (request, response) => {
|
||||
requests.push(record);
|
||||
|
||||
if (path === "/common" && request.method === "GET") {
|
||||
// 模拟站点配置决定允许的登录方式;离线场景关闭外部CAPTCHA与OAuth。
|
||||
return reply(response, {
|
||||
website_name: "本地前端验收",
|
||||
login_phone_password: options.loginMode === "sms-only" ? 0 : 1,
|
||||
@@ -99,6 +110,7 @@ const server = createServer(async (request, response) => {
|
||||
if (path === "/phone/code" && request.method === "POST")
|
||||
return reply(response);
|
||||
|
||||
// 公共配置和登录之后的接口必须携带实际登录得到的测试令牌。
|
||||
if (request.headers.authorization !== `Bearer ${token}`) {
|
||||
record.invalidAuthorization = true;
|
||||
return reply(response, {}, { msg: "Missing fixture session" }, 401);
|
||||
@@ -138,6 +150,7 @@ const server = createServer(async (request, response) => {
|
||||
}
|
||||
if (path === "/ssh_key" && request.method === "GET") {
|
||||
if (options.hangSshBody) {
|
||||
// 只发送响应头和不完整JSON,保持连接以复现“响应体解码永不结束”。
|
||||
response.writeHead(200, { "Content-Type": "application/json" });
|
||||
response.write('{"status":200,"msg":"","data":');
|
||||
return;
|
||||
@@ -149,6 +162,7 @@ const server = createServer(async (request, response) => {
|
||||
{ status: 401, msg: "Expired fixture session" },
|
||||
);
|
||||
const page = Math.max(1, Number(url.searchParams.get("page")) || 1);
|
||||
// 列表接口仍走后端分页;搜索聚合/切片由真实前端路径负责。
|
||||
const limit = Math.max(1, Number(url.searchParams.get("limit")) || 10);
|
||||
return reply(response, {
|
||||
list: keys.slice((page - 1) * limit, page * limit),
|
||||
@@ -172,6 +186,7 @@ const server = createServer(async (request, response) => {
|
||||
return reply(response);
|
||||
}
|
||||
if (path === "/order/42" && request.method === "GET") {
|
||||
// 余额、订单与支付状态共享paid事实,支付成功后回读必须反映已付金额。
|
||||
if (options.expiredPayment)
|
||||
return reply(
|
||||
response,
|
||||
@@ -216,6 +231,7 @@ const server = createServer(async (request, response) => {
|
||||
if (path === "/voucher/pay" && request.method === "GET")
|
||||
return reply(response, { list: [] });
|
||||
if (path === "/pay" && request.method === "POST") {
|
||||
// Paid是现有后端的顶层字段;挂起变体返回渠道HTML,随后状态查询不响应。
|
||||
if (options.hangPaymentStatus)
|
||||
return reply(response, { html: '<div role="status">本地支付渠道</div>' });
|
||||
paid = true;
|
||||
@@ -247,6 +263,7 @@ const server = createServer(async (request, response) => {
|
||||
if (path === "/recommend/promoter" && request.method === "GET")
|
||||
return reply(response, { promoter: { permission: 0 } });
|
||||
|
||||
// 未实现接口显式失败并留标记,防止新增产品请求被“统一成功”假后端掩盖。
|
||||
record.handled = false;
|
||||
reply(
|
||||
response,
|
||||
@@ -257,12 +274,14 @@ const server = createServer(async (request, response) => {
|
||||
});
|
||||
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
// 由系统选择空闲端口,首行JSON作为父测试进程的启动握手。
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ origin: `http://127.0.0.1:${server.address().port}` })}\n`,
|
||||
);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
// 先关闭挂起连接,确保超时测试也能正常清理进程。
|
||||
server.closeAllConnections();
|
||||
server.close(() => process.exit(0));
|
||||
});
|
||||
Reference in new issue
Block a user