ci(frontend): 提高前端流水线及端到端测试稳定性
前端验证 / verify (push) Failing after 15m3s

- 使用内部 checkout 镜像,并校验运行环境中的 Node.js 22
- 检测预览服务端口冲突并重试,避免并行测试误连其他进程
- 调整布局测试的视口宽度和溢出断言,兼容经典滚动条
- 增加时钟暂停的时间余量,避免 CI 调度延迟导致测试失败
This commit is contained in:
cneicy committed 2026-11-06 20:24:56 +08:00
1 parent 6041d717f7
commit 5d080cacec
4 files changed
+69 -48

No files matched your search

+8 -6
View File
@@ -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: 安装测试浏览器
+52 -36
View File
@@ -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,
+6 -5
View File
@@ -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 });
}
+3 -1
View File
@@ -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 () => {