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
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user