From 5d080cacec0d512381fbd58951bf88b92c0c7a60 Mon Sep 17 00:00:00 2001 From: cneicy Date: Fri, 6 Nov 2026 20:24:56 +0800 Subject: [PATCH] =?UTF-8?q?ci(frontend):=20=E6=8F=90=E9=AB=98=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E6=B5=81=E6=B0=B4=E7=BA=BF=E5=8F=8A=E7=AB=AF=E5=88=B0?= =?UTF-8?q?=E7=AB=AF=E6=B5=8B=E8=AF=95=E7=A8=B3=E5=AE=9A=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 使用内部 checkout 镜像,并校验运行环境中的 Node.js 22 - 检测预览服务端口冲突并重试,避免并行测试误连其他进程 - 调整布局测试的视口宽度和溢出断言,兼容经典滚动条 - 增加时钟暂停的时间余量,避免 CI 调度延迟导致测试失败 --- .gitea/workflows/frontend.yml | 14 ++-- tests/helpers/console-browser.mjs | 88 +++++++++++++++----------- tests/review-style-layout.e2e.test.mjs | 11 ++-- tests/review-style-motion.e2e.test.mjs | 4 +- 4 files changed, 69 insertions(+), 48 deletions(-) diff --git a/.gitea/workflows/frontend.yml b/.gitea/workflows/frontend.yml index b1485a4..4248a60 100644 --- a/.gitea/workflows/frontend.yml +++ b/.gitea/workflows/frontend.yml @@ -10,14 +10,16 @@ jobs: timeout-minutes: 15 steps: - name: 获取源码 - uses: https://github.com/actions/checkout@v4 + # Runner 所在网络访问 github.com 不稳定;使用 git.crash.work 上的副本(手动从 GitHub 推送更新)。 + uses: https://git.crash.work/actions/checkout@v4 with: persist-credentials: false - - name: 准备 Node.js - uses: https://github.com/actions/setup-node@v4 - with: - node-version: "22" - cache: npm + # Runner 位于中国大陆,ubuntu-latest 映射到自带 Node 22 的 node:22-bookworm-slim 镜像; + # 不再用 setup-node 从 GitHub 下载 Node(经常超时),只校验镜像内版本。 + - name: 确认 Node.js + run: | + node -v + node -e 'if (Number(process.versions.node.split(".")[0]) !== 22) { console.error("需要 Node 22"); process.exit(1) }' - name: 安装锁定依赖 run: npm ci - name: 安装测试浏览器 diff --git a/tests/helpers/console-browser.mjs b/tests/helpers/console-browser.mjs index 3a2be0c..f2383b3 100644 --- a/tests/helpers/console-browser.mjs +++ b/tests/helpers/console-browser.mjs @@ -87,6 +87,57 @@ async function freePort() { 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,而非开发服务器,确保测试走实际交付入口。 @@ -121,42 +172,7 @@ before( ); }); - 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, - ); + appOrigin = await startApp(); browser = await chromium.launch({ executablePath: chromePath, diff --git a/tests/review-style-layout.e2e.test.mjs b/tests/review-style-layout.e2e.test.mjs index a8fbf77..254069b 100644 --- a/tests/review-style-layout.e2e.test.mjs +++ b/tests/review-style-layout.e2e.test.mjs @@ -215,7 +215,7 @@ async function bannerGeometry(page) { textBottom: text.bottom, topbarTop: bar.top, topbarBottom: bar.bottom, - viewport: innerWidth, + viewport: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth, }; }); @@ -242,10 +242,11 @@ test( state.topbarTop >= state.bottom - 0.5, "The full notice and topbar must occupy separate visible regions", ); - assert.equal( - state.scrollWidth, - state.viewport, - "A maintenance notice must not widen the document", + // 根元素使用 scrollbar-gutter: stable;Linux/Windows 的经典滚动条预留槽位后, + // 内容宽度会比视口窄 15px,只有 macOS 覆盖式滚动条才会相等。验收只要求不产生横向溢出。 + assert.ok( + state.scrollWidth <= state.viewport, + `A maintenance notice must not widen the document: ${state.scrollWidth} > ${state.viewport}`, ); sizes.push({ width, noticeFits: true, headerBelowNotice: true }); } diff --git a/tests/review-style-motion.e2e.test.mjs b/tests/review-style-motion.e2e.test.mjs index 317fa65..4e4d0f5 100644 --- a/tests/review-style-motion.e2e.test.mjs +++ b/tests/review-style-motion.e2e.test.mjs @@ -88,7 +88,9 @@ async function enterMonitor(page) { await login(page, "/servers/server?id=5"); await page.getByRole("tab", { name: "监控", exact: true }).waitFor(); await page.evaluate(() => document.fonts.ready); - await page.clock.pauseAt(await page.evaluate(() => Date.now() + 50)); + // pauseAt 是跳转而非真实等待;只留 50ms 时,读时间与暂停之间的调度延迟(并行验收或 CI) + // 会让目标落在过去并报 “Cannot fast-forward to the past”,因此留出 1 秒余量。 + await page.clock.pauseAt(await page.evaluate(() => Date.now() + 1000)); await page.getByRole("tab", { name: "监控", exact: true }).click(); await eventually( async () => {