游戏分发 web E2E 的冷启动假失败改为浏览器级预热
Project CI / AI game creator shell Rust crates (push) Successful in 1m31s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m56s
Project CI / Backend tests (push) Successful in 3m56s
Project CI / Frontend tests (push) Successful in 2m7s
Project CI / Native shell tests (push) Successful in 6m0s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m34s
Project CI / AI game creator shell web tests (push) Successful in 1m42s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 9m40s
Project CI / Repository checks (push) Successful in 2m15s

- 现象:冷 Vite 下 check:game-distribution-web-e2e 先卡在目录卡片 20 秒等待,重跑又卡在游玩页「开始游戏」15 秒等待;同一时刻公开目录 API 已把该游戏排在第一
- 根因:原先的 fetch 预热只拿到 SPA 外壳(HTML 里没有路由模块),Vite 仍在断言阶段现编译这些模块图
- 修复:改成起浏览器后用一次性 context 真走 /games、/games/detail、/games/play(domcontentloaded + 2 秒 settle,120 秒上限,失败只告警),首个 goto 的 120 秒超时保留
- 复验:重启 dev 栈(冷缓存)→ 24/24 PASS;四条 web E2E(web / publish / publish-recovery / a11y)全部复跑通过
- 文档:里程碑补 2026-09-29 复跑记录,pitfalls 同日条目按实测证据补正
This commit is contained in:
kdletters
2026-09-29 06:54:47 +08:00
parent 198c208172
commit d8011a72fa
3 changed files with 49 additions and 24 deletions
@@ -277,4 +277,15 @@
- `check:game-distribution-web-publish-recovery-e2e` → 全部通过(中断恢复草稿、重登继续沿用原版本、恢复后清理草稿、换账号看不到他人草稿)。
- `check:game-distribution-web-a11y-e2e` → 全部通过(桌面/移动导航、键盘可达、全屏、安全区、移动发布面板无横向溢出)。
**顺带修掉一处取证噪声**:`check:game-distribution-web-e2e` 的首个 `goto` 用的是默认 30 秒超时,碰上 Vite dev 冷启动(实测 `/games` 首请求 20.2 秒、warm 后 14 毫秒)会以 `Timeout 30000ms exceeded` 假失败。现在脚本在导航前先 `fetch` 预热 `/games`、`/games/detail?id=…`、`/games/play?id=…` 三条路由并把首个 `goto` 超时提到 120 秒;冷启动重跑即 24/24 PASS(详见 pitfalls 同日条目)。
**顺带修掉一处取证噪声**:`check:game-distribution-web-e2e` 的首个 `goto` 用的是默认 30 秒超时,碰上 Vite dev 冷启动(实测 `/games` 首请求 20.2 秒、warm 后 14 毫秒)会以 `Timeout 30000ms exceeded` 假失败。当时只是在导航前 `fetch` 预热三条路由并把首个 `goto` 超时提到 120 秒——**这版预热不够**(`fetch` 拿到的 SPA 外壳里没有路由模块),2026-09-29 已改成浏览器级预热,见下。
## 2026-09-29 复跑(四条 E2E 全绿 + 冷启动噪声彻底修掉)
重新拉起 `npm run dev` 后把四条真实网页栈 E2E 全部重跑,**四条全绿**:`check:game-distribution-web-e2e` **24/24 PASS**、`check:game-distribution-web-publish-e2e` / `-web-publish-recovery-e2e` / `-web-a11y-e2e` 全部通过(列表、发布待审、中断恢复草稿、桌面与移动导航/键盘可达都在内)。
其中 `web-e2e` 的冷启动假失败这次才真正修干净:
- **现象**:冷缓存下重跑,先在 `button.game-card:visible` 的 **20 秒**等待超时;第二次(目录已热)推进到游玩页 `getByRole('button', { name: '开始游戏' })` 的 **15 秒**等待超时。两次都停在「该路由第一次被浏览器加载」的那一步,而同一时刻公开目录 API 已把该游戏排在第一(说明后端与数据都没问题)。
- **根因**:`fetch('/games')` 只取到 SPA 外壳,HTML 里没有路由模块,所以 Vite dev 仍是在断言阶段现编译这些模块图,超过单步等待就被判成"页面打不开"。
- **修复**:`scripts/check-game-distribution-web-e2e.mjs` 改成**浏览器级预热**——起浏览器后用一次性 context 真的访问 `/games`、`/games/detail?id=…`、`/games/play?id=…`(`domcontentloaded` + 2 秒 settle,120 秒上限,失败只告警),再做断言;首个 `goto` 的 120 秒超时保留。
- **复验**:重启 dev 栈(Vite 冷缓存)→ 修复版 **24/24 PASS**;同一冷启动条件下修复前版本会在上述两处各失败一次。判据已写入 pitfalls 同日条目。
@@ -6137,8 +6137,10 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- **现象**:`npm run dev` 刚起(Vite 冷缓存)时跑 `check:game-distribution-web-e2e`,三轮 API 断言(管理员登录、作者注册、真实游戏发布上传→送审→审核)全过,但 `page.goto('http://127.0.0.1:3000/games', { waitUntil: 'domcontentloaded' })` 以 `Timeout 30000ms exceeded` 失败——看起来像"网页打不开"。
- **实测**:冷启动时 `Invoke-WebRequest /games` 花了 **20,171 ms**(`/` 约 2,146 ms),随后连续两次 4,208 ms / 10,119 ms,预热完成后 **14 ms**。原因是 Vite dev 按需编译该路由的模块图,首个请求最贵。
- **处理**:脚本在浏览器导航前先 `fetch` 预热本轮要用的 `/games`、`/games/detail?id=…`、`/games/play?id=…`(120s 上限,失败只告警),并把首个 `goto` 超时提到 120s;冷启动重跑 → **24/24 PASS**。另外三个 web 脚本原本就用 `waitUntil: 'commit', timeout: 120_000`,属同一类防护,别再去掉。
- **判据**:遇到"首个 goto 超时 + API 断言全过"的形态,先用 `curl`/`Invoke-WebRequest` 量一次同 URL 的耗时;20s 级冷启动说明是 Vite 编译,不是页面回归。
- **处理(2026-09-29 当天补正:`fetch` 预热不够)**:第一版只在导航前 `fetch` 预热 `/games`、`/games/detail?id=…`、`/games/play?id=…` 并把首个 `goto` 超时提到 120s。但 `fetch` 只拿到 SPA 外壳(HTML 里没有路由模块),**并没有真的让 Vite 编译那些模块**,所以冷启动下断言阶段照样假失败:实测第一次卡在 `button.game-card:visible` 的 20 秒等待,第二次(目录已热)又推进到游玩页 `getByRole('button', { name: '开始游戏' })` 的 15 秒等待——每次都停在「该路由第一次被**浏览器**加载」的那一步。
现在改为**浏览器级预热**:起浏览器后用一次性 context 真的把三条路由走一遍(`page.goto(url, { waitUntil: 'domcontentloaded', timeout: 120_000 })` + 2 秒 settle,失败只告警),让 Vite 先把模块编译落缓存,再建断言用的 context;首个 `goto` 的 120s 超时保留。另外三个 web 脚本原本就用 `waitUntil: 'commit', timeout: 120_000` + 60s 等待,属同一类防护,别再去掉。
- **复验**:重启 `npm run dev`(Vite 冷缓存)后跑修复版 → **24/24 PASS**;同一次冷启动下修复前的脚本会在上面两处各失败一次;四条 web E2E(`web` / `publish` / `publish-recovery` / `a11y`)本次全部复跑通过。
- **判据**:遇到"某一步等元素超时 + 前面的 API 断言全过",且这一步正好是该路由第一次在浏览器里加载,先怀疑 Vite 按需编译。`curl`/`Invoke-WebRequest` 量到的 20s 级冷启动只能解释导航慢;**纯 HTTP 预热不算预热**,要预热就得用浏览器真的走一遍。
## 2026-09-29 客户端"本地导出上限"不等于"平台发布上限"
+33 -21
View File
@@ -38,20 +38,33 @@ function check(name, ok, detail = '') {
);
}
// Vite dev 是按需编译的:某条路由第一次被访问时现编译模块图,实测 /games 冷启动可以到
// 20 秒以上,超过导航默认超时就会被当成"页面打不开"。先预热一遍路由,把冷启动从断言
// 语义里摘出去(预热失败不致命:真正导航时还会再试一次,失败信息照旧可见)。
async function warmUpWebRoute(url, timeoutMs = 120_000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
// Vite dev 是按需编译的:某条路由的模块图要等**浏览器**真的加载它时才现编译,实测冷启动
// 可以超过 20 秒,超过单步断言超时就会被当成"页面打不开"。纯 HTTP 预热只取到 SPA 外壳
// (HTML 里没有路由模块),挡不住这类慢——所以预热必须在浏览器里把每条路由真的走一遍,
// 让 Vite 先把模块编译好。预热失败不致命:断言阶段的导航还会再试一次,失败信息照旧可见。
async function warmUpWebRoutesInBrowser(browser, urls) {
const context = await browser.newContext({
viewport: { width: 1280, height: 800 },
});
try {
const response = await fetch(url, { signal: controller.signal });
await response.arrayBuffer();
console.log(`[warmup] ${url} -> ${response.status}`);
} catch (error) {
console.warn(`[warmup] ${url} 预热失败,导航阶段会再试一次:${error}`);
const page = await context.newPage();
for (const url of urls) {
try {
const response = await page.goto(url, {
waitUntil: 'domcontentloaded',
timeout: 120_000,
});
// 模块请求是 DOMContentLoaded 之后才陆续完成的,留一点时间让 Vite 编译落缓存。
await page.waitForTimeout(2_000);
console.log(
`[warmup] ${url} -> ${response?.status() ?? 'no-response'}`,
);
} catch (error) {
console.warn(`[warmup] ${url} 预热失败,断言阶段会再试一次:${error}`);
}
}
} finally {
clearTimeout(timer);
await context.close();
}
}
@@ -282,15 +295,6 @@ async function main() {
const title = `网页链路 ${String(stamp).slice(-6)}`;
const { gameId } = await publishFixtureGame(stamp, title);
// 先把这次要用到的三条网页路由预热一遍,避免 dev 冷启动把它们误伤成"打不开"。
for (const route of [
'/games',
`/games/detail?id=${gameId}`,
`/games/play?id=${gameId}`,
]) {
await warmUpWebRoute(`${WEB}${route}`);
}
const { chromium } = await loadPlaywright();
const executablePath = (process.env.E2E_CHROMIUM_EXECUTABLE ?? '').trim();
const browser = await chromium.launch({
@@ -298,6 +302,14 @@ async function main() {
...(executablePath ? { executablePath } : {}),
});
try {
// 先把这次要用到的三条网页路由在浏览器里预热一遍,避免 dev 冷启动把它们误伤成"打不开"。
await warmUpWebRoutesInBrowser(
browser,
['/games', `/games/detail?id=${gameId}`, `/games/play?id=${gameId}`].map(
(route) => `${WEB}${route}`,
),
);
const context = await browser.newContext({
viewport: { width: 1280, height: 800 },
});