diff --git a/tests/console.e2e.test.mjs b/tests/console.e2e.test.mjs index b98760d..b0bc8de 100644 --- a/tests/console.e2e.test.mjs +++ b/tests/console.e2e.test.mjs @@ -1,363 +1,19 @@ /** - * 通过浏览器验证实际生产构建,不替换内部模块、不预置 JWT。 - * 测试只连接独立本地 HTTP 假后端,覆盖页面行为和接口契约,不代表真实支付/供应商已验收。 - * 每个场景保存截图、脱敏请求形状及稳定结果,方便失败后复核。 + * 通过真实生产构建和独立本地 HTTP 边界验收认证、SSH 与付款。 + * 各业务回归文件复用同一套浏览器启动、凭证脱敏、外网拦截和结果清单协议。 */ import assert from "node:assert/strict"; -import { spawn } from "node:child_process"; import { createDecipheriv } from "node:crypto"; -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, test } from "node:test"; -import { fileURLToPath } from "node:url"; -import { setTimeout as delay } from "node:timers/promises"; -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"; -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) - ? "" - : 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, "") - .replaceAll(backendOrigin, "") - .replaceAll(loginPassword, "") - .replaceAll("e2e-session-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"); -} +import { test } from "node:test"; +import { + appOrigin, + eventually, + fixtureRequest, + login, + loginAccount, + loginPassword, + scenario, +} from "./helpers/console-browser.mjs"; async function visibleKeyIds(page) { // 从用户实际看到的表格读取 ID,而不是访问组件状态或请求客户端的内部缓存。 diff --git a/tests/helpers/console-browser.mjs b/tests/helpers/console-browser.mjs new file mode 100644 index 0000000..3a2be0c --- /dev/null +++ b/tests/helpers/console-browser.mjs @@ -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) + ? "" + : 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, "") + .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"); +}