diff --git a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md index 01e8d9a53..521978f2c 100644 --- a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md +++ b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md @@ -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 同日条目。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 78774c571..b9dc496fc 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -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 客户端"本地导出上限"不等于"平台发布上限" diff --git a/scripts/check-game-distribution-web-e2e.mjs b/scripts/check-game-distribution-web-e2e.mjs index afc91f994..60ba8f326 100644 --- a/scripts/check-game-distribution-web-e2e.mjs +++ b/scripts/check-game-distribution-web-e2e.mjs @@ -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 }, });