@tea-agent/loop-agent 0.39.0-next.3 → 0.39.0-next.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. package/AGENTS.md +4 -1
  2. package/CHANGELOG.md +270 -65
  3. package/README.md +11 -5
  4. package/bin/loop-agent.js +7 -3
  5. package/dist/application/dag/generate-task-dag.js +9 -0
  6. package/dist/application/task-lifecycle/advance.js +4 -0
  7. package/dist/build-stamp.json +3 -3
  8. package/dist/cli/command-definitions.js +16 -5
  9. package/dist/cli/program.js +16 -2
  10. package/dist/cli/update/notifier.js +2 -2
  11. package/dist/cli.js +2 -1
  12. package/dist/commands/dag-rerun.js +55 -1
  13. package/dist/commands/init-model-catalog.js +464 -0
  14. package/dist/commands/init-upgrade.js +265 -97
  15. package/dist/commands/init.js +428 -27
  16. package/dist/commands/inspect-next.js +8 -0
  17. package/dist/commands/task-advance.js +31 -0
  18. package/dist/executors/dag-pi-executor.js +118 -14
  19. package/dist/executors/pi-event-serializer.js +42 -1
  20. package/dist/executors/pi-executor.js +51 -0
  21. package/dist/executors/pi-extension-resolver.js +233 -0
  22. package/dist/executors/pi-playwright-cli-tool.js +51 -12
  23. package/dist/executors/pi-sdk-executor.js +187 -59
  24. package/dist/executors/shell-executor.js +533 -29
  25. package/dist/executors/shell-write-guard.js +7 -0
  26. package/dist/shared/dag-prompt-override.js +28 -0
  27. package/dist/shared/operator/capabilities.js +181 -27
  28. package/dist/shared/playwright-cli-command-policy.js +15 -0
  29. package/dist/shared/update/console-notifier.js +100 -0
  30. package/dist/{cli → shared}/update/npm-client.js +41 -8
  31. package/dist/{cli → shared}/update/state.js +41 -13
  32. package/dist/task/config-types.js +22 -0
  33. package/dist/task/contract/constants.js +1 -0
  34. package/dist/task/contract/diff.js +26 -0
  35. package/dist/task/contract/project.js +5 -0
  36. package/dist/task/contract/schema.js +6 -1
  37. package/dist/task/source-prepare/build-draft.js +15 -0
  38. package/dist/task/source-prepare/parse-intent.js +24 -4
  39. package/dist/worker/cli.js +31 -1
  40. package/dist/worker/console/chat/deferred-turn.js +91 -0
  41. package/dist/worker/console/chat/mcp-inventory.js +221 -0
  42. package/dist/worker/console/chat/pi-runtime.js +165 -83
  43. package/dist/worker/console/chat/repo-walk.js +10 -1
  44. package/dist/worker/console/chat/routes.js +478 -55
  45. package/dist/worker/console/chat/semantic-activity.js +20 -8
  46. package/dist/worker/console/chat/session-store.js +146 -2
  47. package/dist/worker/console/chat/turn-process.js +17 -30
  48. package/dist/worker/console/dag-execution-receipt.js +33 -0
  49. package/dist/worker/console/harness-pi-model-matrix.js +137 -0
  50. package/dist/worker/console/operator-actions.js +244 -4
  51. package/dist/worker/console/operator-user-error.js +20 -1
  52. package/dist/worker/console/pi-readiness.js +12 -5
  53. package/dist/worker/console/recovery-cta.js +4 -4
  54. package/dist/worker/console/recovery-error-copy.js +4 -4
  55. package/dist/worker/console/routes.js +43 -2
  56. package/dist/worker/console/security.js +83 -10
  57. package/dist/worker/console/server.js +42 -6
  58. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-DSvF6RXQ.js → abnfDiagram-N423BO3Z-gauhD9kD.js} +1 -1
  59. package/dist/worker/console/static/assets/{arc-DuI4ogtJ.js → arc-pm3c2Rsl.js} +1 -1
  60. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-DdBbgD4y.js → architectureDiagram-T3A2C74G-D_JAg3wH.js} +1 -1
  61. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-xQC2HELm.js → blockDiagram-VBNYF7ZC-Dk7-ex52.js} +1 -1
  62. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-DPMSgwIZ.js → c4Diagram-5PPSVZJV-Uval9oRI.js} +1 -1
  63. package/dist/worker/console/static/assets/channel-CVrnUdZl.js +1 -0
  64. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-WTEKTxin.js → chunk-2GRJ4B5K-BSYjHs-v.js} +1 -1
  65. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-B6vZ0Pv_.js → chunk-2Q5K7J3B-KGUuBXI8.js} +1 -1
  66. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-BrerNAix.js → chunk-5RXB4S5H-JqAtxD9N.js} +1 -1
  67. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-D_ch2aaI.js → chunk-5VM5RSS4-D6EJOVQK.js} +1 -1
  68. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-VvfacNrq.js → chunk-6Q2QTUOP-B1JriSjm.js} +1 -1
  69. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-B-uGFIg2.js → chunk-GF5L2VYU-D62b96Jd.js} +1 -1
  70. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-BlysCtod.js → chunk-JWPE2WC7-BsNKEC_r.js} +1 -1
  71. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-BjAUAfcT.js → chunk-KBJHAD2P-B56v6-ZP.js} +1 -1
  72. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-BeZnBmb-.js → chunk-RYQCIY6F-D2KPnd8K.js} +1 -1
  73. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-Bbfe3UJG.js → chunk-XXDRQBXY-kzKSCaaL.js} +1 -1
  74. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-aX91WTya.js +1 -0
  75. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-aX91WTya.js +1 -0
  76. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-BKJQnYVM.js → cose-bilkent-JH36ORCC-C6N6KNJd.js} +1 -1
  77. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-B1wtI3hU.js → cynefin-VYW2F7L2-DiyymT2D.js} +1 -1
  78. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-DCF6DBSu.js → cynefinDiagram-MW4NZA55-BeKAXvf3.js} +1 -1
  79. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BJyNGRlP.js → dagre-VZM6K2ZE-TkIdXfPF.js} +1 -1
  80. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-B7j1-VsZ.js → diagram-7IWD3JNH-BFmSG3ks.js} +1 -1
  81. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-BREJ140a.js → diagram-B4RE2ZJO-DqpJT2Fs.js} +1 -1
  82. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-CDPHmHGH.js → diagram-LBJQPF4R-r5ATN43t.js} +1 -1
  83. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-BtZLrez1.js → diagram-Q27KOJAE-DEcSCMuR.js} +1 -1
  84. package/dist/worker/console/static/assets/{diagram-UB23O5K3-mzybu8oU.js → diagram-UB23O5K3-VOvYRLVc.js} +1 -1
  85. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-Dk3I2uO6.js → ebnfDiagram-BXEA7PRR-D7f4Cb1u.js} +1 -1
  86. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-BT97u_tQ.js → erDiagram-JOGREHBK-ziUf3U0G.js} +1 -1
  87. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-CMOtYzui.js → flowDiagram-UKHOOZJN-BoRdQ-1y.js} +1 -1
  88. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-DiNydkuY.js → ganttDiagram-PKOTCBZU-B1-GncNm.js} +1 -1
  89. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Cwt6fLkl.js → gitGraphDiagram-DS77QQ5N-CTW-ph8W.js} +1 -1
  90. package/dist/worker/console/static/assets/index-C8rOIAJr.css +1 -0
  91. package/dist/worker/console/static/assets/index-DEbyrEfW.js +404 -0
  92. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-AsGIGKKB.js → infoDiagram-6WML65LV-BFA337tZ.js} +1 -1
  93. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-BL8ITZh3.js → ishikawaDiagram-WSZJBQD7-BtEujgRR.js} +1 -1
  94. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-VPKAUX8M.js → journeyDiagram-NVQOT4AX-C8ZwD2Jp.js} +1 -1
  95. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-BL56hfGp.js → kanban-definition-27J2QSJJ-Ck-z8B11.js} +1 -1
  96. package/dist/worker/console/static/assets/{linear-BmSG3nIZ.js → linear-CAyXjQjN.js} +1 -1
  97. package/dist/worker/console/static/assets/{mermaid.core-DWGK9kwP.js → mermaid.core-CcWKn6tS.js} +5 -5
  98. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS--vvKLjo0.js → mindmap-definition-FAOFIHXS-C2NuDO7E.js} +1 -1
  99. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-B41mxb2-.js → pegDiagram-VL7TDLO6-BFub3PDz.js} +1 -1
  100. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-DTlu77sw.js → pieDiagram-7S7Q4E2Y-BJx5THZ5.js} +1 -1
  101. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-Bk5A7alv.js → quadrantDiagram-CIZ2JOQS-DhalkDWV.js} +1 -1
  102. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-Csf9jzQy.js → railroadDiagram-AXF67PYL-CVcBM36o.js} +1 -1
  103. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-CeptoSNx.js → requirementDiagram-LRYGKXZP-D_vy4n0w.js} +1 -1
  104. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-A-DLCLer.js → sankeyDiagram-W5VNT64P-DGYdJL1D.js} +1 -1
  105. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-22VbPdOI.js → sequenceDiagram-SI44F4Z6-C1xJFg3k.js} +1 -1
  106. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-DWvAyBC9.js → sizeCapture-X5ZJPWSS-D_Fc43df.js} +1 -1
  107. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-qICZ75yT.js → stateDiagram-OKZ733FA-BhGbIjbu.js} +1 -1
  108. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DUt1lyFL.js +1 -0
  109. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-xi7lcMyp.js → swimlanes-SLNWSIFB-DVkNcO-V.js} +2 -2
  110. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-BM6SGr7j.js +8 -0
  111. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-Bu6Nl6O2.js → timeline-definition-Z64GVDOM-BQU9ufV0.js} +1 -1
  112. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-BzMz4Bn9.js → vennDiagram-T6HMQDX7-BCQhRmYB.js} +1 -1
  113. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-CxaJnzc5.js → wardleyDiagram-T6FBY63Y-DWerOyHO.js} +1 -1
  114. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-5JK-omQK.js → xychartDiagram-ELKLHX3M-DxLeebQU.js} +1 -1
  115. package/dist/worker/console/static/index.html +2 -2
  116. package/dist/worker/console/static-src/app/console-types.js +39 -0
  117. package/dist/worker/console/static-src/app/useOperatorActions.js +1 -1
  118. package/dist/worker/console/static-src/app/useRecoveryActions.js +2 -2
  119. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +82 -22
  120. package/dist/worker/console/static-src/operator-chat/mutation-gate.js +45 -0
  121. package/dist/worker/console/static-src/operator-chat/open-preview-in-browser.js +328 -0
  122. package/dist/worker/console/static-src/operator-chat/resource-diagnostics.js +204 -0
  123. package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +8 -1
  124. package/dist/worker/console/static-src/operator-chat/turn-submission.js +42 -0
  125. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +18 -0
  126. package/dist/worker/console/static-src/operator-chat/useChatStream.js +216 -10
  127. package/dist/worker/console/static-src/operator-chat/useChatThread.js +50 -22
  128. package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +26 -1
  129. package/dist/worker/loop-agent/loop-agent-client.js +7 -3
  130. package/dist/worker/observe/node-input.js +74 -3
  131. package/dist/worker/observe/node-process.js +377 -0
  132. package/dist/worker/observe/routes.js +36 -3
  133. package/dist/worker/observe/shell-handler-keys.js +34 -0
  134. package/dist/worker/observe/static/api.js +66 -0
  135. package/dist/worker/observe/static/constants.js +8 -1
  136. package/dist/worker/observe/static/dag-history-labels.js +4 -0
  137. package/dist/worker/observe/static/format.js +23 -0
  138. package/dist/worker/observe/static/markdown-render.js +20 -1
  139. package/dist/worker/observe/static/operator-chrome.js +4 -0
  140. package/dist/worker/observe/static/state.js +13 -2
  141. package/dist/worker/observe/static/styles.css +73 -0
  142. package/dist/worker/observe/static/views/dag-inspector.js +612 -8
  143. package/dist/worker/observe/static/views/dag.js +8 -1
  144. package/dist/worker/observe/static/views/failures.js +5 -2
  145. package/dist/worker/observe/static/views/session-timeline.js +78 -9
  146. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +827 -53
  147. package/dist/workflows/dag/backend-test-case-manifest.js +4 -0
  148. package/dist/workflows/dag/backend-test-execution-contract.js +18 -9
  149. package/dist/workflows/dag/backend-test-gap-fill.js +205 -0
  150. package/dist/workflows/dag/backend-test-intake-context.js +32 -0
  151. package/dist/workflows/dag/backend-test-layout.js +133 -0
  152. package/dist/workflows/dag/backend-test-markdown-workflow.js +167 -32
  153. package/dist/workflows/dag/backend-test-module-stem.js +5 -0
  154. package/dist/workflows/dag/backend-test-pytest-collection.js +368 -33
  155. package/dist/workflows/dag/backend-test-result-contract.js +1 -1
  156. package/dist/workflows/dag/backend-test-scenario-param.js +884 -140
  157. package/dist/workflows/dag/backend-test-scenario-partitions.js +321 -0
  158. package/dist/workflows/dag/backend-test-writer-completeness.js +100 -56
  159. package/dist/workflows/dag/contract-output-registry.js +15 -0
  160. package/dist/workflows/dag/contract-validator-registrations.js +3 -1
  161. package/dist/workflows/dag/convergence/controller.js +141 -0
  162. package/dist/workflows/dag/dynamic-runtime/map.js +24 -8
  163. package/dist/workflows/dag/dynamic-runtime/shared.js +1 -1
  164. package/dist/workflows/dag/failure-category.js +12 -0
  165. package/dist/workflows/dag/frontend-implementation-contract.js +493 -2
  166. package/dist/workflows/dag/frontend-plan-render.js +90 -0
  167. package/dist/workflows/dag/frontend-prewrite-gate.js +221 -31
  168. package/dist/workflows/dag/frontend-repair.js +14 -0
  169. package/dist/workflows/dag/frontend-risk.js +15 -2
  170. package/dist/workflows/dag/frontend-test-case-checklist.js +26 -2
  171. package/dist/workflows/dag/frontend-test-case-manifest.js +5 -0
  172. package/dist/workflows/dag/frontend-test-case-quality.js +5 -13
  173. package/dist/workflows/dag/frontend-test-environment-probe.js +227 -0
  174. package/dist/workflows/dag/frontend-test-markdown.js +61 -0
  175. package/dist/workflows/dag/frontend-test-result-contract.js +160 -28
  176. package/dist/workflows/dag/frontend-test-standard-scenarios.js +68 -0
  177. package/dist/workflows/dag/frontend-verification-trace.js +29 -4
  178. package/dist/workflows/dag/init-hybrid.js +895 -127
  179. package/dist/workflows/dag/node-execution.js +194 -6
  180. package/dist/workflows/dag/prompt.js +60 -1
  181. package/dist/workflows/dag/rerun-feedback.js +212 -0
  182. package/dist/workflows/dag/rerun-plan.js +224 -27
  183. package/dist/workflows/dag/rerun-run.js +61 -6
  184. package/dist/workflows/dag/rerun-task.js +61 -2
  185. package/dist/workflows/dag/retry-policy.js +113 -0
  186. package/dist/workflows/dag/structured-output-repair.js +712 -0
  187. package/dist/workflows/dag/types.js +157 -1
  188. package/dist/workflows/dag/validate.js +41 -1
  189. package/docs/init-surface.manifest.json +5 -0
  190. package/docs/operations/README.md +1 -1
  191. package/docs/operations/local-development-environment.md +1 -5
  192. package/docs/skills/vetted-skill-registry.md +14 -0
  193. package/docs/templates/README.md +1 -1
  194. package/docs/templates/agent-dag.schema.json +44 -0
  195. package/docs/templates/backend-test-dag.json +60 -35
  196. package/docs/templates/frontend-implementation-contract.schema.json +9 -2
  197. package/docs/templates/frontend-test-dag.json +8 -10
  198. package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +1 -1
  199. package/docs/templates/init-managed-agents.md +13 -3
  200. package/harness.json +5 -3
  201. package/package.json +12 -4
  202. package/scripts/next-info.mjs +356 -0
  203. package/scripts/next-publish-gate.mjs +238 -0
  204. package/scripts/release-source-binding.mjs +251 -0
  205. package/skills/analyze-product-requirements/SKILL.md +8 -5
  206. package/skills/analyze-product-requirements/references/acceptance-criteria.md +1 -1
  207. package/skills/analyze-product-requirements/references/clarification-and-knowledge.md +10 -6
  208. package/skills/analyze-product-requirements/references/example.md +50 -0
  209. package/skills/analyze-product-requirements/references/forward-test-cases.md +11 -7
  210. package/skills/analyze-product-requirements/references/kb-integration.md +5 -5
  211. package/skills/analyze-product-requirements/references/product-analysis-schema.md +8 -4
  212. package/skills/analyze-product-requirements/references/product-requirement-schema.md +2 -2
  213. package/skills/analyze-product-requirements/references/requirement-clarification-schema.md +1 -1
  214. package/skills/analyze-product-requirements/scripts/test-validators.mjs +11 -1
  215. package/skills/analyze-product-requirements/scripts/validate-product-analysis.mjs +20 -1
  216. package/skills/analyze-product-requirements/scripts/validate-requirement-clarification.mjs +9 -0
  217. package/skills/codebase-scout/SKILL.md +1 -1
  218. package/skills/fe-test-ui-scout/SKILL.md +65 -0
  219. package/skills/fe-test-ui-scout/references/ledger-schema.md +62 -0
  220. package/skills/fe-test-ui-scout/references/recon-protocol.md +54 -0
  221. package/skills/frontend-design-review/references/review-checklist.md +2 -0
  222. package/skills/frontend-implementation/references/design-spec.md +16 -19
  223. package/skills/frontend-implementation/references/node-contracts.md +9 -6
  224. package/skills/improve-codebase-architecture/SKILL.md +81 -0
  225. package/skills/improve-codebase-architecture/deepening.md +37 -0
  226. package/skills/improve-codebase-architecture/html-report.md +123 -0
  227. package/skills/improve-codebase-architecture/interface-design.md +44 -0
  228. package/skills/improve-codebase-architecture/language.md +53 -0
  229. package/skills/loop-agent/references/command-reference.md +11 -4
  230. package/skills/playwright-cli/SKILL.md +22 -0
  231. package/skills/playwright-cli-case-generator/SKILL.md +20 -1
  232. package/dist/worker/console/static/assets/channel-DLVz9TYW.js +0 -1
  233. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-BeAuGcO9.js +0 -1
  234. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-BeAuGcO9.js +0 -1
  235. package/dist/worker/console/static/assets/index-DQxiso1w.js +0 -325
  236. package/dist/worker/console/static/assets/index-_j0Hzo69.css +0 -1
  237. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DzKvfHTX.js +0 -1
  238. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-Cxqr_qM1.js +0 -8
