@guanlin4924/devflow-pi 0.1.94 → 0.1.95

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/README.md +4 -4
  2. package/node_modules/@playwright/test/LICENSE +202 -0
  3. package/node_modules/@playwright/test/NOTICE +5 -0
  4. package/node_modules/@playwright/test/README.md +318 -0
  5. package/node_modules/@playwright/test/cli.js +19 -0
  6. package/node_modules/@playwright/test/index.d.ts +18 -0
  7. package/node_modules/@playwright/test/index.js +17 -0
  8. package/node_modules/@playwright/test/index.mjs +18 -0
  9. package/node_modules/@playwright/test/package.json +35 -0
  10. package/node_modules/@playwright/test/reporter.d.ts +17 -0
  11. package/node_modules/@playwright/test/reporter.js +17 -0
  12. package/node_modules/@playwright/test/reporter.mjs +17 -0
  13. package/node_modules/playwright/LICENSE +202 -0
  14. package/node_modules/playwright/NOTICE +5 -0
  15. package/node_modules/playwright/README.md +318 -0
  16. package/node_modules/playwright/ThirdPartyNotices.txt +14 -0
  17. package/node_modules/playwright/cli.js +19 -0
  18. package/node_modules/playwright/index.d.ts +17 -0
  19. package/node_modules/playwright/index.js +17 -0
  20. package/node_modules/playwright/index.mjs +18 -0
  21. package/node_modules/playwright/jsx-runtime.js +42 -0
  22. package/node_modules/playwright/jsx-runtime.mjs +21 -0
  23. package/node_modules/playwright/lib/agents/agentParser.js +89 -0
  24. package/node_modules/playwright/lib/agents/copilot-setup-steps.yml +34 -0
  25. package/node_modules/playwright/lib/agents/generateAgents.js +395 -0
  26. package/node_modules/playwright/lib/agents/playwright-test-coverage.prompt.md +31 -0
  27. package/node_modules/playwright/lib/agents/playwright-test-generate.prompt.md +8 -0
  28. package/node_modules/playwright/lib/agents/playwright-test-generator.agent.md +88 -0
  29. package/node_modules/playwright/lib/agents/playwright-test-heal.prompt.md +6 -0
  30. package/node_modules/playwright/lib/agents/playwright-test-healer.agent.md +56 -0
  31. package/node_modules/playwright/lib/agents/playwright-test-plan.prompt.md +9 -0
  32. package/node_modules/playwright/lib/agents/playwright-test-planner.agent.md +74 -0
  33. package/node_modules/playwright/lib/cli/reportActions.js +78 -0
  34. package/node_modules/playwright/lib/cli/testActions.js +214 -0
  35. package/node_modules/playwright/lib/common/index.js +2923 -0
  36. package/node_modules/playwright/lib/common/index.js.txt +34 -0
  37. package/node_modules/playwright/lib/errorContext.js +129 -0
  38. package/node_modules/playwright/lib/globals.js +58 -0
  39. package/node_modules/playwright/lib/index.js +815 -0
  40. package/node_modules/playwright/lib/isomorphic.js +260 -0
  41. package/node_modules/playwright/lib/isomorphic.js.txt +9 -0
  42. package/node_modules/playwright/lib/loader/loaderProcessEntry.js +34 -0
  43. package/node_modules/playwright/lib/loader/loaderProcessEntry.js.txt +9 -0
  44. package/node_modules/playwright/lib/matchers/expect.js +13490 -0
  45. package/node_modules/playwright/lib/matchers/expect.js.LICENSE +640 -0
  46. package/node_modules/playwright/lib/matchers/expect.js.txt +67 -0
  47. package/node_modules/playwright/lib/mcp/test/browserBackend.js +135 -0
  48. package/node_modules/playwright/lib/mcp/test/generatorTools.js +122 -0
  49. package/node_modules/playwright/lib/mcp/test/plannerTools.js +150 -0
  50. package/node_modules/playwright/lib/mcp/test/seed.js +82 -0
  51. package/node_modules/playwright/lib/mcp/test/streams.js +44 -0
  52. package/node_modules/playwright/lib/mcp/test/testBackend.js +99 -0
  53. package/node_modules/playwright/lib/mcp/test/testContext.js +306 -0
  54. package/node_modules/playwright/lib/mcp/test/testTool.js +30 -0
  55. package/node_modules/playwright/lib/mcp/test/testTools.js +98 -0
  56. package/node_modules/playwright/lib/package.js +47 -0
  57. package/node_modules/playwright/lib/program.js +241 -0
  58. package/node_modules/playwright/lib/runner/index.js +8527 -0
  59. package/node_modules/playwright/lib/runner/index.js.txt +61 -0
  60. package/node_modules/playwright/lib/transform/babelBundle.js +72274 -0
  61. package/node_modules/playwright/lib/transform/babelBundle.js.LICENSE +2384 -0
  62. package/node_modules/playwright/lib/transform/babelBundle.js.txt +325 -0
  63. package/node_modules/playwright/lib/transform/esmLoader.js +8054 -0
  64. package/node_modules/playwright/lib/transform/esmLoader.js.LICENSE +355 -0
  65. package/node_modules/playwright/lib/transform/esmLoader.js.txt +60 -0
  66. package/node_modules/playwright/lib/util.js +373 -0
  67. package/node_modules/playwright/lib/worker/workerProcessEntry.js +1861 -0
  68. package/node_modules/playwright/lib/worker/workerProcessEntry.js.txt +19 -0
  69. package/node_modules/playwright/package.json +55 -0
  70. package/node_modules/playwright/test.d.ts +18 -0
  71. package/node_modules/playwright/test.js +24 -0
  72. package/node_modules/playwright/test.mjs +35 -0
  73. package/node_modules/playwright/types/test.d.ts +11074 -0
  74. package/node_modules/playwright/types/testReporter.d.ts +950 -0
  75. package/node_modules/playwright-core/LICENSE +202 -0
  76. package/node_modules/playwright-core/NOTICE +5 -0
  77. package/node_modules/playwright-core/README.md +3 -0
  78. package/node_modules/playwright-core/ThirdPartyNotices.txt +13 -0
  79. package/node_modules/playwright-core/bin/install_media_pack.ps1 +5 -0
  80. package/node_modules/playwright-core/bin/install_webkit_wsl.ps1 +33 -0
  81. package/node_modules/playwright-core/bin/reinstall_chrome_beta_linux.sh +42 -0
  82. package/node_modules/playwright-core/bin/reinstall_chrome_beta_mac.sh +13 -0
  83. package/node_modules/playwright-core/bin/reinstall_chrome_beta_win.ps1 +24 -0
  84. package/node_modules/playwright-core/bin/reinstall_chrome_stable_linux.sh +42 -0
  85. package/node_modules/playwright-core/bin/reinstall_chrome_stable_mac.sh +12 -0
  86. package/node_modules/playwright-core/bin/reinstall_chrome_stable_win.ps1 +24 -0
  87. package/node_modules/playwright-core/bin/reinstall_msedge_beta_linux.sh +48 -0
  88. package/node_modules/playwright-core/bin/reinstall_msedge_beta_mac.sh +11 -0
  89. package/node_modules/playwright-core/bin/reinstall_msedge_beta_win.ps1 +23 -0
  90. package/node_modules/playwright-core/bin/reinstall_msedge_dev_linux.sh +48 -0
  91. package/node_modules/playwright-core/bin/reinstall_msedge_dev_mac.sh +11 -0
  92. package/node_modules/playwright-core/bin/reinstall_msedge_dev_win.ps1 +23 -0
  93. package/node_modules/playwright-core/bin/reinstall_msedge_stable_linux.sh +48 -0
  94. package/node_modules/playwright-core/bin/reinstall_msedge_stable_mac.sh +11 -0
  95. package/node_modules/playwright-core/bin/reinstall_msedge_stable_win.ps1 +24 -0
  96. package/node_modules/playwright-core/browsers.json +52 -0
  97. package/node_modules/playwright-core/cli.js +21 -0
  98. package/node_modules/playwright-core/index.d.ts +17 -0
  99. package/node_modules/playwright-core/index.js +17 -0
  100. package/node_modules/playwright-core/index.mjs +28 -0
  101. package/node_modules/playwright-core/lib/bootstrap.js +88 -0
  102. package/node_modules/playwright-core/lib/coreBundle.js +75813 -0
  103. package/node_modules/playwright-core/lib/entry/cliDaemon.js +5 -0
  104. package/node_modules/playwright-core/lib/entry/dashboardApp.js +3 -0
  105. package/node_modules/playwright-core/lib/entry/mcp.js +10 -0
  106. package/node_modules/playwright-core/lib/entry/oopBrowserDownload.js +3 -0
  107. package/node_modules/playwright-core/lib/package.js +50 -0
  108. package/node_modules/playwright-core/lib/server/chromium/appIcon.png +0 -0
  109. package/node_modules/playwright-core/lib/server/electron/loader.js +116 -0
  110. package/node_modules/playwright-core/lib/serverRegistry.js +1939 -0
  111. package/node_modules/playwright-core/lib/serverRegistry.js.LICENSE +65 -0
  112. package/node_modules/playwright-core/lib/tools/cli-client/channelSessions.js +141 -0
  113. package/node_modules/playwright-core/lib/tools/cli-client/cli.js +6 -0
  114. package/node_modules/playwright-core/lib/tools/cli-client/help.json +721 -0
  115. package/node_modules/playwright-core/lib/tools/cli-client/minimist.js +128 -0
  116. package/node_modules/playwright-core/lib/tools/cli-client/output.js +351 -0
  117. package/node_modules/playwright-core/lib/tools/cli-client/program.js +418 -0
  118. package/node_modules/playwright-core/lib/tools/cli-client/registry.js +180 -0
  119. package/node_modules/playwright-core/lib/tools/cli-client/session.js +259 -0
  120. package/node_modules/playwright-core/lib/tools/dashboard/appIcon.png +0 -0
  121. package/node_modules/playwright-core/lib/tools/skills/playwright-cli/SKILL.md +425 -0
  122. package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/element-attributes.md +23 -0
  123. package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/playwright-tests.md +39 -0
  124. package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/request-mocking.md +87 -0
  125. package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/running-code.md +241 -0
  126. package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/session-management.md +225 -0
  127. package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/storage-state.md +275 -0
  128. package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/test-generation.md +433 -0
  129. package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/tracing.md +139 -0
  130. package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/video-recording.md +143 -0
  131. package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/SKILL.md +136 -0
  132. package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/gallery-spec.md +144 -0
  133. package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/migration.md +89 -0
  134. package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/react.md +69 -0
  135. package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/typing.md +173 -0
  136. package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/vue.md +77 -0
  137. package/node_modules/playwright-core/lib/tools/skills/playwright-trace/SKILL.md +174 -0
  138. package/node_modules/playwright-core/lib/tools/utils/extension.js +112 -0
  139. package/node_modules/playwright-core/lib/tools/utils/socketConnection.js +108 -0
  140. package/node_modules/playwright-core/lib/utilsBundle.js +85357 -0
  141. package/node_modules/playwright-core/lib/utilsBundle.js.LICENSE +1890 -0
  142. package/node_modules/playwright-core/lib/vite/dashboard/assets/codeMirrorModule--QdMvsKi.css +1 -0
  143. package/node_modules/playwright-core/lib/vite/dashboard/assets/codeMirrorModule-CQ8RZGBm.js +32 -0
  144. package/node_modules/playwright-core/lib/vite/dashboard/assets/codicon-DCmgc-ay.ttf +0 -0
  145. package/node_modules/playwright-core/lib/vite/dashboard/assets/firefox-1bWoP6pv.svg +1 -0
  146. package/node_modules/playwright-core/lib/vite/dashboard/assets/firefox-beta-k3eOH_eK.svg +1 -0
  147. package/node_modules/playwright-core/lib/vite/dashboard/assets/firefox-nightly-Cp5nfeDT.svg +1 -0
  148. package/node_modules/playwright-core/lib/vite/dashboard/assets/index-AwgwcYie.css +1 -0
  149. package/node_modules/playwright-core/lib/vite/dashboard/assets/index-hRvO4yU2.js +12 -0
  150. package/node_modules/playwright-core/lib/vite/dashboard/assets/safari-na3_-uQk.svg +1 -0
  151. package/node_modules/playwright-core/lib/vite/dashboard/index.html +29 -0
  152. package/node_modules/playwright-core/lib/vite/dashboard/playwright-logo.svg +24 -0
  153. package/node_modules/playwright-core/lib/vite/htmlReport/index.html +16 -0
  154. package/node_modules/playwright-core/lib/vite/htmlReport/report.css +2 -0
  155. package/node_modules/playwright-core/lib/vite/htmlReport/report.js +32 -0
  156. package/node_modules/playwright-core/lib/vite/recorder/assets/codeMirrorModule--QdMvsKi.css +1 -0
  157. package/node_modules/playwright-core/lib/vite/recorder/assets/codeMirrorModule-CVQWJZAA.js +32 -0
  158. package/node_modules/playwright-core/lib/vite/recorder/assets/codicon-DCmgc-ay.ttf +0 -0
  159. package/node_modules/playwright-core/lib/vite/recorder/assets/index-BZpYJZ-P.css +1 -0
  160. package/node_modules/playwright-core/lib/vite/recorder/assets/index-hrlLqLtq.js +129 -0
  161. package/node_modules/playwright-core/lib/vite/recorder/index.html +29 -0
  162. package/node_modules/playwright-core/lib/vite/recorder/playwright-logo.svg +9 -0
  163. package/node_modules/playwright-core/lib/vite/traceViewer/assets/codeMirrorModule-BbkfBe3n.js +32 -0
  164. package/node_modules/playwright-core/lib/vite/traceViewer/assets/defaultSettingsView-Ds6CBOo0.js +182 -0
  165. package/node_modules/playwright-core/lib/vite/traceViewer/assets/urlMatch-L3liM589.js +1 -0
  166. package/node_modules/playwright-core/lib/vite/traceViewer/assets/xtermModule-DywYcAf8.js +7 -0
  167. package/node_modules/playwright-core/lib/vite/traceViewer/codeMirrorModule.-QdMvsKi.css +1 -0
  168. package/node_modules/playwright-core/lib/vite/traceViewer/codicon.DCmgc-ay.ttf +0 -0
  169. package/node_modules/playwright-core/lib/vite/traceViewer/defaultSettingsView.Bqk9acqE.css +1 -0
  170. package/node_modules/playwright-core/lib/vite/traceViewer/index.B4cLoZK3.js +1 -0
  171. package/node_modules/playwright-core/lib/vite/traceViewer/index.B_TqY17P.css +1 -0
  172. package/node_modules/playwright-core/lib/vite/traceViewer/index.html +44 -0
  173. package/node_modules/playwright-core/lib/vite/traceViewer/manifest.webmanifest +16 -0
  174. package/node_modules/playwright-core/lib/vite/traceViewer/playwright-logo.svg +9 -0
  175. package/node_modules/playwright-core/lib/vite/traceViewer/snapshot.B_Jk1wbt.js +1 -0
  176. package/node_modules/playwright-core/lib/vite/traceViewer/snapshot.html +10 -0
  177. package/node_modules/playwright-core/lib/vite/traceViewer/sw.bundle.js +4 -0
  178. package/node_modules/playwright-core/lib/vite/traceViewer/uiMode.CU5KtEkS.js +5 -0
  179. package/node_modules/playwright-core/lib/vite/traceViewer/uiMode.CyMfwkXJ.css +1 -0
  180. package/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html +18 -0
  181. package/node_modules/playwright-core/lib/vite/traceViewer/xtermModule.kHJ-D0s7.css +1 -0
  182. package/node_modules/playwright-core/lib/webp_codec.LICENSE +173 -0
  183. package/node_modules/playwright-core/lib/webp_codec.wasm +0 -0
  184. package/node_modules/playwright-core/lib/xdg-open +1267 -0
  185. package/node_modules/playwright-core/package.json +34 -0
  186. package/node_modules/playwright-core/types/protocol.d.ts +24645 -0
  187. package/node_modules/playwright-core/types/structs.d.ts +52 -0
  188. package/node_modules/playwright-core/types/types.d.ts +26632 -0
  189. package/package.json +12 -2
  190. package/plugins/devflow-core/extensions/devflow-core.ts +431 -7
  191. package/plugins/devflow-core/runtime/hooks/hook_common.py +269 -0
  192. package/plugins/devflow-core/runtime/hooks/index_gate.py +29 -16
  193. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py +407 -71
  194. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +51 -0
  195. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/approval_pins.py +433 -0
  196. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/approval_store.py +137 -0
  197. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_coverage.py +87 -0
  198. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_v1.py +318 -0
  199. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/environment_profile.py +134 -0
  200. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +1 -0
  201. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_surface.py +138 -0
  202. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/playwright_adapter.py +515 -0
  203. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/tc_hash.py +132 -0
  204. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/test_command_policy.py +102 -8
  205. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +13 -0
  206. package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +13 -34
  207. package/plugins/devflow-core/skills/workflow-pipeline/SKILL.md +3 -1
  208. package/plugins/devflow-core/skills/workflow-pipeline/references/pipeline-flow.md +68 -140
  209. package/plugins/devflow-core/skills/workflow-project-context/scripts/validate_initialization_database.py +1 -1
  210. package/plugins/devflow-implementation/skills/workflow-execute-phase/references/execute-phase.md +3 -1
  211. package/plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py +23 -4
  212. package/plugins/devflow-quality/runtime/playwright/devflow-test-dsl-v1.schema.json +872 -0
  213. package/plugins/devflow-quality/runtime/playwright/playwright.config.mjs +46 -0
  214. package/plugins/devflow-quality/runtime/playwright/reporter.mjs +311 -0
  215. package/plugins/devflow-quality/runtime/playwright/runner.spec.mjs +1308 -0
  216. package/plugins/devflow-quality/runtime/playwright/runtime-manifest.json +11 -0
  217. package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +3 -0
  218. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/SKILL.md +71 -58
  219. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/api-mainflow-rules.md +20 -15
  220. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/codebase-discovery.md +3 -1
  221. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/intermediate-format.md +115 -192
  222. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/mainflow-selection.md +27 -23
  223. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/project-dsl-contract.md +78 -153
  224. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/ui-mainflow-rules.md +36 -83
  225. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/compile_dsl.py +497 -556
  226. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/export_legacy_testagent.py +647 -0
  227. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/normalize_case_payloads.py +10 -3
  228. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/validate_case_payloads.py +165 -275
  229. package/plugins/devflow-quality/skills/workflow-human-acceptance/SKILL.md +61 -0
  230. package/plugins/devflow-quality/skills/workflow-release-closeout/SKILL.md +4 -4
  231. package/plugins/devflow-quality/skills/workflow-run-acceptance-tests/SKILL.md +15 -9
  232. package/src/approval-store.ts +340 -0
  233. package/src/constants.ts +1 -1
  234. package/src/doctor.ts +259 -2
  235. package/src/initialization-store.ts +48 -1
  236. package/src/paths.ts +12 -1
  237. package/src/project-context.ts +24 -1
  238. package/templates/omp/hook-config.json +6 -0
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env python3
2
+ """Single implementation of frozen-TC execution_surface derivation and consistency rules.
3
+
4
+ execution_surface is a top-level field of the frozen TC node data (http | ui |
5
+ api_flow | project_test). Legacy TCs without the field are derived
6
+ conservatively; only the project_test fallback is marked legacy_unclassified.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import re
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+
17
+ SURFACES = ("http", "ui", "api_flow", "project_test")
18
+ FEATURE_RE = re.compile(r"^[^/\\\x00-\x1f\x7f]+$")
19
+ # Allowed binding kinds per surface (方案 §4.3). manual/blocked are modes, not adapters.
20
+ ADAPTERS_BY_SURFACE = {
21
+ "http": {"http"},
22
+ "ui": {"devflow_playwright"},
23
+ "api_flow": {"devflow_playwright"},
24
+ "project_test": {"command"},
25
+ }
26
+
27
+
28
+ def http_execution_error(source: dict[str, Any]) -> str | None:
29
+ execution = source.get("execution")
30
+ if not isinstance(execution, dict) or execution.get("protocol") != "http":
31
+ return "frozen TC has no HTTP execution definition"
32
+ request = execution.get("request")
33
+ expect = execution.get("expect")
34
+ if not isinstance(request, dict) or not isinstance(expect, dict):
35
+ return "frozen TC HTTP execution requires request and expect"
36
+ if not isinstance(request.get("method"), str) or not re.fullmatch(r"[A-Z]+", request["method"]):
37
+ return "frozen TC HTTP request method is invalid"
38
+ if not isinstance(request.get("path"), str) or not request["path"].startswith("/"):
39
+ return "frozen TC HTTP request path is invalid"
40
+ if not isinstance(expect.get("http_status"), int) or isinstance(expect.get("http_status"), bool) or not 100 <= expect["http_status"] <= 599:
41
+ return "frozen TC HTTP expectation http_status is invalid"
42
+ for key in ("path_params", "query", "headers", "body"):
43
+ if key in request and not isinstance(request[key], dict):
44
+ return f"frozen TC HTTP request {key} must be an object"
45
+ if "error_code" in expect and (not isinstance(expect["error_code"], str) or not expect["error_code"].strip()):
46
+ return "frozen TC HTTP expectation error_code is invalid"
47
+ if "error_code_pointer" in expect and (not isinstance(expect["error_code_pointer"], str) or not expect["error_code_pointer"].startswith("/")):
48
+ return "frozen TC HTTP expectation error_code_pointer is invalid"
49
+ return None
50
+
51
+
52
+ def _declares_http(data: dict[str, Any]) -> bool:
53
+ execution = data.get("execution")
54
+ return isinstance(execution, dict) and execution.get("protocol") == "http"
55
+
56
+
57
+ def derive(data: dict[str, Any]) -> tuple[str | None, bool]:
58
+ """Return (surface, legacy_unclassified); surface is None for an invalid explicit value."""
59
+ if "execution_surface" in data:
60
+ value = data.get("execution_surface")
61
+ return (value, False) if value in SURFACES else (None, False)
62
+ if _declares_http(data) and http_execution_error(data) is None:
63
+ return "http", False
64
+ if data.get("test_type") == "page":
65
+ return "ui", False
66
+ return "project_test", True
67
+
68
+
69
+ def consistency_errors(data: dict[str, Any], require_explicit: bool = False) -> list[str]:
70
+ errors: list[str] = []
71
+ explicit = "execution_surface" in data
72
+ if require_explicit and not explicit:
73
+ return ["execution_surface is required for a newly frozen TC"]
74
+ surface, _legacy = derive(data)
75
+ if surface is None:
76
+ return [f"execution_surface must be one of {', '.join(SURFACES)}"]
77
+ if _declares_http(data) and surface != "http":
78
+ errors.append("TC with execution.protocol=http must have execution_surface=http")
79
+ if surface == "http":
80
+ http_error = http_execution_error(data)
81
+ if http_error:
82
+ errors.append(f"execution_surface=http requires a valid execution.protocol=http definition ({http_error})")
83
+ if data.get("test_type") == "page":
84
+ if surface != "ui":
85
+ errors.append("test_type=page requires execution_surface=ui")
86
+ if _declares_http(data):
87
+ errors.append("test_type=page must not declare execution.protocol=http")
88
+ if surface in {"ui", "api_flow"} and "execution" in data:
89
+ errors.append(f"execution_surface={surface} must not carry an execution object")
90
+ return errors
91
+
92
+
93
+ def _test_cases(suite: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]:
94
+ cases: list[tuple[str, dict[str, Any]]] = []
95
+ for node in suite.get("nodes", []) if isinstance(suite.get("nodes"), list) else []:
96
+ if isinstance(node, dict) and node.get("kind") == "test_case":
97
+ cases.append((str(node.get("id")), node.get("data") if isinstance(node.get("data"), dict) else {}))
98
+ return cases
99
+
100
+
101
+ def check_suite(suite: dict[str, Any], require_explicit: bool = True) -> list[str]:
102
+ """Consistency errors for every TC; a new freeze requires the explicit field."""
103
+ errors: list[str] = []
104
+ for tc_id, data in _test_cases(suite):
105
+ errors.extend(f"{tc_id}: {error}" for error in consistency_errors(data, require_explicit=require_explicit))
106
+ return errors
107
+
108
+
109
+ def main() -> int:
110
+ parser = argparse.ArgumentParser()
111
+ parser.add_argument("action", choices=("check-suite",))
112
+ parser.add_argument("--feature", required=True)
113
+ parser.add_argument("--allow-legacy", action="store_true", help="derive missing fields instead of requiring them")
114
+ parser.add_argument("--json", action="store_true")
115
+ args = parser.parse_args()
116
+ root = Path.cwd().resolve()
117
+ if not FEATURE_RE.fullmatch(args.feature):
118
+ result: dict[str, Any] = {"ok": False, "reason": "execution_surface_mismatch", "errors": ["invalid feature"]}
119
+ else:
120
+ path = root / ".devflow/planning" / "requirements" / args.feature / "tests.json"
121
+ try:
122
+ suite = json.loads(path.read_text(encoding="utf-8"))
123
+ if not isinstance(suite, dict):
124
+ raise ValueError("TC suite root must be an object")
125
+ errors = check_suite(suite, require_explicit=not args.allow_legacy)
126
+ if not _test_cases(suite):
127
+ errors.append("TC suite contains no test_case nodes")
128
+ except (OSError, ValueError) as error:
129
+ errors = [f"cannot read TC suite: {error}"]
130
+ result = {"ok": not errors, "feature": args.feature, "errors": errors}
131
+ if errors:
132
+ result["reason"] = "execution_surface_mismatch"
133
+ print(json.dumps(result, ensure_ascii=False, indent=2))
134
+ return 0
135
+
136
+
137
+ if __name__ == "__main__":
138
+ raise SystemExit(main())
@@ -0,0 +1,515 @@
1
+ #!/usr/bin/env python3
2
+ """Core devflow_playwright adapter: trust contract between Core and the fixed Runtime.
3
+
4
+ Core alone decides what runs: it resolves the packaged runner/config/CLI,
5
+ checks approvals and the runtime manifest, creates a fresh run directory with
6
+ an execution id + nonce, launches Node in its own process group with a
7
+ sanitized environment, and then verifies result.json and every artifact before
8
+ normalizing the Runtime's local verdict (方案 §8, 变更清单 §1.3–§1.6).
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+ import os
15
+ import re
16
+ import secrets
17
+ import shutil
18
+ import signal
19
+ import subprocess
20
+ from datetime import datetime, timezone
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from approval_store import REQUIRES_DATABASE, valid_approval
25
+ from dsl_v1 import runtime_dir_candidates
26
+ from environment_profile import load as load_profile
27
+ from tc_hash import tc_sha256
28
+
29
+
30
+ ADAPTER_KIND = "devflow_playwright"
31
+ ADAPTER_KEYS = frozenset({"kind", "dsl_file", "environment_profile", "timeout_seconds", "artifacts"})
32
+ FORBIDDEN_ADAPTER_KEYS = frozenset({"argv", "env", "runner", "config", "result_file", "spec"})
33
+ ARTIFACT_KEYS = frozenset({"trace", "screenshots", "max_total_bytes"})
34
+ ARTIFACT_MODES = ("on_failure", "off")
35
+ DEFAULT_TIMEOUT_SECONDS, MIN_TIMEOUT_SECONDS, MAX_TIMEOUT_SECONDS = 600, 30, 1800
36
+ DEFAULT_MAX_ARTIFACT_BYTES, MAX_ARTIFACT_BYTES = 104857600, 1073741824
37
+ MAX_DSL_BYTES = 2 * 1024 * 1024
38
+ RUNTIME_FILES = ("runner.spec.mjs", "playwright.config.mjs", "reporter.mjs", "devflow-test-dsl-v1.schema.json")
39
+ PASSTHROUGH_ENV = ("PATH", "HOME", "TMPDIR", "LANG", "PLAYWRIGHT_BROWSERS_PATH")
40
+ RESULT_ORIGIN = "devflow_playwright_runtime"
41
+ RESULT_STATUSES = frozenset({"passed", "failed", "blocked", "invalid"})
42
+ ARTIFACT_KINDS = frozenset({"trace", "screenshot", "network"})
43
+ EXECUTION_ID_RE = re.compile(r"^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{8}$")
44
+ SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
45
+ KILL_GRACE_SECONDS = 5
46
+ STEP_SUMMARY_KEYS = ("id", "phase", "driver", "action", "status", "duration_ms", "locator", "match_count", "http_status", "assertion", "error")
47
+
48
+
49
+ def utc_now() -> str:
50
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
51
+
52
+
53
+ def sha256_bytes(value: bytes) -> str:
54
+ return hashlib.sha256(value).hexdigest()
55
+
56
+
57
+ def generated_cases_dir(root: Path, feature: str) -> Path:
58
+ return root / ".devflow/planning" / "generated-cases" / feature
59
+
60
+
61
+ def dsl_path(root: Path, feature: str, raw: Any) -> tuple[Path | None, str | None]:
62
+ """dsl_file must be a regular JSON file under .devflow/planning/generated-cases/<feature>/."""
63
+ if not isinstance(raw, str) or not raw.strip() or "\x00" in raw:
64
+ return None, "dsl_file must be a project-relative path"
65
+ candidate = Path(raw)
66
+ if candidate.is_absolute() or ".." in candidate.parts or candidate.suffix != ".json":
67
+ return None, f"dsl_file must be a relative .json path inside the project: {raw}"
68
+ root_real = root.resolve()
69
+ resolved = (root_real / candidate).resolve()
70
+ try:
71
+ resolved.relative_to(generated_cases_dir(root_real, feature).resolve())
72
+ except ValueError:
73
+ return None, f"dsl_file must be under .devflow/planning/generated-cases/{feature}/: {raw}"
74
+ return resolved, None
75
+
76
+
77
+ def adapter_settings(adapter: dict[str, Any]) -> dict[str, Any]:
78
+ artifacts = adapter.get("artifacts") if isinstance(adapter.get("artifacts"), dict) else {}
79
+ return {"timeout_seconds": adapter.get("timeout_seconds", DEFAULT_TIMEOUT_SECONDS),
80
+ "trace": artifacts.get("trace", "on_failure"), "screenshots": artifacts.get("screenshots", "on_failure"),
81
+ "max_total_bytes": artifacts.get("max_total_bytes", DEFAULT_MAX_ARTIFACT_BYTES)}
82
+
83
+
84
+ def adapter_errors(root: Path, feature: str, adapter: dict[str, Any]) -> list[str]:
85
+ """Binding-shape errors only; the binding can never name a runner, argv, env, result path, or hash."""
86
+ errors: list[str] = []
87
+ keys = set(adapter)
88
+ forbidden = sorted(key for key in keys if key in FORBIDDEN_ADAPTER_KEYS or key.endswith("_sha256"))
89
+ if forbidden:
90
+ errors.append(f"devflow_playwright adapter must not provide {', '.join(forbidden)} (Core resolves runner, environment, results and trusted hashes)")
91
+ unknown = sorted(keys - ADAPTER_KEYS - set(forbidden))
92
+ if unknown:
93
+ errors.append(f"devflow_playwright adapter has unsupported keys: {', '.join(unknown)}")
94
+ _path, path_error = dsl_path(root, feature, adapter.get("dsl_file"))
95
+ if path_error:
96
+ errors.append(path_error)
97
+ profile = adapter.get("environment_profile")
98
+ if not isinstance(profile, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,63}", profile):
99
+ errors.append("devflow_playwright adapter requires a valid environment_profile name")
100
+ timeout = adapter.get("timeout_seconds", DEFAULT_TIMEOUT_SECONDS)
101
+ if not isinstance(timeout, int) or isinstance(timeout, bool) or not MIN_TIMEOUT_SECONDS <= timeout <= MAX_TIMEOUT_SECONDS:
102
+ errors.append(f"timeout_seconds must be an integer in [{MIN_TIMEOUT_SECONDS}, {MAX_TIMEOUT_SECONDS}]")
103
+ artifacts = adapter.get("artifacts", {})
104
+ if not isinstance(artifacts, dict):
105
+ errors.append("artifacts must be an object")
106
+ else:
107
+ bad = sorted(key for key in artifacts if key not in ARTIFACT_KEYS)
108
+ if bad:
109
+ errors.append(f"artifacts has unsupported keys: {', '.join(bad)}")
110
+ for key in ("trace", "screenshots"):
111
+ if key in artifacts and artifacts[key] not in ARTIFACT_MODES:
112
+ errors.append(f"artifacts.{key} must be one of {', '.join(ARTIFACT_MODES)}")
113
+ limit = artifacts.get("max_total_bytes", DEFAULT_MAX_ARTIFACT_BYTES)
114
+ if not isinstance(limit, int) or isinstance(limit, bool) or not 1 <= limit <= MAX_ARTIFACT_BYTES:
115
+ errors.append(f"artifacts.max_total_bytes must be an integer in [1, {MAX_ARTIFACT_BYTES}]")
116
+ return errors
117
+
118
+
119
+ def read_dsl(path: Path) -> tuple[bytes | None, Any, str | None]:
120
+ try:
121
+ if path.is_symlink() or not path.is_file():
122
+ return None, None, "DSL file is missing or not a regular file"
123
+ raw = path.read_bytes()
124
+ except OSError as error:
125
+ return None, None, f"DSL file is unreadable: {error}"
126
+ if len(raw) > MAX_DSL_BYTES:
127
+ return None, None, f"DSL file exceeds {MAX_DSL_BYTES} bytes"
128
+ try:
129
+ return raw, json.loads(raw.decode("utf-8")), None
130
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
131
+ return raw, None, f"DSL file is not valid JSON: {error}"
132
+
133
+
134
+ # ---- Runtime location and integrity (A03) ---------------------------------------------------
135
+
136
+ def locate_runtime() -> Path | None:
137
+ return next((candidate for candidate in runtime_dir_candidates() if (candidate / "runtime-manifest.json").is_file()), None)
138
+
139
+
140
+ def verify_manifest(runtime: Path) -> tuple[dict[str, Any] | None, str | None, list[str]]:
141
+ """Return (manifest, manifest sha256, errors) after re-hashing every pinned runtime file."""
142
+ try:
143
+ raw = (runtime / "runtime-manifest.json").read_bytes()
144
+ manifest = json.loads(raw.decode("utf-8"))
145
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
146
+ return None, None, [f"runtime manifest is unreadable: {error}"]
147
+ errors: list[str] = []
148
+ files = manifest.get("files") if isinstance(manifest, dict) else None
149
+ if not isinstance(manifest, dict) or manifest.get("schema_version") != 1 or not isinstance(files, dict):
150
+ return None, None, ["runtime manifest must have schema_version 1 and a files map"]
151
+ if not isinstance(manifest.get("runtime_version"), str) or not manifest["runtime_version"]:
152
+ errors.append("runtime manifest runtime_version is missing")
153
+ for name in RUNTIME_FILES:
154
+ expected = files.get(name)
155
+ path = runtime / name
156
+ if not isinstance(expected, str) or not SHA256_RE.fullmatch(expected):
157
+ errors.append(f"runtime manifest does not pin {name}")
158
+ elif path.is_symlink() or not path.is_file():
159
+ errors.append(f"runtime file is missing: {name}")
160
+ elif sha256_bytes(path.read_bytes()) != expected:
161
+ errors.append(f"runtime file hash mismatch: {name}")
162
+ return (manifest if not errors else None), sha256_bytes(raw), errors
163
+
164
+
165
+ def sanitized_base_env() -> dict[str, str]:
166
+ return {key: os.environ[key] for key in PASSTHROUGH_ENV if os.environ.get(key)}
167
+
168
+
169
+ def package_root(runtime: Path) -> Path:
170
+ return runtime.resolve().parents[3]
171
+
172
+
173
+ def resolve_playwright_cli(runtime: Path, project_root: Path, expected_version: str | None) -> tuple[Path | None, str | None]:
174
+ """Resolve @playwright/test/cli from the Runtime directory with Node's resolver, never the project's."""
175
+ env = sanitized_base_env()
176
+ node = shutil.which("node", path=env.get("PATH"))
177
+ if node is None:
178
+ return None, "node is not available on PATH"
179
+ script = "process.stdout.write(require.resolve('@playwright/test/cli', {paths: [process.argv[1]]}))"
180
+ try:
181
+ completed = subprocess.run([node, "-e", script, str(runtime.resolve())], cwd=runtime, env=env, text=True,
182
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=30, check=False)
183
+ except (OSError, subprocess.TimeoutExpired) as error:
184
+ return None, f"cannot resolve the Runtime Playwright CLI: {error}"
185
+ if completed.returncode != 0 or not completed.stdout.strip():
186
+ return None, "the Runtime Playwright CLI (@playwright/test/cli) is not installed"
187
+ cli = Path(completed.stdout.strip()).resolve()
188
+ project_real = project_root.resolve()
189
+ owner = package_root(runtime)
190
+ inside_project = cli.is_relative_to(project_real)
191
+ if inside_project and not (owner.is_relative_to(project_real) and cli.is_relative_to(owner)):
192
+ return None, f"Playwright CLI resolved into the project under test: {cli}"
193
+ package_json = next((parent / "package.json" for parent in cli.parents if parent.name == "test" and parent.parent.name == "@playwright"), None)
194
+ if expected_version:
195
+ try:
196
+ version = json.loads(package_json.read_text(encoding="utf-8")).get("version") if package_json else None
197
+ except (OSError, ValueError):
198
+ version = None
199
+ if version != expected_version:
200
+ return None, f"Runtime Playwright version {version!r} does not match manifest {expected_version!r}"
201
+ return cli, None
202
+
203
+
204
+ def launch_argv(runtime: Path, project_root: Path, manifest: dict[str, Any]) -> tuple[list[str] | None, str | None]:
205
+ cli, error = resolve_playwright_cli(runtime, project_root, manifest.get("playwright_version"))
206
+ if cli is None:
207
+ return None, error
208
+ node = shutil.which("node", path=sanitized_base_env().get("PATH"))
209
+ return [str(node), str(cli), "test", "--config", str(runtime.resolve() / "playwright.config.mjs")], None
210
+
211
+
212
+ # ---- Execution (A02) ------------------------------------------------------------------------
213
+
214
+ def new_execution_id() -> str:
215
+ return f"{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}-{secrets.token_hex(4)}"
216
+
217
+
218
+ def runs_dir(root: Path, feature: str) -> Path:
219
+ return root / ".devflow" / "runs" / feature
220
+
221
+
222
+ def blocked(error_code: str, message: str, **extra: Any) -> dict[str, Any]:
223
+ return {"status": "blocked", "error_code": error_code, "error": message, **extra}
224
+
225
+
226
+ def terminate_group(process: subprocess.Popen[Any]) -> None:
227
+ try:
228
+ os.killpg(process.pid, signal.SIGTERM)
229
+ except (ProcessLookupError, PermissionError):
230
+ return
231
+ try:
232
+ process.wait(timeout=KILL_GRACE_SECONDS)
233
+ except subprocess.TimeoutExpired:
234
+ pass
235
+ try:
236
+ os.killpg(process.pid, signal.SIGKILL)
237
+ except (ProcessLookupError, PermissionError):
238
+ pass
239
+ process.wait()
240
+
241
+
242
+ def _identity_errors(result: Any, expected: dict[str, Any]) -> list[str]:
243
+ if not isinstance(result, dict):
244
+ return ["result.json root must be an object"]
245
+ errors = [f"result.json {key} does not match" for key, value in expected.items() if result.get(key) != value]
246
+ if result.get("status") not in RESULT_STATUSES:
247
+ errors.append("result.json status is invalid")
248
+ for key in ("started_at", "finished_at"):
249
+ if not isinstance(result.get(key), str) or not result[key]:
250
+ errors.append(f"result.json {key} is required")
251
+ if not isinstance(result.get("steps"), list) or not all(isinstance(step, dict) for step in result["steps"]):
252
+ errors.append("result.json steps must be an array of objects")
253
+ if not isinstance(result.get("artifacts", []), list):
254
+ errors.append("result.json artifacts must be an array")
255
+ cleanup = result.get("cleanup", {})
256
+ if not isinstance(cleanup, dict):
257
+ errors.append("result.json cleanup must be an object")
258
+ if not errors and result["status"] == "passed":
259
+ if any(step.get("status") in {"failed", "blocked"} for step in result["steps"]):
260
+ errors.append("result.json reports passed with failed or blocked steps")
261
+ if cleanup.get("status") == "failed":
262
+ errors.append("result.json reports passed although cleanup failed")
263
+ return errors
264
+
265
+
266
+ def verify_artifacts(root: Path, run_dir: Path, entries: list[Any], max_total_bytes: int) -> tuple[list[dict[str, Any]], list[str]]:
267
+ run_real = run_dir.resolve()
268
+ root_real = root.resolve()
269
+ verified: list[dict[str, Any]] = []
270
+ errors: list[str] = []
271
+ total = 0
272
+ for index, entry in enumerate(entries):
273
+ label = f"artifacts[{index}]"
274
+ if not isinstance(entry, dict) or entry.get("kind") not in ARTIFACT_KINDS or not isinstance(entry.get("path"), str):
275
+ errors.append(f"{label} must declare kind and path")
276
+ continue
277
+ raw = entry["path"]
278
+ candidate = Path(raw)
279
+ if not raw or "\x00" in raw or candidate.is_absolute() or ".." in candidate.parts:
280
+ errors.append(f"{label} path escapes the run directory: {raw}")
281
+ continue
282
+ unresolved = run_real / candidate
283
+ resolved = unresolved.resolve()
284
+ if not resolved.is_relative_to(run_real) or any(parent.is_symlink() for parent in [unresolved, *unresolved.parents] if parent.is_relative_to(run_real) and parent != run_real):
285
+ errors.append(f"{label} path escapes the run directory: {raw}")
286
+ continue
287
+ if not resolved.is_file():
288
+ errors.append(f"{label} file is missing: {raw}")
289
+ continue
290
+ content = resolved.read_bytes()
291
+ digest = sha256_bytes(content)
292
+ if entry.get("bytes") is not None and entry.get("bytes") != len(content):
293
+ errors.append(f"{label} size does not match result.json")
294
+ if entry.get("sha256") is not None and entry.get("sha256") != digest:
295
+ errors.append(f"{label} sha256 does not match result.json")
296
+ total += len(content)
297
+ verified.append({"path": resolved.relative_to(root_real).as_posix(), "kind": entry["kind"], "bytes": len(content), "sha256": digest})
298
+ if total > max_total_bytes:
299
+ errors.append(f"artifacts total {total} bytes exceed max_total_bytes {max_total_bytes}")
300
+ return verified, errors
301
+
302
+
303
+ def normalize(result_status: str | None, exit_code: int | None, runtime_error_code: Any) -> tuple[str, str | None, list[str]]:
304
+ """方案 §8.3: the Runtime status is a local verdict; the exit code is only a consistency check."""
305
+ if result_status == "passed":
306
+ return ("passed", None, []) if exit_code == 0 else ("blocked", "runtime_integrity_error", [])
307
+ if result_status == "failed":
308
+ return "failed", runtime_error_code if isinstance(runtime_error_code, str) else None, (["result_exit_conflict"] if exit_code == 0 else [])
309
+ if result_status in {"blocked", "invalid"}:
310
+ return "blocked", runtime_error_code if isinstance(runtime_error_code, str) and runtime_error_code else f"runtime_{result_status}", []
311
+ return "blocked", "runtime_result_invalid", []
312
+
313
+
314
+ def resolve_trust(root: Path, feature: str, tc_id: str, tc_data: dict[str, Any], suite: dict[str, Any], adapter: dict[str, Any]) -> dict[str, Any]:
315
+ """Re-derive, from current files and approvals, everything Core trusts before a run.
316
+
317
+ Shared by execute() (run time) and acceptance_test_runner.validate_results() (gate time) so both
318
+ apply exactly the same DSL/TC identity, execution_plan approval and environment_profile checks.
319
+ Returns {"base", "failure", "profile", "profile_name", "raw_profile", "raw_dsl", "dsl_sha", "tc_sha"};
320
+ ``failure`` is a complete Core-normalized blocked result (or None when everything is valid).
321
+ """
322
+ meta = suite.get("view_meta") if isinstance(suite.get("view_meta"), dict) else {}
323
+ current_tc = tc_sha256(tc_data)
324
+ trust: dict[str, Any] = {"base": {}, "failure": None, "profile": None, "profile_name": None, "raw_profile": None, "raw_dsl": None, "dsl_sha": None, "tc_sha": current_tc}
325
+ path, path_error = dsl_path(root, feature, adapter.get("dsl_file"))
326
+ raw_dsl, dsl, dsl_error = read_dsl(path) if path else (None, None, path_error)
327
+ if raw_dsl is None or dsl is None:
328
+ trust["failure"] = blocked("runtime_result_invalid", dsl_error or "DSL is unavailable")
329
+ return trust
330
+ dsl_sha = sha256_bytes(raw_dsl)
331
+ base = {"tc_sha256": current_tc, "dsl_sha256": dsl_sha}
332
+ trust.update({"base": base, "raw_dsl": raw_dsl, "dsl_sha": dsl_sha})
333
+ if not isinstance(dsl, dict) or dsl.get("tc_id") != tc_id or dsl.get("tc_sha256") != current_tc:
334
+ trust["failure"] = blocked("dsl_not_approved", "DSL no longer matches the frozen TC", **base)
335
+ return trust
336
+
337
+ # Pins are recomputed by approval_pins from the approved request; the DSL the
338
+ # binding names, the frozen TC and the suite must be exactly what was approved.
339
+ plan = valid_approval(root, feature, "execution_plan", tc_id, {"suite_graph_revision": suite.get("graph_revision"), "suite_fingerprint": meta.get("fingerprint"),
340
+ "tc_sha256": current_tc, "dsl_sha256": dsl_sha})
341
+ if not plan["ok"]:
342
+ code = REQUIRES_DATABASE if plan["reason"] == REQUIRES_DATABASE else "dsl_not_approved"
343
+ trust["failure"] = blocked(code, f"execution plan approval is not valid: {plan['message']}", dsl_approval="none", **base)
344
+ return trust
345
+ base["dsl_approval"] = {"status": "approved", "approval_id": plan["approval"]["id"], "approved_at": plan["approval"]["approved_at"]}
346
+
347
+ profile_name = adapter["environment_profile"]
348
+ profile, profile_sha, profile_errors = load_profile(root, profile_name)
349
+ if profile is None or profile_sha is None:
350
+ trust["failure"] = blocked("environment_profile_not_approved", "; ".join(profile_errors), **base)
351
+ return trust
352
+ profile_approval = valid_approval(root, feature, "environment_profile", profile_name, {"profile_sha256": profile_sha})
353
+ if not profile_approval["ok"]:
354
+ code = REQUIRES_DATABASE if profile_approval["reason"] == REQUIRES_DATABASE else "environment_profile_not_approved"
355
+ trust["failure"] = blocked(code, f"environment profile approval is not valid: {profile_approval['message']}", **base)
356
+ return trust
357
+ base["environment_profile"] = {"name": profile_name, "sha256": profile_sha, "approval_id": profile_approval["approval"]["id"]}
358
+ raw_profile = (root / ".devflow/planning/test-environments" / f"{profile_name}.json").read_bytes()
359
+ if sha256_bytes(raw_profile) != profile_sha:
360
+ trust["failure"] = blocked("environment_profile_not_approved", "environment profile changed while starting the run", **base)
361
+ return trust
362
+ trust.update({"profile": profile, "profile_name": profile_name, "raw_profile": raw_profile})
363
+ return trust
364
+
365
+
366
+ def recorded_run_errors(root: Path, feature: str, tc_id: str, entry: dict[str, Any], run_dir_expired: bool = False) -> list[str]:
367
+ """Re-verify the on-disk evidence a recorded result entry points at (result.json + artifacts).
368
+
369
+ Missing/changed evidence is an error. When the whole run directory is gone and ``run_dir_expired``
370
+ is true (the feature is past its post-closeout retention window, so GC legitimately removed it)
371
+ the file checks are skipped; every other field is still verified by the caller.
372
+ """
373
+ execution_id = entry.get("execution_id")
374
+ if not isinstance(execution_id, str) or not EXECUTION_ID_RE.fullmatch(execution_id):
375
+ return ["execution_id is missing or malformed"]
376
+ root_real = root.resolve()
377
+ run_dir = runs_dir(root_real, feature) / execution_id
378
+ expected_relative = run_dir.relative_to(root_real).as_posix()
379
+ if entry.get("run_dir") != expected_relative:
380
+ return [f"run_dir {entry.get('run_dir')!r} does not match execution_id (expected {expected_relative})"]
381
+ if run_dir.is_symlink() or not run_dir.is_dir():
382
+ return [] if run_dir_expired and not run_dir.is_symlink() and not run_dir.exists() else [f"run directory is missing: {expected_relative}"]
383
+ errors: list[str] = []
384
+ recorded_sha = entry.get("result_sha256")
385
+ result_file = run_dir / "result.json"
386
+ if result_file.is_symlink() or not result_file.is_file():
387
+ errors.append(f"result.json is missing: {expected_relative}/result.json")
388
+ else:
389
+ raw = result_file.read_bytes()
390
+ if sha256_bytes(raw) != recorded_sha:
391
+ errors.append("result.json sha256 differs from the recorded result_sha256 (evidence changed)")
392
+ else:
393
+ try:
394
+ result = json.loads(raw.decode("utf-8"))
395
+ except (UnicodeDecodeError, ValueError):
396
+ result = None
397
+ expected = {"origin": RESULT_ORIGIN, "execution_id": execution_id, "tc_id": tc_id, "tc_sha256": entry.get("tc_sha256"), "dsl_sha256": entry.get("dsl_sha256")}
398
+ if not isinstance(result, dict):
399
+ errors.append("result.json is not a JSON object")
400
+ else:
401
+ errors.extend(f"result.json {key} does not match the recorded entry" for key, value in expected.items() if result.get(key) != value)
402
+ artifacts = entry.get("artifacts")
403
+ if not isinstance(artifacts, list):
404
+ errors.append("artifacts must be a recorded array")
405
+ return errors
406
+ for index, artifact in enumerate(artifacts):
407
+ raw_path = artifact.get("path") if isinstance(artifact, dict) else None
408
+ if not isinstance(raw_path, str) or not raw_path or not isinstance(artifact.get("sha256"), str):
409
+ errors.append(f"artifacts[{index}] must record path and sha256")
410
+ continue
411
+ candidate = Path(raw_path)
412
+ resolved = (root_real / candidate).resolve()
413
+ if candidate.is_absolute() or ".." in candidate.parts or not resolved.is_relative_to(run_dir.resolve()):
414
+ errors.append(f"artifacts[{index}] path is outside the run directory: {raw_path}")
415
+ elif not resolved.is_file():
416
+ errors.append(f"artifact is missing: {raw_path}")
417
+ elif sha256_bytes(resolved.read_bytes()) != artifact["sha256"]:
418
+ errors.append(f"artifact sha256 differs from the recorded value (evidence changed): {raw_path}")
419
+ return errors
420
+
421
+
422
+ def execute(root: Path, feature: str, tc_id: str, tc_data: dict[str, Any], suite: dict[str, Any], adapter: dict[str, Any]) -> dict[str, Any]:
423
+ """Run one bound TC through the Runtime and return the Core-normalized result fields."""
424
+ settings = adapter_settings(adapter)
425
+ trust = resolve_trust(root, feature, tc_id, tc_data, suite, adapter)
426
+ if trust["failure"] is not None:
427
+ return trust["failure"]
428
+ base, profile, raw_profile, raw_dsl, dsl_sha, current_tc = trust["base"], trust["profile"], trust["raw_profile"], trust["raw_dsl"], trust["dsl_sha"], trust["tc_sha"]
429
+
430
+ secret_env: dict[str, str] = {}
431
+ for key, env_name in profile.get("secret_env", {}).items():
432
+ if not os.environ.get(env_name):
433
+ return blocked("environment_secret_missing", f"secret environment variable for '{key}' is unset: {env_name}", **base)
434
+ secret_env[env_name] = os.environ[env_name]
435
+
436
+ runtime = locate_runtime()
437
+ if runtime is None:
438
+ return blocked("runtime_integrity_error", "Devflow Playwright Runtime is not installed (runtime-manifest.json not found)", **base)
439
+ manifest, manifest_sha, manifest_errors = verify_manifest(runtime)
440
+ if manifest is None:
441
+ return blocked("runtime_integrity_error", "; ".join(manifest_errors), **base)
442
+ base["runtime"] = {"version": manifest["runtime_version"], "playwright_version": manifest.get("playwright_version"), "manifest_sha256": manifest_sha}
443
+ argv, launch_error = launch_argv(runtime, root, manifest)
444
+ if argv is None:
445
+ return blocked("runtime_integrity_error", launch_error or "Runtime launcher is unavailable", **base)
446
+
447
+ execution_id = new_execution_id()
448
+ nonce = secrets.token_hex(16)
449
+ run_dir = runs_dir(root, feature) / execution_id
450
+ try:
451
+ run_dir.parent.mkdir(parents=True, exist_ok=True)
452
+ run_dir.mkdir(mode=0o700)
453
+ (run_dir / "artifacts").mkdir(mode=0o700)
454
+ except OSError as error:
455
+ return blocked("runtime_result_invalid", f"cannot create a fresh run directory: {error}", **base)
456
+ base["execution_id"] = execution_id
457
+ base["run_dir"] = run_dir.relative_to(root.resolve() if run_dir.is_relative_to(root.resolve()) else root).as_posix()
458
+ dsl_copy = run_dir / "dsl.json"
459
+ environment_copy = run_dir / "environment.json"
460
+ dsl_copy.write_bytes(raw_dsl)
461
+ environment_copy.write_bytes(raw_profile)
462
+ result_file = run_dir / "result.json"
463
+ env = {**sanitized_base_env(), **secret_env,
464
+ "DEVFLOW_DSL_FILE": str(dsl_copy.resolve()), "DEVFLOW_DSL_SHA256": dsl_sha,
465
+ "DEVFLOW_TC_ID": tc_id, "DEVFLOW_TC_SHA256": current_tc,
466
+ "DEVFLOW_EXECUTION_ID": execution_id, "DEVFLOW_EXECUTION_NONCE": nonce,
467
+ "DEVFLOW_RESULT_FILE": str(result_file.resolve()), "DEVFLOW_ARTIFACT_DIR": str((run_dir / "artifacts").resolve()),
468
+ "DEVFLOW_ENVIRONMENT_FILE": str(environment_copy.resolve())}
469
+ started_at = utc_now()
470
+ exit_code: int | None = None
471
+ timed_out = False
472
+ with open(run_dir / "runtime.log", "wb") as log:
473
+ try:
474
+ process = subprocess.Popen(argv, cwd=runtime, env=env, stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT, start_new_session=True)
475
+ except OSError as error:
476
+ return blocked("runtime_integrity_error", f"Runtime could not start: {error}", **base)
477
+ try:
478
+ exit_code = process.wait(timeout=settings["timeout_seconds"])
479
+ except subprocess.TimeoutExpired:
480
+ timed_out = True
481
+ terminate_group(process)
482
+ finally:
483
+ try: # reap any browser left in the group after the leader exited
484
+ os.killpg(process.pid, signal.SIGKILL)
485
+ except (ProcessLookupError, PermissionError):
486
+ pass
487
+ base.update({"started_at": started_at, "finished_at": utc_now(), "exit_code": exit_code})
488
+ if timed_out:
489
+ return blocked("runtime_timeout", f"Runtime exceeded timeout_seconds={settings['timeout_seconds']}", **base)
490
+
491
+ try:
492
+ if result_file.is_symlink() or not result_file.is_file():
493
+ raise ValueError("result.json is missing")
494
+ raw_result = result_file.read_bytes()
495
+ result = json.loads(raw_result.decode("utf-8"))
496
+ except (OSError, UnicodeDecodeError, ValueError) as error:
497
+ return blocked("runtime_result_invalid", str(error), **base)
498
+ base["result_sha256"] = sha256_bytes(raw_result)
499
+ identity_errors = _identity_errors(result, {"schema_version": 1, "origin": RESULT_ORIGIN, "execution_id": execution_id, "nonce": nonce,
500
+ "tc_id": tc_id, "tc_sha256": current_tc, "dsl_sha256": dsl_sha,
501
+ "runtime_version": manifest["runtime_version"]})
502
+ if identity_errors:
503
+ return blocked("runtime_result_invalid", "; ".join(identity_errors), **base)
504
+ artifacts, artifact_errors = verify_artifacts(root, run_dir, result.get("artifacts", []), settings["max_total_bytes"])
505
+ base["artifacts"] = artifacts
506
+ base["steps"] = [{key: step[key] for key in STEP_SUMMARY_KEYS if key in step} for step in result["steps"]]
507
+ base["cleanup"] = result.get("cleanup", {})
508
+ base["runtime_status"] = result["status"]
509
+ if artifact_errors:
510
+ return blocked("runtime_artifact_invalid", "; ".join(artifact_errors), **base)
511
+ status, error_code, conflicts = normalize(result["status"], exit_code, result.get("error_code"))
512
+ base.update({"status": status, "error_code": error_code})
513
+ if conflicts:
514
+ base["conflicts"] = conflicts
515
+ return base