diff --git a/workflows/coding.mjs b/workflows/coding.mjs index ca56d4a..34b2f79 100644 --- a/workflows/coding.mjs +++ b/workflows/coding.mjs @@ -1299,6 +1299,11 @@ function frontendSkeletonPrompt(feItems, c) { ' - **前端 dev server 必须由 Playwright 自己拥有并做就绪门(关键,防 e2e 连接竞态)**:在同一份配置里声明 `webServer` 段——`command` = 起前端 dev/preview 的命令(取自 `frontend/package.json` scripts),`url` = 前端 baseURL,`reuseExistingServer: true`,`timeout` 给足(≥120000)。Playwright 会**轮询 `url` 直到真正可连**才开始跑用例,这正是竞态的解药。', ' - **绝不**用「并行起 vite + 直接跑 playwright」(如 `concurrently` / `&` 后台起再立刻跑)这类**无就绪门**的写法——那是本项目历史上最贵的一类假红灯。若项目约定必须自起,则**必须**在 `globalSetup` 里显式轮询前端端口直到返回 2xx/3xx 再返回,超时抛明确错误(`前端 dev server 在 s 内未就绪 @ `),**不允许**静默继续让用例去撞连接拒绝。', ` - **globalSetup**(如 \`frontend/e2e/global-setup.*\`):\`node ${c.root}/scripts/setup-test-db.mjs\`(复制源库→一次性副本)→ 冷起后端 + 轮询健康端点就绪(Flyway 在此把本轮新迁移 apply 到副本)→ 执行 \`node ${c.root}/scripts/seed-demo-data.mjs\`(灌演示种子进副本)→ 用 \`config-vars.yaml\` 的 \`admin_init\` 凭据经 \`docs/05-API接口契约.md\` 登录端点取 JWT,写 \`storageState\`(admin 登录态供 e2e 复用)。`, + // storageState 写不全 = 整条 e2e 线的沉默塌陷:token 有了但 user 没写,前端 auth store 判「没登录」, + // 于是每个复用 storageState 的用例都在未登录态下跑。2026-07-23 实测(xlyClaude13):authSlice 要求 + // erp_token + erp_user 两键齐备才回灌,global-setup 只写了 erp_token,冒烟断言恒红且重跑三次不变。 + // 「登录端点返回什么就写什么」不够——必须反查**前端真正读哪些键**,那才是登录态的判据。 + ' - **storageState 必须写「前端真正消费的完整登录态」,不是只写 token(硬要求)**:生成 globalSetup 前,先用 Grep 在 `frontend/src/` 定位 auth store / 启动回灌逻辑(如 `store/**/auth*`、`localStorage.getItem(` 的全部调用点),**逐个列出**它恢复登录态时读取的 key(常见:token + 用户对象 + 权限/角色,键名各项目不同)。globalSetup 写 storageState 时**这些键一个都不能少**,值取自登录端点响应。少写任何一个 → 应用启动即判未登录,所有复用 storageState 的 e2e 都在未登录态下跑,且表现为「元素找得到但内容是未登录态」这种极难归因的红。写完自检:把读取键清单与实际写入键清单逐项对账,写进 `decisions[]`。', // globalTeardown 只 kill 进程、**不删副本**:test.mjs 的晋升(promote-to-source)在 e2e 之后跑、 // 靠读副本的 flyway_schema_history 算 delta——teardown 若删副本,promote 会静默判"无迁移可晋升", // 晋升链路断且无声。副本清理归各闸门的 finally(test.mjs / seed runner / 行为门 runner)在 promote 后做; @@ -1313,8 +1318,17 @@ function frontendSkeletonPrompt(feItems, c) { ' - 值为 `smoke` → 只匹配 `e2e/__smoke__/` 下的 spec。', ' - 其它值 → 按逗号切分成路径片段列表,只匹配**路径含其中任一片段**的 spec(如 `order/create.spec.ts,order/list.spec.ts`)。', ' - 实现用 `testMatch`(接受正则数组)或等价过滤;片段先做正则转义再拼,**绝不**把用户值当正则直接塞进去。', - ' - **冒烟集 `frontend/e2e/__smoke__/app-smoke.spec.*`(必须生成)**:3 条以内的最小用例——① 带 `storageState` 打开 baseURL 首页,断言应用根节点已挂载(非空白、无未捕获错误);② 访问 router 里**已指向真组件**的任一路由,断言其可达且未渲染 `data-fe-stub`;③ 断言登录态有效(如页面出现仅登录后可见的导航元素)。它是增量模式下后端 test-gate 的 e2e 段唯一会跑的东西,职责是「栈起得来 + 应用没整体炸」,**不是**业务断言,故必须与具体业务功能解耦、不随 FE 增减而失效。', - ' - **说明**:这是 **e2e 基线契约**(前端 e2e 基线 = 源库副本 + 本轮新迁移 + 演示种子 + admin storageState)的**唯一接线点**——per-FE tdd 的 e2e 与阶段级 testGate 跑的 e2e 共用此 globalSetup。**骨架期只需静态成立 + 不破坏 build,无需真跑 e2e。** 幂等:已存在则按需补齐;**已存在但缺 `webServer` 就绪门(或用了无就绪门的并行起法)、缺 `ERP_E2E_SCOPE` 支持、缺冒烟集 → 必须补齐/改正**,这些都是已知会造成整轮 e2e 假红或增量模式失效的缺陷,不属于"已存在就不动"的范围。', + // 冒烟集的唯一职责是「栈起得来 + 应用没整体炸」。它在增量模式下是后端 test-gate 唯一会跑的 e2e, + // 一旦它自己红,整条流水线就被一条与业务无关的断言卡死——2026-07-23 实测正是如此:初版规格写了 + // 「断言顶栏显示已登录用户名」,而该项目 storageState 缺 erp_user,断言恒红、连跑 3 个 attempt 无一例外。 + // 教训:冒烟断言**只许依赖框架级不变量**(路由响应、重定向与否、根节点挂载),**绝不**依赖任何 + // UI 文案字面量、组件内部结构、或某个 auth 实现细节——那些是业务 e2e 的活,不是冒烟的活。 + ' - **冒烟集 `frontend/e2e/__smoke__/app-smoke.spec.*`(必须生成)**:3 条以内的最小用例,**文件首行逐字写 `// erp-smoke-contract: v2`**(版本标记,state 判据据此识别旧版冒烟集并重写)——', + ' - ① 带 `storageState` 打开 baseURL 首页:断言应用根节点已挂载(容器内有子节点、非空白页)且无未捕获 JS 错误(监听 `pageerror`)。', + ' - ② 访问 router 里**已指向真组件**的任一路由:断言 HTTP 响应非 4xx/5xx、页面未渲染 `data-fe-stub`。', + ' - ③ 登录态冒烟**只断言「没有被踢回登录页」**:带 `storageState` 访问一个需登录才可进的路由,断言最终 URL **不是**登录页路径(或页面上不出现登录表单)。**绝不**断言顶栏用户名、欢迎语、菜单项等任何 UI 文案——那依赖具体 auth 实现与文案,属业务 e2e 范畴;冒烟只回答「这份登录态被应用接受了吗」。', + ' - **通用红线**:三条用例都**不得**出现业务字面量(表名/字段名/中文文案/固定用户名)、不得依赖演示种子的具体行、不得随 FE 增减而失效。冒烟红 = 栈或应用整体坏了,不该有第二种解读。', + ' - **说明**:这是 **e2e 基线契约**(前端 e2e 基线 = 源库副本 + 本轮新迁移 + 演示种子 + admin storageState)的**唯一接线点**——per-FE tdd 的 e2e 与阶段级 testGate 跑的 e2e 共用此 globalSetup。**骨架期只需静态成立 + 不破坏 build,无需真跑 e2e。** 幂等:已存在则按需补齐;**已存在但缺 `webServer` 就绪门(或用了无就绪门的并行起法)、缺 `ERP_E2E_SCOPE` 支持、缺冒烟集、storageState 未写全登录态、冒烟集无 `erp-smoke-contract: v2` 标记(旧版规格) → 必须补齐/改正/按 v2 重写**,这些都是已知会造成整轮 e2e 假红、恒红或增量模式失效的缺陷,不属于"已存在就不动"的范围。', '6. **单测基线(测试隔离接线点)**:vitest 配置(按 docs/04 § 零 `frontend.unit_test_runner` 约定,如 `frontend/vitest.config.*` 或 package.json 内配置段)`include` **限定** `tests/**/*.test.*`——单测一律落 `frontend/tests/**`(镜像 `src/` 结构,smoke 类归 `tests/__smoke__/` 且文件名同样以 `.test.*` 结尾,见 docs/04 § 2.1),`frontend/src/` 内的测试残留不被执行(约定漂移立即可见)。不存在才创建,已存在则只补齐 include 限定。**legacy 守卫**:若 `frontend/src/` 内已存在测试文件(旧约定 colocation 残留),**绝不**收窄 include(否则旧单测静默停跑、回归覆盖丢失)——保持现有 include 不动,登记 `decisions[]` 提示人工迁移(src/ 内测试迁至 tests/ 镜像路径后方可收窄)。', '- **lazy 硬护栏**:router 表里**任何** FE 路由都不得用顶部静态 `import`;必须 `() => import(...)`。自检:Grep 路由文件,确认每个 FE 路由的 `component` 都是动态 import 形态。', '- **路径硬护栏**:所有产出文件必须以 `frontend/` 开头;命中 `backend/` / `sql/` / `scripts/` → 越界硬停。', @@ -1322,6 +1336,9 @@ function frontendSkeletonPrompt(feItems, c) { '## 自检(可构建)', '- 推断本项目前端 build / typecheck 命令(docs/04 § 零 / `frontend/package.json` scripts)。若可在子会话内安全跑(不挂死),**派 Agent 子会话**跑一次 build / dev-server 就绪探测确认骨架可构建可起;不可行则至少静态核对「docs/08 全量清单的每个 FE 路由都已声明 + 全 lazy + 导航无悬空 path + FeStub 存在」。', '- **增量回归自检**:若本次是在已有 router 上补齐(往轮已实现过 FE),再核对一遍「本轮待实现清单**之外**的 FE 路由,component 指向与改动前一致」——用 `git diff` 看 router 文件,出现「真组件 → FeStub」的行即为误伤,必须改回。', + // 冒烟集恒红会把整条流水线卡在一条与业务无关的断言上(实测烧掉 3 个 attempt + 仲裁循环), + // 而它每轮都跑,是所有 e2e 里最不该出错的一份。故在骨架期就把它跑通,别留到 test-gate 才炸。 + '- **冒烟集自检(必做,别把它留给 test-gate 才发现)**:冒烟集是增量模式下每轮必跑的那 3 条,它红一次就卡住整条流水线。逐条复核:断言里有没有 UI 文案字面量 / 依赖某个 auth 实现细节 / 依赖演示种子具体行?有 → 按 v2 规格改写。再对账一遍 globalSetup 写入的 storageState 键集与前端 auth store 读取的键集是否一致——不一致就是「应用判未登录」的恒红源头。', '- 占位符扫描:`TBD` / `TODO` / `【人工填写:】` → 命中即修。', '', commitBlock('frontend/', 'feat(fe-skeleton): App 外壳 + router 全量 lazy 路由表 + FeStub 占位', @@ -1349,11 +1366,14 @@ function frontendSkeletonStatePromptM(feItems, c) { // 存量项目的 config 是在这两项存在之前生成的,必须靠本判据把它们拽回来补齐—— // 否则 test-gate 设了 ERP_E2E_SCOPE 也没人消费,静默退化成全量(不假绿,但增量省时完全失效)。 `- **e2e 范围开关判据**:Grep \`${c.root}/frontend/playwright.config.*\` 是否含 \`ERP_E2E_SCOPE\`,且 \`${c.root}/frontend/e2e/__smoke__/\` 下存在至少一个 spec 文件。任一缺失 → 判 \`exists:false\`(存量项目普遍缺这两项,需要重跑骨架 stage 补齐)。`, + // 版本标记而非内容启发式:坏冒烟集"存在"但断言不合规,只查存在性会永远判 exists:true 跳过重写。 + // v1(无标记)里出现过依赖 UI 文案的登录断言,必然红且重跑无解——只能靠标记把它们全部拽回来重建。 + `- **冒烟集契约版本判据**:Read \`${c.root}/frontend/e2e/__smoke__/\` 下的 spec,其中**必须**有一份首行逐字含 \`erp-smoke-contract: v2\`。找不到该标记(旧版冒烟集)→ 判 \`exists:false\`——旧版可能带着依赖 UI 文案 / auth 实现细节的断言,那类断言会恒红且重跑无解,必须由骨架 stage 按 v2 规格重写。`, '- 判据**只针对上面列出的 FE**:router 里其它 FE 路由(往轮已实现,指向真组件)一律视为满足,**不要**因为它们没指向 `FeStub` 就判缺失。', '- 上面列出的 FE,其路由若已指向**真组件**(resume:上次已实现到一半)同样视为满足——本检测只问"路由在不在、是不是 lazy",不问指向谁。', `- **legacy 豁免**:若 \`${c.root}/frontend/src/\` 内已存在测试文件(\`*.test.*\` / \`*.spec.*\`,旧约定 colocation 残留),vitest include 项**不作为缺失判据**(视为满足)——该状态须人工迁移决策,绝不由骨架重跑静默收窄 include 停跑旧测试。`, '- 全部满足(骨架已建齐,含 e2e 基线脚手架 + 单测基线)→ `{ "exists": true }`', - '- 任一缺失(无 router / 缺上面某个待实现 FE 的路由 / 存在 eager import / 无 FeStub / 缺 Playwright 配置 / 缺 globalSetup / **缺前端 dev server 就绪门** / **缺 `ERP_E2E_SCOPE` 支持或冒烟集** / vitest include 未限定 tests/**/*.test.*(且非 legacy 豁免))→ `{ "exists": false }`', + '- 任一缺失(无 router / 缺上面某个待实现 FE 的路由 / 存在 eager import / 无 FeStub / 缺 Playwright 配置 / 缺 globalSetup / **缺前端 dev server 就绪门** / **缺 `ERP_E2E_SCOPE` 支持或冒烟集** / **冒烟集无 `erp-smoke-contract: v2` 标记** / vitest include 未限定 tests/**/*.test.*(且非 legacy 豁免))→ `{ "exists": false }`', '## 输出(EXISTS_SCHEMA)', ].join('\n') }