@@ -0,0 +1,227 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ export const FRONTEND_TEST_CONTEXT_REL = "testcase/frontend/rag/context.md";
5
+ export const FRONTEND_TEST_PROBE_REL = "testcase/frontend/rag/environment-probe.json";
6
+ const CONNECTION_REFUSED_HINT = "被测服务未在 baseUrl 监听(connection refused)。请先启动本地前端(例如 scripts/serve.sh 或项目约定的 npm start),确认可访问后再从 materialize-frontend-test-execution-shell 续跑。";
7
+ export function classifyFrontendBaseUrlProbeFailure(input) {
8
+ if (input.spawnError)
9
+ return { errorClass: "spawn-error" };
10
+ const exit = input.curlExit ?? 0;
11
+ if (exit === 7) {
12
+ return { errorClass: "connection-refused", hint: CONNECTION_REFUSED_HINT };
13
+ }
14
+ if (exit === 6) {
15
+ return {
16
+ errorClass: "dns-unresolved",
17
+ hint: "无法解析 baseUrl 主机名。确认 context.md 中的地址可在本机解析,或改用 127.0.0.1。",
18
+ };
19
+ }
20
+ if (exit === 28) {
21
+ return {
22
+ errorClass: "connect-timeout",
23
+ hint: "连接 baseUrl 超时。确认被测服务已启动且端口可访问。",
24
+ };
25
+ }
26
+ if (exit !== 0)
27
+ return { errorClass: `curl-exit-${exit}` };
28
+ if (input.httpStatus && Number.isFinite(input.httpStatus)) {
29
+ return { errorClass: `http-${input.httpStatus}` };
30
+ }
31
+ return { errorClass: "unreachable" };
32
+ }
33
+ function defaultCurlRunner(args) {
34
+ const result = spawnSync("curl", [...args], { encoding: "utf8" });
35
+ return {
36
+ status: result.status,
37
+ stdout: result.stdout ?? "",
38
+ stderr: result.stderr ?? "",
39
+ error: result.error,
40
+ };
41
+ }
42
+ function redactUrl(value) {
43
+ try {
44
+ const parsed = new URL(value);
45
+ parsed.username = "";
46
+ parsed.password = "";
47
+ parsed.search = "";
48
+ return parsed.toString();
49
+ }
50
+ catch {
51
+ return value.replace(/\/\/[^@\s]+@/g, "//");
52
+ }
53
+ }
54
+ function resolveBaseUrl(context) {
55
+ const patterns = [
56
+ /baseUrl\s*[:=]\s*["'`]?((?:https?):\/\/[^\s"'`<>]+)/i,
57
+ /base[-_ ]url\s*[:=]\s*["'`]?((?:https?):\/\/[^\s"'`<>]+)/i,
58
+ /playwright-cli open --browser=chrome\s+((?:https?):\/\/[^\s"'`<>]+)/i,
59
+ /(https?:\/\/(?:localhost|127\.0\.0\.1)[^\s)}\],"']*)/i,
60
+ ];
61
+ for (const pattern of patterns) {
62
+ const match = context.match(pattern);
63
+ if (match?.[1])
64
+ return match[1].replace(/[)}\],"'.`]+$/g, "");
65
+ }
66
+ return null;
67
+ }
68
+ async function writeProbe(workspaceRoot, probe) {
69
+ const probePath = path.join(workspaceRoot, FRONTEND_TEST_PROBE_REL);
70
+ const contextPath = path.join(workspaceRoot, FRONTEND_TEST_CONTEXT_REL);
71
+ await mkdir(path.dirname(probePath), { recursive: true });
72
+ await writeFile(probePath, `${JSON.stringify(probe, null, 2)}\n`, "utf8");
73
+ let context = "";
74
+ try {
75
+ context = await readFile(contextPath, "utf8");
76
+ }
77
+ catch {
78
+ context = "";
79
+ }
80
+ const line = `environmentProbe: ${probe.status}${probe.blockedReason ? ` (${probe.blockedReason})` : ""}`;
81
+ const next = /environmentProbe\s*[:=]/i.test(context)
82
+ ? context.replace(/environmentProbe\s*[:=]\s*.*/i, line)
83
+ : `${context.trimEnd()}\n\n${line}\n`;
84
+ await writeFile(contextPath, next, "utf8");
85
+ }
86
+ export async function probeFrontendTestEnvironment(input) {
87
+ const curl = input.curl ?? defaultCurlRunner;
88
+ const contextPath = path.join(input.workspaceRoot, FRONTEND_TEST_CONTEXT_REL);
89
+ let context;
90
+ try {
91
+ context = await readFile(contextPath, "utf8");
92
+ }
93
+ catch {
94
+ return { ok: false, stderr: `missing ${FRONTEND_TEST_CONTEXT_REL}` };
95
+ }
96
+ const rawUrl = resolveBaseUrl(context);
97
+ if (!rawUrl) {
98
+ return {
99
+ ok: false,
100
+ stderr: "frontend-test preflight missing absolute baseUrl from context.md",
101
+ };
102
+ }
103
+ let parsed;
104
+ try {
105
+ parsed = new URL(rawUrl);
106
+ }
107
+ catch {
108
+ return { ok: false, stderr: `baseUrl must be absolute http(s): ${rawUrl}` };
109
+ }
110
+ if ((parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
111
+ parsed.username ||
112
+ parsed.password ||
113
+ parsed.search ||
114
+ parsed.hash) {
115
+ return {
116
+ ok: false,
117
+ stderr: `unsafe baseUrl from context.md: ${redactUrl(rawUrl)}`,
118
+ };
119
+ }
120
+ if (/(?:^|\.)(?:www\.)?[^.]*?(?:prod|production)/i.test(parsed.hostname)) {
121
+ return {
122
+ ok: false,
123
+ stderr: `production URL forbidden: ${redactUrl(rawUrl)}`,
124
+ };
125
+ }
126
+ const baseUrl = parsed.toString();
127
+ const safe = redactUrl(baseUrl);
128
+ const sourceMatch = context.match(/baseUrlSource\s*[:=]\s*([^\r\n]+)/i);
129
+ const baseUrlSource = sourceMatch ? sourceMatch[1].trim() : "context.md";
130
+ const failBlocked = async (reason, extra) => {
131
+ const probe = {
132
+ status: "unreachable",
133
+ blockedReason: reason,
134
+ baseUrlRedacted: extra.baseUrlRedacted ?? safe,
135
+ httpStatus: extra.httpStatus ?? null,
136
+ method: extra.method ?? null,
137
+ curlExit: extra.curlExit ?? null,
138
+ errorClass: extra.errorClass ?? null,
139
+ ...(extra.hint ? { hint: extra.hint } : {}),
140
+ ...(extra.baseUrlSource ? { baseUrlSource: extra.baseUrlSource } : {}),
141
+ };
142
+ await writeProbe(input.workspaceRoot, probe);
143
+ const payload = {
144
+ blockedReason: reason,
145
+ baseUrl: probe.baseUrlRedacted,
146
+ httpStatus: probe.httpStatus,
147
+ errorClass: probe.errorClass,
148
+ ...(probe.hint ? { hint: probe.hint } : {}),
149
+ };
150
+ return {
151
+ ok: false,
152
+ stderr: `frontend-test preflight blocked: ${JSON.stringify(payload)}`,
153
+ probe,
154
+ };
155
+ };
156
+ const curlCheck = curl(["--version"]);
157
+ if (curlCheck.error || curlCheck.status !== 0) {
158
+ return failBlocked("curl-unavailable", {
159
+ baseUrlRedacted: safe,
160
+ errorClass: "curl-missing",
161
+ });
162
+ }
163
+ const probeMethod = (method) => curl([
164
+ "-sS",
165
+ "-o",
166
+ "/dev/null",
167
+ "-w",
168
+ "%{http_code}",
169
+ "--connect-timeout",
170
+ "3",
171
+ "--max-time",
172
+ "8",
173
+ "-X",
174
+ method,
175
+ "-L",
176
+ "--max-redirs",
177
+ "3",
178
+ "--http1.1",
179
+ "--proto-redir",
180
+ "=http,https",
181
+ safe,
182
+ ]);
183
+ let used = "HEAD";
184
+ let result = probeMethod("HEAD");
185
+ let code = String(result.stdout || "").trim();
186
+ let statusNum = Number.parseInt(code, 10);
187
+ const headRejected = result.status !== 0 || !statusNum || statusNum === 405 || statusNum === 501;
188
+ if (headRejected) {
189
+ used = "GET";
190
+ result = probeMethod("GET");
191
+ code = String(result.stdout || "").trim();
192
+ statusNum = Number.parseInt(code, 10);
193
+ }
194
+ const ok = statusNum >= 200 && statusNum < 400;
195
+ if (!ok) {
196
+ const classified = classifyFrontendBaseUrlProbeFailure({
197
+ spawnError: Boolean(result.error),
198
+ curlExit: result.status,
199
+ httpStatus: Number.isFinite(statusNum) ? statusNum : null,
200
+ });
201
+ return failBlocked("frontend-base-url-unreachable", {
202
+ baseUrlRedacted: safe,
203
+ httpStatus: Number.isFinite(statusNum) ? statusNum : null,
204
+ method: used,
205
+ curlExit: result.status,
206
+ errorClass: classified.errorClass,
207
+ hint: classified.hint,
208
+ });
209
+ }
210
+ const probe = {
211
+ status: "reachable",
212
+ blockedReason: null,
213
+ baseUrl: safe,
214
+ baseUrlRedacted: safe,
215
+ baseUrlSource,
216
+ httpStatus: statusNum,
217
+ method: used,
218
+ curlExit: result.status,
219
+ errorClass: null,
220
+ };
221
+ await writeProbe(input.workspaceRoot, probe);
222
+ return {
223
+ ok: true,
224
+ stdout: `frontend-test-execution-v1 validated contextBaseUrl=${safe} source=${baseUrlSource} probe=reachable method=${used} httpStatus=${statusNum}`,
225
+ probe,
226
+ };
227
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Bounded ATX section/list parsing for frontend-test case Markdown.
3
+ * Generator cases use `##` headings; older fixtures use `###`.
4
+ */
5
+ const ATX_HEADING = /^(#{1,3})\s+(\S.*)$/;
6
+ function headingMatches(title, names) {
7
+ const normalized = title.trim();
8
+ return names.some((name) => {
9
+ if (normalized === name)
10
+ return true;
11
+ return (normalized.startsWith(name) &&
12
+ (normalized.length === name.length ||
13
+ /[\s::与((-]/.test(normalized.charAt(name.length))));
14
+ });
15
+ }
16
+ export function markdownSection(body, names) {
17
+ const lines = body.replace(/\r\n/g, "\n").split("\n");
18
+ let start = -1;
19
+ let startLevel = 0;
20
+ for (let index = 0; index < lines.length; index += 1) {
21
+ const match = ATX_HEADING.exec(lines[index]);
22
+ if (!match)
23
+ continue;
24
+ if (!headingMatches(match[2], names))
25
+ continue;
26
+ start = index;
27
+ startLevel = match[1].length;
28
+ break;
29
+ }
30
+ if (start < 0)
31
+ return "";
32
+ const collected = [];
33
+ for (let index = start + 1; index < lines.length; index += 1) {
34
+ const match = ATX_HEADING.exec(lines[index]);
35
+ if (match && match[1].length <= startLevel)
36
+ break;
37
+ collected.push(lines[index]);
38
+ }
39
+ return collected.join("\n").trim();
40
+ }
41
+ export function markdownList(value) {
42
+ const items = [];
43
+ for (const raw of value.replace(/\r\n/g, "\n").split("\n")) {
44
+ const listed = raw.match(/^\s*(?:\d+[.)]|[-*+])\s+(.*)$/);
45
+ if (listed) {
46
+ const text = listed[1].trim();
47
+ if (text)
48
+ items.push(text);
49
+ continue;
50
+ }
51
+ const continuation = raw.match(/^\s{2,}(\S.*)$/);
52
+ if (continuation && items.length > 0) {
53
+ items[items.length - 1] = `${items[items.length - 1]} ${continuation[1].trim()}`;
54
+ }
55
+ }
56
+ return items;
57
+ }
58
+ export function markdownH1Title(body) {
59
+ const match = /^#\s+(\S.*)$/m.exec(body.replace(/\r\n/g, "\n"));
60
+ return match?.[1]?.trim() ?? "";
61
+ }
@@ -5,7 +5,27 @@ import { z } from "zod";
5
5
  import { writeDagRunJsonArtifact } from "../../infrastructure/harness/artifact-store.js";
6
6
  import { readPlaywrightCliReceipts, summarizePlaywrightCliReceipts, } from "../../executors/pi-playwright-cli-tool.js";
7
7
  import { validateFrontendCaseContent } from "./frontend-test-case-quality.js";
8
+ import { markdownH1Title, markdownList, markdownSection, } from "./frontend-test-markdown.js";
8
9
  export const FRONTEND_TEST_RESULT_SCHEMA_ID = "frontend-test-result-v1";
10
+ /**
11
+ * Truncated acceptance-id shapes observed in generator output: a bare
12
+ * numeric segment ("AC-002" dropped its AC-FE-<FEATURE> prefix) or an
13
+ * AC-FE-<FEATURE> literal without the trailing -NNN sequence number
14
+ * ("AC-FE-CHAT"). Generic short ids like "AC-1"/"AC-FE-001" stay valid;
15
+ * declared-source cross-checks (unknown-ac) remain the primary guard when a
16
+ * sourceBinding exists.
17
+ */
18
+ export function isTruncatedFrontendAcId(acId) {
19
+ return /^AC-FE-[A-Z]+$/i.test(acId.trim());
20
+ }
21
+ /**
22
+ * Bare numeric ids ("AC-002") only indicate truncation when no declared AC
23
+ * list exists to adjudicate them: with a sourceBinding they are ordinary
24
+ * unknown-ac findings ("AC-999" may be a legitimately declared id style).
25
+ */
26
+ export function isTruncatedWhenUndeclaredFrontendAcId(acId) {
27
+ return /^AC-\d{3}$/i.test(acId.trim()) || isTruncatedFrontendAcId(acId);
28
+ }
9
29
  const safeRelativePathSchema = z.string().min(1).refine((value) => !path.posix.isAbsolute(value) &&
10
30
  !path.win32.isAbsolute(value) &&
11
31
  !value.includes("\\") &&
@@ -56,6 +76,8 @@ export const frontendTestResultContractSchema = z.object({
56
76
  passed: z.number().int().min(0),
57
77
  failed: z.number().int().min(0),
58
78
  blocked: z.number().int().min(0),
79
+ /** Stability metric: passed on attempt 0 (no rerun/self-heal retry). */
80
+ firstTryPassed: z.number().int().min(0).optional(),
59
81
  }).strict(),
60
82
  acceptanceCoverage: z.object({
61
83
  covered: z.array(z.string().min(1)),
@@ -109,7 +131,13 @@ export async function validateFrontendCaseEvidence(input) {
109
131
  catch {
110
132
  throw new Error("missing testcase/frontend/cases/manifest.json");
111
133
  }
112
- const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
134
+ let manifest;
135
+ try {
136
+ manifest = JSON.parse(await readFile(manifestPath, "utf8"));
137
+ }
138
+ catch {
139
+ throw new Error("invalid frontend case manifest: malformed JSON");
140
+ }
113
141
  if (!Array.isArray(manifest.cases))
114
142
  throw new Error("invalid frontend case manifest");
115
143
  const statuses = new Set(["passed", "failed", "blocked"]);
@@ -194,11 +222,17 @@ export function evaluatePassedCaseBrowserReceipts(summary, caseId) {
194
222
  }
195
223
  return { ok: true };
196
224
  }
197
- async function loadCaseBrowserReceiptSummary(input) {
225
+ async function loadCaseBrowserReceiptsWithSummary(input) {
198
226
  try {
199
227
  const entries = await readdir(input.runDir, { withFileTypes: true });
200
228
  // A passed chain must belong to exactly one browser child stream. Never
201
- // stitch receipts across DAG nodes or accept ambiguous duplicate authority.
229
+ // stitch receipts across DAG nodes or accept ambiguous duplicate
230
+ // authority — with one exception: bounded rerun creates a second,
231
+ // later stream for the same case (the rerun overwrites the disk
232
+ // case-result, so the final attempt is the authoritative one). When
233
+ // multiple streams authorize, prefer the highest rerun round
234
+ // (rerun-frontend-case-rN-<idx> directories); ties within the same
235
+ // round remain ambiguous and fail closed.
202
236
  const authorizingStreams = [];
203
237
  for (const entry of entries) {
204
238
  if (!entry.isDirectory())
@@ -206,48 +240,121 @@ async function loadCaseBrowserReceiptSummary(input) {
206
240
  const nodeReceipts = (await readPlaywrightCliReceipts(input.runDir, entry.name))
207
241
  .filter((item) => item.caseId === input.caseId);
208
242
  const summary = summarizePlaywrightCliReceipts(nodeReceipts);
209
- if (summary.hasOrderedPassedReceiptChain)
210
- authorizingStreams.push(summary);
243
+ if (summary.hasOrderedPassedReceiptChain) {
244
+ const roundMatch = /rerun-frontend-case-r(\d+)-/i.exec(entry.name);
245
+ authorizingStreams.push({
246
+ receipts: nodeReceipts,
247
+ summary,
248
+ round: roundMatch ? Number(roundMatch[1]) : 0,
249
+ });
250
+ }
251
+ }
252
+ if (authorizingStreams.length === 0) {
253
+ return { receipts: [], summary: summarizePlaywrightCliReceipts([]) };
211
254
  }
212
- return authorizingStreams.length === 1
213
- ? authorizingStreams[0]
214
- : summarizePlaywrightCliReceipts([]);
255
+ const maxRound = Math.max(...authorizingStreams.map((s) => s.round));
256
+ const finalists = authorizingStreams.filter((s) => s.round === maxRound);
257
+ return finalists.length === 1
258
+ ? finalists[0]
259
+ : { receipts: [], summary: summarizePlaywrightCliReceipts([]) };
215
260
  }
216
261
  catch {
217
- return summarizePlaywrightCliReceipts([]);
262
+ return { receipts: [], summary: summarizePlaywrightCliReceipts([]) };
218
263
  }
219
264
  }
220
- function sha256(content) {
221
- return createHash("sha256").update(content).digest("hex");
265
+ /**
266
+ * Extract `find "literal" >=N` / `find "literal"` declarations from a case
267
+ * document. Only quoted literals are binding assertions — the count suffix
268
+ * (`>=N`, `==N`) is the expected minimum/exact match count from the ledger.
269
+ * Placeholder literals ("...", "…", "<字面>", "eX") appear in prose discussing
270
+ * the syntax itself; they are documentation, never declarations.
271
+ */
272
+ export function extractDeclaredFindAssertions(caseMarkdown) {
273
+ const source = stripFencedCodeBlocks(caseMarkdown);
274
+ const out = [];
275
+ const seen = new Set();
276
+ const pattern = /find\s+"((?:[^"\\]|\\.)+)"\s*(>=|==)\s*(\d+)/g;
277
+ const isPlaceholder = (literal) => /^(?:\.{2,}|…+|<[^>]*>|e\d+)$/.test(literal.trim());
278
+ let match;
279
+ while ((match = pattern.exec(source)) !== null) {
280
+ const literal = match[1].replace(/\\(.)/g, "$1");
281
+ if (isPlaceholder(literal))
282
+ continue;
283
+ const operator = match[2];
284
+ const expected = Number(match[3]);
285
+ const key = `${operator}:${literal}`;
286
+ if (seen.has(key))
287
+ continue;
288
+ seen.add(key);
289
+ out.push({ literal, expected, operator });
290
+ }
291
+ return out;
222
292
  }
223
- function markdownSection(body, names) {
224
- const escaped = names.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
225
- const marker = new RegExp(`^###\\s+(?:${escaped})\\s*$`, "mi");
226
- const hit = marker.exec(body);
227
- if (!hit)
293
+ function stripFencedCodeBlocks(markdown) {
294
+ return markdown.replace(/```[\s\S]*?```/g, "");
295
+ }
296
+ async function readCaseMarkdownForAudit(workspaceRoot, casePath) {
297
+ try {
298
+ return await readFile(path.join(workspaceRoot, casePath), "utf8");
299
+ }
300
+ catch {
228
301
  return "";
229
- const rest = body.slice(hit.index + hit[0].length);
230
- const next = /^###\s+/m.exec(rest);
231
- return rest.slice(0, next?.index ?? rest.length).trim();
302
+ }
303
+ }
304
+ /**
305
+ * Controller-side assertion audit: every find-family declaration in the case
306
+ * document must be backed by a successful find receipt whose parsed match
307
+ * count satisfies the declared operator. This moves pass authority from
308
+ * "the model says it asserted" to "receipts prove it asserted".
309
+ */
310
+ export function auditDeclaredFindAssertions(declared, receipts) {
311
+ if (declared.length === 0)
312
+ return { ok: true };
313
+ const successfulFinds = receipts.filter((receipt) => (receipt.command === "find" || receipt.command === "find-unique") &&
314
+ receipt.exitCode === 0 &&
315
+ !receipt.timedOut);
316
+ const missing = [];
317
+ for (const decl of declared) {
318
+ const backing = successfulFinds.some((receipt) => receipt.argsRedacted.some((arg) => arg === decl.literal || arg === `"${decl.literal}"`) &&
319
+ typeof receipt.matchCount === "number" &&
320
+ (decl.operator === ">="
321
+ ? receipt.matchCount >= decl.expected
322
+ : receipt.matchCount === decl.expected));
323
+ if (!backing) {
324
+ missing.push(`${decl.operator}${decl.expected} "${decl.literal.slice(0, 40)}"`);
325
+ }
326
+ }
327
+ if (missing.length > 0) {
328
+ return {
329
+ ok: false,
330
+ reason: "assertion-not-receipt-backed",
331
+ missing,
332
+ };
333
+ }
334
+ return { ok: true };
232
335
  }
233
- function markdownList(value) {
234
- const items = value.split(/\r?\n/).map((line) => line.replace(/^\s*(?:\d+[.)]|[-*+])\s+/, "").trim()).filter(Boolean);
235
- return items;
336
+ function sha256(content) {
337
+ return createHash("sha256").update(content).digest("hex");
236
338
  }
237
339
  /**
238
340
  * Read planned case facts from the case Markdown. Recognizes both English and
239
- * Chinese headings, including `测试点` (test points) and `测试步骤` (planned
240
- * test steps), alongside the legacy `操作步骤`/`Steps` headings.
341
+ * Chinese headings at `##` or `###`, including `测试点` (test points) and
342
+ * `测试步骤` (planned test steps), alongside the legacy `操作步骤`/`Steps`
343
+ * headings. Numbered and bulleted lists are extracted; a missing 测试目的
344
+ * falls back to the case H1 title.
241
345
  */
242
346
  async function readFrontendCaseContent(workspaceRoot, casePath) {
243
347
  if (!safeRelativePathSchema.safeParse(casePath).success || !casePath.startsWith("testcase/frontend/cases/")) {
244
348
  throw new Error(`unsafe frontend case path: ${casePath}`);
245
349
  }
246
350
  const body = await readFile(path.resolve(workspaceRoot, casePath), "utf8");
351
+ const purpose = markdownSection(body, ["Test Purpose", "测试目的", "测试场景"]) ||
352
+ markdownH1Title(body).replace(/^FE-[^\s::]+[::]\s*/, "").trim() ||
353
+ "未提供测试目的";
247
354
  return {
248
355
  caseContent: {
249
- purpose: markdownSection(body, ["Test Purpose", "测试目的", "测试场景"]) || "未提供测试目的",
250
- preconditions: markdownList(markdownSection(body, ["Preconditions", "前置条件"])),
356
+ purpose,
357
+ preconditions: markdownList(markdownSection(body, ["Preconditions", "前置条件", "前置条件与重置"])),
251
358
  steps: markdownList(markdownSection(body, ["Test Steps", "测试步骤", "Steps", "操作步骤"])),
252
359
  expectedResults: markdownList(markdownSection(body, ["Expected Results", "预期结果"])),
253
360
  },
@@ -332,7 +439,13 @@ export async function materializeFrontendTestResult(input) {
332
439
  }
333
440
  const manifestPath = "testcase/frontend/cases/manifest.json";
334
441
  const manifestAbsolute = path.resolve(input.workspaceRoot, manifestPath);
335
- const manifest = JSON.parse(await readFile(manifestAbsolute, "utf-8"));
442
+ let manifest;
443
+ try {
444
+ manifest = JSON.parse(await readFile(manifestAbsolute, "utf-8"));
445
+ }
446
+ catch {
447
+ throw new Error("invalid or empty frontend case manifest: malformed JSON");
448
+ }
336
449
  if (manifest.schemaVersion !== 1 || !Array.isArray(manifest.cases) || manifest.cases.length === 0) {
337
450
  throw new Error("invalid or empty frontend case manifest");
338
451
  }
@@ -395,7 +508,7 @@ export async function materializeFrontendTestResult(input) {
395
508
  advisoryFindings.push({ ruleId: "passed-without-browser-evidence", caseId: item.caseId, detail: "passed case has no browser evidence" });
396
509
  let blockedReason = status === "blocked" && typeof resultRaw.blockedReason === "string" && resultRaw.blockedReason.trim() ? resultRaw.blockedReason.trim() : undefined;
397
510
  if (status === "passed") {
398
- const receiptSummary = await loadCaseBrowserReceiptSummary({
511
+ const { receipts: caseReceipts, summary: receiptSummary } = await loadCaseBrowserReceiptsWithSummary({
399
512
  runDir: input.runDir,
400
513
  caseId: item.caseId,
401
514
  });
@@ -409,6 +522,24 @@ export async function materializeFrontendTestResult(input) {
409
522
  detail: "passed case lacks controller-owned open + interaction/assertion + cleanup receipts",
410
523
  });
411
524
  }
525
+ else {
526
+ // Controller-side assertion audit: every `find "literal" >=N` declared
527
+ // in the case document must be backed by a successful find receipt
528
+ // whose parsed matchCount satisfies the operator. Pass authority moves
529
+ // from model prose to receipt evidence.
530
+ const caseMarkdown = await readCaseMarkdownForAudit(input.workspaceRoot, item.casePath);
531
+ const declared = extractDeclaredFindAssertions(caseMarkdown);
532
+ const audit = auditDeclaredFindAssertions(declared, caseReceipts);
533
+ if (!audit.ok) {
534
+ status = "blocked";
535
+ blockedReason = audit.reason;
536
+ advisoryFindings.push({
537
+ ruleId: "assertion-not-receipt-backed",
538
+ caseId: item.caseId,
539
+ detail: `declared find assertions without receipt backing: ${audit.missing.join("; ")}`,
540
+ });
541
+ }
542
+ }
412
543
  }
413
544
  const { caseContent, testPoints } = await readFrontendCaseContent(input.workspaceRoot, item.casePath);
414
545
  // Parse bounded fixed sections from execution.md. Missing optional
@@ -459,6 +590,7 @@ export async function materializeFrontendTestResult(input) {
459
590
  passed: cases.filter((item) => item.status === "passed").length,
460
591
  failed: cases.filter((item) => item.status === "failed").length,
461
592
  blocked: cases.filter((item) => item.status === "blocked").length,
593
+ firstTryPassed: cases.filter((item) => item.status === "passed" && !item.rerunAttempt).length,
462
594
  };
463
595
  const outcome = totals.failed > 0 ? "failed" : totals.blocked > 0 || missing.length > 0 ? "incomplete" : "passed";
464
596
  const result = frontendTestResultContractSchema.parse({
@@ -0,0 +1,68 @@
1
+ import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ export const FRONTEND_TEST_STANDARD_SCENARIOS_FILENAME = "frontend-test-standard-scenarios.v1.json";
4
+ export const FRONTEND_TEST_STANDARD_SCENARIOS_DEST = "testcase/frontend/rag/standard-scenarios.v1.json";
5
+ const MINIMAL_STANDARD_SCENARIOS = {
6
+ schemaVersion: 1,
7
+ id: "frontend-test-standard-scenarios-v1",
8
+ scenarios: [
9
+ {
10
+ id: "STD-FE-SMOKE-ENTRY",
11
+ title: "入口可打开",
12
+ category: "smoke",
13
+ priority: "must",
14
+ testPoints: ["open"],
15
+ minCases: 1,
16
+ },
17
+ ],
18
+ };
19
+ function isSafeRelativeDir(value) {
20
+ if (!value || path.isAbsolute(value) || path.win32.isAbsolute(value)) {
21
+ return false;
22
+ }
23
+ return value.split(/[\\/]/).every((part) => part.length > 0 && part !== "." && part !== "..");
24
+ }
25
+ export function listFrontendTestStandardScenarioCandidates(input) {
26
+ const seen = new Set();
27
+ const add = (relative) => {
28
+ const normalized = relative.replaceAll("\\", "/");
29
+ if (!seen.has(normalized))
30
+ seen.add(normalized);
31
+ };
32
+ add(`docs/templates/${FRONTEND_TEST_STANDARD_SCENARIOS_FILENAME}`);
33
+ const governanceRoot = input.governanceRoot?.trim().replaceAll("\\", "/").replace(/\/+$/, "");
34
+ if (governanceRoot && isSafeRelativeDir(governanceRoot)) {
35
+ add(`${governanceRoot}/templates/${FRONTEND_TEST_STANDARD_SCENARIOS_FILENAME}`);
36
+ }
37
+ add(`ai_workspace/loop-agent/templates/${FRONTEND_TEST_STANDARD_SCENARIOS_FILENAME}`);
38
+ return [...seen];
39
+ }
40
+ async function readGovernanceRoot(workspaceRoot) {
41
+ try {
42
+ const raw = JSON.parse(await readFile(path.join(workspaceRoot, "harness.json"), "utf8"));
43
+ return typeof raw.governanceRoot === "string" ? raw.governanceRoot : undefined;
44
+ }
45
+ catch {
46
+ return undefined;
47
+ }
48
+ }
49
+ export async function copyFrontendTestStandardScenarios(input) {
50
+ const destRel = FRONTEND_TEST_STANDARD_SCENARIOS_DEST;
51
+ const destAbs = path.join(input.workspaceRoot, destRel);
52
+ const candidates = listFrontendTestStandardScenarioCandidates({
53
+ governanceRoot: await readGovernanceRoot(input.workspaceRoot),
54
+ });
55
+ await mkdir(path.dirname(destAbs), { recursive: true });
56
+ for (const relative of candidates) {
57
+ const sourceAbs = path.join(input.workspaceRoot, relative);
58
+ try {
59
+ await copyFile(sourceAbs, destAbs);
60
+ return { status: "copied", from: relative, to: destRel };
61
+ }
62
+ catch {
63
+ // try the next candidate
64
+ }
65
+ }
66
+ await writeFile(destAbs, `${JSON.stringify(MINIMAL_STANDARD_SCENARIOS, null, 2)}\n`, "utf8");
67
+ return { status: "fallback", to: destRel };
68
+ }