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 () => {