@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.
- package/README.md +4 -4
- package/node_modules/@playwright/test/LICENSE +202 -0
- package/node_modules/@playwright/test/NOTICE +5 -0
- package/node_modules/@playwright/test/README.md +318 -0
- package/node_modules/@playwright/test/cli.js +19 -0
- package/node_modules/@playwright/test/index.d.ts +18 -0
- package/node_modules/@playwright/test/index.js +17 -0
- package/node_modules/@playwright/test/index.mjs +18 -0
- package/node_modules/@playwright/test/package.json +35 -0
- package/node_modules/@playwright/test/reporter.d.ts +17 -0
- package/node_modules/@playwright/test/reporter.js +17 -0
- package/node_modules/@playwright/test/reporter.mjs +17 -0
- package/node_modules/playwright/LICENSE +202 -0
- package/node_modules/playwright/NOTICE +5 -0
- package/node_modules/playwright/README.md +318 -0
- package/node_modules/playwright/ThirdPartyNotices.txt +14 -0
- package/node_modules/playwright/cli.js +19 -0
- package/node_modules/playwright/index.d.ts +17 -0
- package/node_modules/playwright/index.js +17 -0
- package/node_modules/playwright/index.mjs +18 -0
- package/node_modules/playwright/jsx-runtime.js +42 -0
- package/node_modules/playwright/jsx-runtime.mjs +21 -0
- package/node_modules/playwright/lib/agents/agentParser.js +89 -0
- package/node_modules/playwright/lib/agents/copilot-setup-steps.yml +34 -0
- package/node_modules/playwright/lib/agents/generateAgents.js +395 -0
- package/node_modules/playwright/lib/agents/playwright-test-coverage.prompt.md +31 -0
- package/node_modules/playwright/lib/agents/playwright-test-generate.prompt.md +8 -0
- package/node_modules/playwright/lib/agents/playwright-test-generator.agent.md +88 -0
- package/node_modules/playwright/lib/agents/playwright-test-heal.prompt.md +6 -0
- package/node_modules/playwright/lib/agents/playwright-test-healer.agent.md +56 -0
- package/node_modules/playwright/lib/agents/playwright-test-plan.prompt.md +9 -0
- package/node_modules/playwright/lib/agents/playwright-test-planner.agent.md +74 -0
- package/node_modules/playwright/lib/cli/reportActions.js +78 -0
- package/node_modules/playwright/lib/cli/testActions.js +214 -0
- package/node_modules/playwright/lib/common/index.js +2923 -0
- package/node_modules/playwright/lib/common/index.js.txt +34 -0
- package/node_modules/playwright/lib/errorContext.js +129 -0
- package/node_modules/playwright/lib/globals.js +58 -0
- package/node_modules/playwright/lib/index.js +815 -0
- package/node_modules/playwright/lib/isomorphic.js +260 -0
- package/node_modules/playwright/lib/isomorphic.js.txt +9 -0
- package/node_modules/playwright/lib/loader/loaderProcessEntry.js +34 -0
- package/node_modules/playwright/lib/loader/loaderProcessEntry.js.txt +9 -0
- package/node_modules/playwright/lib/matchers/expect.js +13490 -0
- package/node_modules/playwright/lib/matchers/expect.js.LICENSE +640 -0
- package/node_modules/playwright/lib/matchers/expect.js.txt +67 -0
- package/node_modules/playwright/lib/mcp/test/browserBackend.js +135 -0
- package/node_modules/playwright/lib/mcp/test/generatorTools.js +122 -0
- package/node_modules/playwright/lib/mcp/test/plannerTools.js +150 -0
- package/node_modules/playwright/lib/mcp/test/seed.js +82 -0
- package/node_modules/playwright/lib/mcp/test/streams.js +44 -0
- package/node_modules/playwright/lib/mcp/test/testBackend.js +99 -0
- package/node_modules/playwright/lib/mcp/test/testContext.js +306 -0
- package/node_modules/playwright/lib/mcp/test/testTool.js +30 -0
- package/node_modules/playwright/lib/mcp/test/testTools.js +98 -0
- package/node_modules/playwright/lib/package.js +47 -0
- package/node_modules/playwright/lib/program.js +241 -0
- package/node_modules/playwright/lib/runner/index.js +8527 -0
- package/node_modules/playwright/lib/runner/index.js.txt +61 -0
- package/node_modules/playwright/lib/transform/babelBundle.js +72274 -0
- package/node_modules/playwright/lib/transform/babelBundle.js.LICENSE +2384 -0
- package/node_modules/playwright/lib/transform/babelBundle.js.txt +325 -0
- package/node_modules/playwright/lib/transform/esmLoader.js +8054 -0
- package/node_modules/playwright/lib/transform/esmLoader.js.LICENSE +355 -0
- package/node_modules/playwright/lib/transform/esmLoader.js.txt +60 -0
- package/node_modules/playwright/lib/util.js +373 -0
- package/node_modules/playwright/lib/worker/workerProcessEntry.js +1861 -0
- package/node_modules/playwright/lib/worker/workerProcessEntry.js.txt +19 -0
- package/node_modules/playwright/package.json +55 -0
- package/node_modules/playwright/test.d.ts +18 -0
- package/node_modules/playwright/test.js +24 -0
- package/node_modules/playwright/test.mjs +35 -0
- package/node_modules/playwright/types/test.d.ts +11074 -0
- package/node_modules/playwright/types/testReporter.d.ts +950 -0
- package/node_modules/playwright-core/LICENSE +202 -0
- package/node_modules/playwright-core/NOTICE +5 -0
- package/node_modules/playwright-core/README.md +3 -0
- package/node_modules/playwright-core/ThirdPartyNotices.txt +13 -0
- package/node_modules/playwright-core/bin/install_media_pack.ps1 +5 -0
- package/node_modules/playwright-core/bin/install_webkit_wsl.ps1 +33 -0
- package/node_modules/playwright-core/bin/reinstall_chrome_beta_linux.sh +42 -0
- package/node_modules/playwright-core/bin/reinstall_chrome_beta_mac.sh +13 -0
- package/node_modules/playwright-core/bin/reinstall_chrome_beta_win.ps1 +24 -0
- package/node_modules/playwright-core/bin/reinstall_chrome_stable_linux.sh +42 -0
- package/node_modules/playwright-core/bin/reinstall_chrome_stable_mac.sh +12 -0
- package/node_modules/playwright-core/bin/reinstall_chrome_stable_win.ps1 +24 -0
- package/node_modules/playwright-core/bin/reinstall_msedge_beta_linux.sh +48 -0
- package/node_modules/playwright-core/bin/reinstall_msedge_beta_mac.sh +11 -0
- package/node_modules/playwright-core/bin/reinstall_msedge_beta_win.ps1 +23 -0
- package/node_modules/playwright-core/bin/reinstall_msedge_dev_linux.sh +48 -0
- package/node_modules/playwright-core/bin/reinstall_msedge_dev_mac.sh +11 -0
- package/node_modules/playwright-core/bin/reinstall_msedge_dev_win.ps1 +23 -0
- package/node_modules/playwright-core/bin/reinstall_msedge_stable_linux.sh +48 -0
- package/node_modules/playwright-core/bin/reinstall_msedge_stable_mac.sh +11 -0
- package/node_modules/playwright-core/bin/reinstall_msedge_stable_win.ps1 +24 -0
- package/node_modules/playwright-core/browsers.json +52 -0
- package/node_modules/playwright-core/cli.js +21 -0
- package/node_modules/playwright-core/index.d.ts +17 -0
- package/node_modules/playwright-core/index.js +17 -0
- package/node_modules/playwright-core/index.mjs +28 -0
- package/node_modules/playwright-core/lib/bootstrap.js +88 -0
- package/node_modules/playwright-core/lib/coreBundle.js +75813 -0
- package/node_modules/playwright-core/lib/entry/cliDaemon.js +5 -0
- package/node_modules/playwright-core/lib/entry/dashboardApp.js +3 -0
- package/node_modules/playwright-core/lib/entry/mcp.js +10 -0
- package/node_modules/playwright-core/lib/entry/oopBrowserDownload.js +3 -0
- package/node_modules/playwright-core/lib/package.js +50 -0
- package/node_modules/playwright-core/lib/server/chromium/appIcon.png +0 -0
- package/node_modules/playwright-core/lib/server/electron/loader.js +116 -0
- package/node_modules/playwright-core/lib/serverRegistry.js +1939 -0
- package/node_modules/playwright-core/lib/serverRegistry.js.LICENSE +65 -0
- package/node_modules/playwright-core/lib/tools/cli-client/channelSessions.js +141 -0
- package/node_modules/playwright-core/lib/tools/cli-client/cli.js +6 -0
- package/node_modules/playwright-core/lib/tools/cli-client/help.json +721 -0
- package/node_modules/playwright-core/lib/tools/cli-client/minimist.js +128 -0
- package/node_modules/playwright-core/lib/tools/cli-client/output.js +351 -0
- package/node_modules/playwright-core/lib/tools/cli-client/program.js +418 -0
- package/node_modules/playwright-core/lib/tools/cli-client/registry.js +180 -0
- package/node_modules/playwright-core/lib/tools/cli-client/session.js +259 -0
- package/node_modules/playwright-core/lib/tools/dashboard/appIcon.png +0 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-cli/SKILL.md +425 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/element-attributes.md +23 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/playwright-tests.md +39 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/request-mocking.md +87 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/running-code.md +241 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/session-management.md +225 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/storage-state.md +275 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/test-generation.md +433 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/tracing.md +139 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/video-recording.md +143 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/SKILL.md +136 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/gallery-spec.md +144 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/migration.md +89 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/react.md +69 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/typing.md +173 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/vue.md +77 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-trace/SKILL.md +174 -0
- package/node_modules/playwright-core/lib/tools/utils/extension.js +112 -0
- package/node_modules/playwright-core/lib/tools/utils/socketConnection.js +108 -0
- package/node_modules/playwright-core/lib/utilsBundle.js +85357 -0
- package/node_modules/playwright-core/lib/utilsBundle.js.LICENSE +1890 -0
- package/node_modules/playwright-core/lib/vite/dashboard/assets/codeMirrorModule--QdMvsKi.css +1 -0
- package/node_modules/playwright-core/lib/vite/dashboard/assets/codeMirrorModule-CQ8RZGBm.js +32 -0
- package/node_modules/playwright-core/lib/vite/dashboard/assets/codicon-DCmgc-ay.ttf +0 -0
- package/node_modules/playwright-core/lib/vite/dashboard/assets/firefox-1bWoP6pv.svg +1 -0
- package/node_modules/playwright-core/lib/vite/dashboard/assets/firefox-beta-k3eOH_eK.svg +1 -0
- package/node_modules/playwright-core/lib/vite/dashboard/assets/firefox-nightly-Cp5nfeDT.svg +1 -0
- package/node_modules/playwright-core/lib/vite/dashboard/assets/index-AwgwcYie.css +1 -0
- package/node_modules/playwright-core/lib/vite/dashboard/assets/index-hRvO4yU2.js +12 -0
- package/node_modules/playwright-core/lib/vite/dashboard/assets/safari-na3_-uQk.svg +1 -0
- package/node_modules/playwright-core/lib/vite/dashboard/index.html +29 -0
- package/node_modules/playwright-core/lib/vite/dashboard/playwright-logo.svg +24 -0
- package/node_modules/playwright-core/lib/vite/htmlReport/index.html +16 -0
- package/node_modules/playwright-core/lib/vite/htmlReport/report.css +2 -0
- package/node_modules/playwright-core/lib/vite/htmlReport/report.js +32 -0
- package/node_modules/playwright-core/lib/vite/recorder/assets/codeMirrorModule--QdMvsKi.css +1 -0
- package/node_modules/playwright-core/lib/vite/recorder/assets/codeMirrorModule-CVQWJZAA.js +32 -0
- package/node_modules/playwright-core/lib/vite/recorder/assets/codicon-DCmgc-ay.ttf +0 -0
- package/node_modules/playwright-core/lib/vite/recorder/assets/index-BZpYJZ-P.css +1 -0
- package/node_modules/playwright-core/lib/vite/recorder/assets/index-hrlLqLtq.js +129 -0
- package/node_modules/playwright-core/lib/vite/recorder/index.html +29 -0
- package/node_modules/playwright-core/lib/vite/recorder/playwright-logo.svg +9 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/assets/codeMirrorModule-BbkfBe3n.js +32 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/assets/defaultSettingsView-Ds6CBOo0.js +182 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/assets/urlMatch-L3liM589.js +1 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/assets/xtermModule-DywYcAf8.js +7 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/codeMirrorModule.-QdMvsKi.css +1 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/codicon.DCmgc-ay.ttf +0 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/defaultSettingsView.Bqk9acqE.css +1 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/index.B4cLoZK3.js +1 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/index.B_TqY17P.css +1 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/index.html +44 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/manifest.webmanifest +16 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/playwright-logo.svg +9 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/snapshot.B_Jk1wbt.js +1 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/snapshot.html +10 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/sw.bundle.js +4 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/uiMode.CU5KtEkS.js +5 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/uiMode.CyMfwkXJ.css +1 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html +18 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/xtermModule.kHJ-D0s7.css +1 -0
- package/node_modules/playwright-core/lib/webp_codec.LICENSE +173 -0
- package/node_modules/playwright-core/lib/webp_codec.wasm +0 -0
- package/node_modules/playwright-core/lib/xdg-open +1267 -0
- package/node_modules/playwright-core/package.json +34 -0
- package/node_modules/playwright-core/types/protocol.d.ts +24645 -0
- package/node_modules/playwright-core/types/structs.d.ts +52 -0
- package/node_modules/playwright-core/types/types.d.ts +26632 -0
- package/package.json +12 -2
- package/plugins/devflow-core/extensions/devflow-core.ts +431 -7
- package/plugins/devflow-core/runtime/hooks/hook_common.py +269 -0
- package/plugins/devflow-core/runtime/hooks/index_gate.py +29 -16
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py +407 -71
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +51 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/approval_pins.py +433 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/approval_store.py +137 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_coverage.py +87 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_v1.py +318 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/environment_profile.py +134 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +1 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_surface.py +138 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/playwright_adapter.py +515 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/tc_hash.py +132 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/test_command_policy.py +102 -8
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +13 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +13 -34
- package/plugins/devflow-core/skills/workflow-pipeline/SKILL.md +3 -1
- package/plugins/devflow-core/skills/workflow-pipeline/references/pipeline-flow.md +68 -140
- package/plugins/devflow-core/skills/workflow-project-context/scripts/validate_initialization_database.py +1 -1
- package/plugins/devflow-implementation/skills/workflow-execute-phase/references/execute-phase.md +3 -1
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py +23 -4
- package/plugins/devflow-quality/runtime/playwright/devflow-test-dsl-v1.schema.json +872 -0
- package/plugins/devflow-quality/runtime/playwright/playwright.config.mjs +46 -0
- package/plugins/devflow-quality/runtime/playwright/reporter.mjs +311 -0
- package/plugins/devflow-quality/runtime/playwright/runner.spec.mjs +1308 -0
- package/plugins/devflow-quality/runtime/playwright/runtime-manifest.json +11 -0
- package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +3 -0
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/SKILL.md +71 -58
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/api-mainflow-rules.md +20 -15
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/codebase-discovery.md +3 -1
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/intermediate-format.md +115 -192
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/mainflow-selection.md +27 -23
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/project-dsl-contract.md +78 -153
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/ui-mainflow-rules.md +36 -83
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/compile_dsl.py +497 -556
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/export_legacy_testagent.py +647 -0
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/normalize_case_payloads.py +10 -3
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/validate_case_payloads.py +165 -275
- package/plugins/devflow-quality/skills/workflow-human-acceptance/SKILL.md +61 -0
- package/plugins/devflow-quality/skills/workflow-release-closeout/SKILL.md +4 -4
- package/plugins/devflow-quality/skills/workflow-run-acceptance-tests/SKILL.md +15 -9
- package/src/approval-store.ts +340 -0
- package/src/constants.ts +1 -1
- package/src/doctor.ts +259 -2
- package/src/initialization-store.ts +48 -1
- package/src/paths.ts +12 -1
- package/src/project-context.ts +24 -1
- 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
|