@guanlin4924/devflow-pi 0.1.96 → 0.1.98
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 +11 -1
- package/package.json +1 -1
- package/plugins/devflow-core/extensions/devflow-core.ts +697 -91
- package/plugins/devflow-core/skills/wireframe-render/SKILL.md +1 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py +18 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_suite.rb +6 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +64 -8
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_coverage.py +3 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_v1.py +52 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +2 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/explore_preflight.py +58 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/init_workflow.rb +1 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/locator_map.py +156 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +1 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/playwright_adapter.py +1 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +50 -15
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +60 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/startup_preflight.py +167 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +5 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_render.py +262 -56
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/regression-acceptance.yaml +126 -0
- package/plugins/devflow-core/skills/workflow-pipeline/SKILL.md +2 -0
- package/plugins/devflow-quality/runtime/playwright/devflow-test-dsl-v1.schema.json +103 -88
- package/plugins/devflow-quality/runtime/playwright/explore-host.mjs +1015 -0
- package/plugins/devflow-quality/runtime/playwright/runner.spec.mjs +297 -15
- package/plugins/devflow-quality/runtime/playwright/runtime-manifest.json +3 -2
- package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +4 -0
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/SKILL.md +12 -2
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/intermediate-format.md +6 -2
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/project-dsl-contract.md +3 -1
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/ui-mainflow-rules.md +4 -2
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/compile_dsl.py +98 -15
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/locator_report.py +73 -0
- package/plugins/devflow-quality/skills/workflow-regression-test-design/SKILL.md +72 -0
- package/plugins/devflow-quality/skills/workflow-release-closeout/SKILL.md +1 -1
- package/plugins/devflow-quality/skills/workflow-run-acceptance-tests/SKILL.md +1 -1
- package/src/constants.ts +1 -1
- package/src/startup-briefing.ts +109 -0
|
@@ -5,4 +5,4 @@ description: Render validated canonical wireframe JSON into deterministic Markdo
|
|
|
5
5
|
|
|
6
6
|
# Wireframe Render
|
|
7
7
|
|
|
8
|
-
Call the workflow-orchestrator wireframe_render.py script only with the current Core-built canonical JSON and the validation metadata. Generate preview Markdown, flow SVG,
|
|
8
|
+
Call the workflow-orchestrator wireframe_render.py script only with the current Core-built canonical JSON and the validation metadata. Generate preview Markdown, flow SVG, one SVG per declared state under `wireframes/screens/<screen-id>/<platform>/<state>.svg` (`default.svg` is always present), and the optional static gallery under the wireframe output directory. The `wireframes/screens` directory is a required render output and is covered by the execution contract. The `canonical.meta.json` file is created later by the Core approval path and is not an input to this step. Never read proposal YAML as a substitute, mutate canonical JSON, write approval, or modify workflow state.
|
|
@@ -480,7 +480,12 @@ def _run_locked(root: Path, feature: str, write: bool) -> dict[str, Any]:
|
|
|
480
480
|
source = cases[tc_id]
|
|
481
481
|
strategy = source.get("evidence_strategy")
|
|
482
482
|
surface, legacy = derive_surface(source)
|
|
483
|
-
|
|
483
|
+
# Feature-development TCs historically omitted this field; the shared
|
|
484
|
+
# contract defines that legacy omission as requirement-origin. A
|
|
485
|
+
# regression suite is frozen only after Core has required it explicitly.
|
|
486
|
+
oracle_source = source.get("oracle_source") or "requirement"
|
|
487
|
+
common = {"tc_id": tc_id, "mode": mode, "execution_surface": surface, "legacy_unclassified": legacy,
|
|
488
|
+
"oracle_source": oracle_source, "tc_sha256": tc_sha256(source)}
|
|
484
489
|
frozen_mode = source.get("automation_mode")
|
|
485
490
|
if mode != frozen_mode:
|
|
486
491
|
common["downgrade"] = {"from": frozen_mode, "to": mode}
|
|
@@ -542,6 +547,8 @@ def _run_locked(root: Path, feature: str, write: bool) -> dict[str, Any]:
|
|
|
542
547
|
passed = sum(1 for item in results if item.get("status") == "passed")
|
|
543
548
|
failed = sum(1 for item in results if item.get("status") == "failed")
|
|
544
549
|
blocked = sum(1 for item in results if item.get("status") == "blocked")
|
|
550
|
+
oracle_source_counts = {source: sum(1 for item in results if (item.get("oracle_source") or "requirement") == source)
|
|
551
|
+
for source in ("requirement", "contract", "invariant", "characterization")}
|
|
545
552
|
write_json(meta_path, {
|
|
546
553
|
"schema_version": 1,
|
|
547
554
|
"feature": feature,
|
|
@@ -551,6 +558,7 @@ def _run_locked(root: Path, feature: str, write: bool) -> dict[str, Any]:
|
|
|
551
558
|
"acceptance_tc_legacy_unclassified": sum(1 for item in results if item.get("legacy_unclassified")),
|
|
552
559
|
"acceptance_tc_downgraded": sum(1 for item in results if item.get("downgrade")),
|
|
553
560
|
"acceptance_tc_dsl_unapproved": sum(1 for item in results if item.get("dsl_approval") == "none"),
|
|
561
|
+
"oracle_source_counts": oracle_source_counts,
|
|
554
562
|
"acceptance_suite_graph_revision": payload["test_suite"].get("graph_revision"),
|
|
555
563
|
"acceptance_results_fingerprint": file_fingerprint(result_path),
|
|
556
564
|
"built_by": "core_acceptance_test_runner",
|
|
@@ -576,7 +584,7 @@ def _result_summary(entry: dict[str, Any]) -> dict[str, Any]:
|
|
|
576
584
|
elif approval not in {"approved", "none"}:
|
|
577
585
|
approval = None
|
|
578
586
|
return {key: entry.get(key) for key in (
|
|
579
|
-
"tc_id", "status", "mode", "adapter_kind", "execution_surface", "error_code"
|
|
587
|
+
"tc_id", "status", "mode", "adapter_kind", "execution_surface", "oracle_source", "error_code"
|
|
580
588
|
)} | {"dsl_approval": approval, "legacy_unclassified": bool(entry.get("legacy_unclassified")),
|
|
581
589
|
"downgrade": entry.get("downgrade")}
|
|
582
590
|
|
|
@@ -594,9 +602,12 @@ def report(root: Path, feature: str) -> dict[str, Any]:
|
|
|
594
602
|
entries = [entry for entry in payload["results"] if isinstance(entry, dict)]
|
|
595
603
|
statuses = {status: sum(1 for entry in entries if entry.get("status") == status)
|
|
596
604
|
for status in ("passed", "failed", "blocked")}
|
|
605
|
+
oracle_source_counts = {source: sum(1 for entry in entries if (entry.get("oracle_source") or "requirement") == source)
|
|
606
|
+
for source in ("requirement", "contract", "invariant", "characterization")}
|
|
597
607
|
counts = {"passed": statuses["passed"], "failed": statuses["failed"], "blocked": statuses["blocked"],
|
|
598
608
|
"legacy_unclassified": sum(bool(entry.get("legacy_unclassified")) for entry in entries),
|
|
599
|
-
"downgraded": sum(bool(entry.get("downgrade")) for entry in entries)
|
|
609
|
+
"downgraded": sum(bool(entry.get("downgrade")) for entry in entries),
|
|
610
|
+
"oracle_source": oracle_source_counts}
|
|
600
611
|
gate = validate_results(root, feature)
|
|
601
612
|
raw_errors = gate.get("errors", []) if isinstance(gate, dict) else []
|
|
602
613
|
return {"ok": True, "feature": feature,
|
|
@@ -689,6 +700,10 @@ def result_entry_errors(root: Path, feature: str, tc_id: str, source: dict[str,
|
|
|
689
700
|
"""Re-verify one recorded result against the CURRENT binding, frozen TC, approvals and evidence files."""
|
|
690
701
|
errors: list[str] = []
|
|
691
702
|
surface, _legacy = derive_surface(source)
|
|
703
|
+
frozen_oracle_source = source.get("oracle_source") or "requirement"
|
|
704
|
+
recorded_oracle_source = entry.get("oracle_source")
|
|
705
|
+
if recorded_oracle_source != frozen_oracle_source:
|
|
706
|
+
errors.append(f"recorded oracle_source {recorded_oracle_source!r} does not match the frozen TC oracle_source {frozen_oracle_source!r}")
|
|
692
707
|
mode = binding.get("mode")
|
|
693
708
|
if entry.get("mode") != mode:
|
|
694
709
|
errors.append(f"recorded mode {entry.get('mode')!r} does not match the current binding mode {mode!r}")
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
require "json"
|
|
8
8
|
require "open3"
|
|
9
9
|
require "optparse"
|
|
10
|
+
require "yaml"
|
|
10
11
|
require_relative "control_io"
|
|
11
12
|
require_relative "requirement_graph"
|
|
12
13
|
|
|
@@ -46,6 +47,10 @@ begin
|
|
|
46
47
|
lock = DevflowControlIO.acquire_project_lock(root) if command == "freeze"
|
|
47
48
|
graph, path, relative = RequirementGraph.load_graph(root, options[:feature])
|
|
48
49
|
cases = test_nodes(graph)
|
|
50
|
+
state_path = File.join(root, ".devflow/planning/STATE.md")
|
|
51
|
+
state_front = File.file?(state_path) ? File.read(state_path).match(/\A---\n(.*?)\n---\n/m)&.[](1) : nil
|
|
52
|
+
state = state_front ? YAML.safe_load(state_front, permitted_classes: [], aliases: false) : {}
|
|
53
|
+
regression = state.is_a?(Hash) && state["current_flow"].to_s == "regression-acceptance"
|
|
49
54
|
|
|
50
55
|
if command == "inspect"
|
|
51
56
|
view = RequirementGraph.view_payload(graph, "tests")
|
|
@@ -82,7 +87,7 @@ begin
|
|
|
82
87
|
else
|
|
83
88
|
matrix = api_test_matrix_check(root, options[:feature], graph)
|
|
84
89
|
result = if matrix["ok"]
|
|
85
|
-
RequirementGraph.freeze_test_cases!(root, graph, options[:cp_record], confirm_prerequisites: options[:confirm_prerequisites])
|
|
90
|
+
RequirementGraph.freeze_test_cases!(root, graph, options[:cp_record], confirm_prerequisites: options[:confirm_prerequisites], regression: regression)
|
|
86
91
|
else
|
|
87
92
|
{ "ok" => false, "reason" => "api_test_matrix_incomplete", "message" => Array(matrix["errors"]).join("; "), "matrix" => matrix }
|
|
88
93
|
end
|
|
@@ -565,6 +565,11 @@ HARD_FAILURE_REASONS = %w[
|
|
|
565
565
|
dsl_not_approved
|
|
566
566
|
environment_profile_not_approved
|
|
567
567
|
runtime_integrity_error
|
|
568
|
+
acceptance_test_suite_freeze_failed
|
|
569
|
+
acceptance_test_suite_freeze_attestation_invalid
|
|
570
|
+
acceptance_test_suite_freeze_attestation_failed
|
|
571
|
+
acceptance_test_suite_candidate_binding_failed
|
|
572
|
+
acceptance_test_suite_freeze_recovery_failed
|
|
568
573
|
].freeze
|
|
569
574
|
$devflow_advisories = []
|
|
570
575
|
|
|
@@ -724,7 +729,9 @@ def write_test_suite_freeze_attestation(root, feature, state_revision, candidate
|
|
|
724
729
|
raise DevflowControlIO::InvalidControlPath, "candidate test suite pin missing from CP-3 binding check" unless candidate_suite.is_a?(Hash)
|
|
725
730
|
raise DevflowControlIO::InvalidControlPath, "frozen test suite pin missing from CP-3 freeze" unless frozen_suite.is_a?(Hash)
|
|
726
731
|
binding_hash = candidate_check["binding_sha256"]
|
|
727
|
-
|
|
732
|
+
unless candidate_check["regression"] == true || (binding_hash.is_a?(String) && !binding_hash.empty?)
|
|
733
|
+
raise DevflowControlIO::InvalidControlPath, "test binding hash missing from CP-3 binding check"
|
|
734
|
+
end
|
|
728
735
|
|
|
729
736
|
path, relative = test_suite_freeze_attestation_path(root, feature)
|
|
730
737
|
payload = {
|
|
@@ -735,9 +742,9 @@ def write_test_suite_freeze_attestation(root, feature, state_revision, candidate
|
|
|
735
742
|
"state_revision" => state_revision,
|
|
736
743
|
"candidate_test_suite" => candidate_suite,
|
|
737
744
|
"frozen_test_suite" => frozen_suite,
|
|
738
|
-
"test_binding_sha256" => binding_hash,
|
|
739
745
|
"frozen_case_ids" => Array(freeze_result["frozen_case_ids"]).sort
|
|
740
746
|
}
|
|
747
|
+
payload["test_binding_sha256"] = binding_hash unless candidate_check["regression"] == true
|
|
741
748
|
DevflowControlIO.atomic_create(path, JSON.pretty_generate(payload) + "\n")
|
|
742
749
|
DevflowControlIO.register_control_artifact(root, {
|
|
743
750
|
"path" => relative, "type" => "test-suite-freeze-attestation", "feature" => feature,
|
|
@@ -746,6 +753,26 @@ def write_test_suite_freeze_attestation(root, feature, state_revision, candidate
|
|
|
746
753
|
payload
|
|
747
754
|
end
|
|
748
755
|
|
|
756
|
+
def regression_freeze_attestation_check(graph, feature, attestation_path)
|
|
757
|
+
begin
|
|
758
|
+
attestation = JSON.parse(File.read(attestation_path))
|
|
759
|
+
rescue JSON::ParserError, Errno::ENOENT => error
|
|
760
|
+
return { "ok" => false, "errors" => ["invalid regression freeze attestation: #{error.message}"] }
|
|
761
|
+
end
|
|
762
|
+
current_view = graph.dig("views", "tests")
|
|
763
|
+
cases = Array(graph["nodes"]).select { |node| node.is_a?(Hash) && node["kind"] == "test_case" }
|
|
764
|
+
errors = []
|
|
765
|
+
errors << "attestation schema_version is invalid" unless attestation["schema_version"] == 1
|
|
766
|
+
errors << "attestation origin is invalid" unless attestation["origin"] == "core_acceptance_test_suite_freeze"
|
|
767
|
+
errors << "attestation feature does not match" unless attestation["feature"] == feature
|
|
768
|
+
errors << "attestation checkpoint is invalid" unless attestation["checkpoint"] == "CP-3"
|
|
769
|
+
errors << "attestation frozen suite does not match the graph" unless attestation["frozen_test_suite"] == frozen_test_suite_pin("tests_view" => current_view)
|
|
770
|
+
frozen_ids = attestation["frozen_case_ids"]
|
|
771
|
+
expected_ids = cases.map { |node| node["id"] }.sort
|
|
772
|
+
errors << "attestation does not cover exactly the frozen TC cases" unless frozen_ids.is_a?(Array) && frozen_ids.map(&:to_s).sort == expected_ids
|
|
773
|
+
{ "ok" => errors.empty?, "errors" => errors }
|
|
774
|
+
end
|
|
775
|
+
|
|
749
776
|
def cp3_freeze_provenance?(graph, state_revision)
|
|
750
777
|
expected = "CP-3: state revision #{state_revision} approved"
|
|
751
778
|
cases = Array(graph["nodes"]).select { |node| node.is_a?(Hash) && node["kind"] == "test_case" }
|
|
@@ -754,6 +781,10 @@ def cp3_freeze_provenance?(graph, state_revision)
|
|
|
754
781
|
end
|
|
755
782
|
end
|
|
756
783
|
|
|
784
|
+
def regression_workflow?(state, steps)
|
|
785
|
+
state["current_flow"].to_s == "regression-acceptance" || steps.any? { |step| step["id"] == "regression-test-design" }
|
|
786
|
+
end
|
|
787
|
+
|
|
757
788
|
# Return from a completed failure branch using its declared return_to contract.
|
|
758
789
|
# The caller must still explicitly confirm the returned checkpoint before any
|
|
759
790
|
# subsequent source-flow step can proceed.
|
|
@@ -1111,13 +1142,18 @@ result =
|
|
|
1111
1142
|
# inside the checkpoint transition so the user does not have to
|
|
1112
1143
|
# approve an extra model-issued dialog and a Skill cannot freeze its
|
|
1113
1144
|
# own candidates early.
|
|
1114
|
-
if result.nil? && state["checkpoint"] == "CP-3" && steps.any? { |step|
|
|
1145
|
+
if result.nil? && state["checkpoint"] == "CP-3" && steps.any? { |step| %w[acceptance-test-design regression-test-design].include?(step["id"]) }
|
|
1115
1146
|
feature = state.fetch("current_feature")
|
|
1116
1147
|
graph, = RequirementGraph.load_graph(root, feature)
|
|
1148
|
+
regression = regression_workflow?(state, steps)
|
|
1117
1149
|
attestation_path, = test_suite_freeze_attestation_path(root, feature)
|
|
1118
1150
|
|
|
1119
1151
|
if File.exist?(attestation_path)
|
|
1120
|
-
frozen_check =
|
|
1152
|
+
frozen_check = if regression
|
|
1153
|
+
regression_freeze_attestation_check(graph, feature, attestation_path)
|
|
1154
|
+
else
|
|
1155
|
+
test_task_binding_check(root, feature, "frozen")
|
|
1156
|
+
end
|
|
1121
1157
|
unless frozen_check["ok"]
|
|
1122
1158
|
result = fail_result("acceptance_test_suite_freeze_attestation_invalid", "test_suite" => frozen_check)
|
|
1123
1159
|
else
|
|
@@ -1128,7 +1164,11 @@ result =
|
|
|
1128
1164
|
# attestation is published. Recover only if each TC proves that
|
|
1129
1165
|
# this exact CP-3 already froze it; never use this path to bless
|
|
1130
1166
|
# a generic confirmed suite.
|
|
1131
|
-
recovery_check =
|
|
1167
|
+
recovery_check = if regression
|
|
1168
|
+
{ "ok" => true, "regression" => true, "candidate_test_suite" => graph.dig("views", "tests") }
|
|
1169
|
+
else
|
|
1170
|
+
test_task_binding_check(root, feature, "recovery")
|
|
1171
|
+
end
|
|
1132
1172
|
unless recovery_check["ok"]
|
|
1133
1173
|
result = fail_result("acceptance_test_suite_freeze_recovery_failed", "test_suite" => recovery_check)
|
|
1134
1174
|
else
|
|
@@ -1139,7 +1179,11 @@ result =
|
|
|
1139
1179
|
}
|
|
1140
1180
|
begin
|
|
1141
1181
|
write_test_suite_freeze_attestation(root, feature, current_revision, recovery_check, recovered)
|
|
1142
|
-
frozen_check =
|
|
1182
|
+
frozen_check = if regression
|
|
1183
|
+
regression_freeze_attestation_check(graph, feature, attestation_path)
|
|
1184
|
+
else
|
|
1185
|
+
test_task_binding_check(root, feature, "frozen")
|
|
1186
|
+
end
|
|
1143
1187
|
if frozen_check["ok"]
|
|
1144
1188
|
actions << "acceptance_test_suite_freeze_attestation_recovered"
|
|
1145
1189
|
else
|
|
@@ -1160,7 +1204,11 @@ result =
|
|
|
1160
1204
|
result = fail_result("execution_surface_mismatch", "execution_surface" => surface_check) unless surface_check["ok"]
|
|
1161
1205
|
end
|
|
1162
1206
|
if matrix_check["ok"] && surface_check["ok"]
|
|
1163
|
-
candidate_check =
|
|
1207
|
+
candidate_check = if regression
|
|
1208
|
+
{ "ok" => true, "regression" => true, "candidate_test_suite" => graph.dig("views", "tests") }
|
|
1209
|
+
else
|
|
1210
|
+
test_task_binding_check(root, feature, "candidate")
|
|
1211
|
+
end
|
|
1164
1212
|
unless candidate_check["ok"]
|
|
1165
1213
|
result = fail_result("acceptance_test_suite_candidate_binding_failed", "test_suite" => candidate_check)
|
|
1166
1214
|
else
|
|
@@ -1169,6 +1217,7 @@ result =
|
|
|
1169
1217
|
graph,
|
|
1170
1218
|
"CP-3: state revision #{current_revision} approved",
|
|
1171
1219
|
confirm_prerequisites: options[:confirm_test_prerequisites] && options[:runtime_confirmed],
|
|
1220
|
+
regression: regression,
|
|
1172
1221
|
persist: false
|
|
1173
1222
|
)
|
|
1174
1223
|
unless frozen["ok"]
|
|
@@ -1313,7 +1362,14 @@ result =
|
|
|
1313
1362
|
pending_freeze.fetch("candidate_check"),
|
|
1314
1363
|
pending_freeze.fetch("frozen")
|
|
1315
1364
|
)
|
|
1316
|
-
frozen_check =
|
|
1365
|
+
frozen_check = if pending_freeze.dig("candidate_check", "regression") == true
|
|
1366
|
+
regression_freeze_attestation_check(
|
|
1367
|
+
pending_freeze.fetch("graph"), pending_freeze.fetch("feature"),
|
|
1368
|
+
test_suite_freeze_attestation_path(root, pending_freeze.fetch("feature")).first
|
|
1369
|
+
)
|
|
1370
|
+
else
|
|
1371
|
+
test_task_binding_check(root, pending_freeze.fetch("feature"), "frozen")
|
|
1372
|
+
end
|
|
1317
1373
|
unless frozen_check["ok"]
|
|
1318
1374
|
result = fail_result("acceptance_test_suite_freeze_attestation_invalid", "test_suite" => frozen_check)
|
|
1319
1375
|
end
|
|
@@ -46,6 +46,9 @@ def _count(data: dict[str, Any], key: str) -> int:
|
|
|
46
46
|
|
|
47
47
|
def check(tc_data: dict[str, Any], dsl: dict[str, Any]) -> list[str]:
|
|
48
48
|
errors: list[str] = []
|
|
49
|
+
for _phase, path, step in all_steps(dsl):
|
|
50
|
+
if step.get("action") == "assert_each_text" and step.get("min_count", 1) < 1:
|
|
51
|
+
errors.append(f"{path}.min_count must be >= 1")
|
|
49
52
|
counts = {"preconditions": _count(tc_data, "preconditions"), "steps": _count(tc_data, "steps")}
|
|
50
53
|
covered: dict[str, set[int]] = {"preconditions": set(), "steps": set()}
|
|
51
54
|
oracle = tc_data.get("oracle")
|
|
@@ -28,7 +28,14 @@ BUILTIN_VARIABLES = frozenset({"execution_id", "namespace"})
|
|
|
28
28
|
VARIABLE_REF_RE = re.compile(r"\$\{([^}]*)\}")
|
|
29
29
|
VARIABLE_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,63}$")
|
|
30
30
|
SECRET_HEADERS = frozenset({"authorization", "proxy-authorization", "cookie", "x-api-key", "x-auth-token"})
|
|
31
|
-
|
|
31
|
+
FRAGILE_CSS_PATTERNS = (
|
|
32
|
+
re.compile(r"el-table_\d+(?:_column_\d+)?"),
|
|
33
|
+
re.compile(r":nth-(?:last-)?(?:child|of-type)\("),
|
|
34
|
+
re.compile(r"\[data-v-[0-9a-fA-F]{8}\]"),
|
|
35
|
+
re.compile(r"[A-Za-z]+[-_][0-9a-fA-F]{6,}"),
|
|
36
|
+
re.compile(r"_[A-Za-z0-9]{5,}_"),
|
|
37
|
+
)
|
|
38
|
+
UI_ASSERTIONS = frozenset({"assert_visible", "assert_text", "assert_each_text", "assert_value", "assert_url"})
|
|
32
39
|
UI_INTERACTIONS = frozenset({"click", "fill", "select", "check", "uncheck"})
|
|
33
40
|
_IGNORED_KEYWORDS = {"$schema", "$id", "title", "description", "$defs", "default", "examples"}
|
|
34
41
|
|
|
@@ -111,7 +118,9 @@ class _Validator:
|
|
|
111
118
|
if unknown:
|
|
112
119
|
raise ValueError(f"unsupported schema keywords: {sorted(unknown)}")
|
|
113
120
|
if "oneOf" in schema:
|
|
114
|
-
|
|
121
|
+
one_of_errors = self.one_of(value, schema["oneOf"], path)
|
|
122
|
+
if one_of_errors:
|
|
123
|
+
return one_of_errors
|
|
115
124
|
found: list[str] = []
|
|
116
125
|
if "type" in schema:
|
|
117
126
|
types = schema["type"] if isinstance(schema["type"], list) else [schema["type"]]
|
|
@@ -259,6 +268,8 @@ def semantic_errors(dsl: dict[str, Any]) -> list[str]:
|
|
|
259
268
|
seen_ids.setdefault(step_id, path)
|
|
260
269
|
payload = {key: value for key, value in step.items() if key not in {"id", "driver", "action", "covers", "extract", "assertions", "retry"}}
|
|
261
270
|
_references(payload, path, known, errors)
|
|
271
|
+
if step.get("driver") == "ui" and isinstance(step.get("target"), dict):
|
|
272
|
+
_target_errors(step["target"], f"{path}.target", errors, action=step.get("action"))
|
|
262
273
|
if step["driver"] != "api":
|
|
263
274
|
continue
|
|
264
275
|
for index, assertion in enumerate(step.get("assertions", [])):
|
|
@@ -281,9 +292,48 @@ def semantic_errors(dsl: dict[str, Any]) -> list[str]:
|
|
|
281
292
|
known.add(name)
|
|
282
293
|
if spec.get("sensitive") is True:
|
|
283
294
|
sensitive.add(name)
|
|
295
|
+
if "header" in spec and spec.get("sensitive", True):
|
|
296
|
+
sensitive.add(name)
|
|
284
297
|
return errors
|
|
285
298
|
|
|
286
299
|
|
|
300
|
+
def _target_errors(target: dict[str, Any], path: str, errors: list[str], depth: int = 0, action: Any = None) -> None:
|
|
301
|
+
"""Semantic target rules shared with the Runtime's independent gate."""
|
|
302
|
+
locator_keys = {"testid", "role", "label", "placeholder", "text", "css"}
|
|
303
|
+
locators = locator_keys & target.keys()
|
|
304
|
+
if "role" in target and "name" not in target:
|
|
305
|
+
errors.append(f"{path} role target requires name")
|
|
306
|
+
if "name" in target and "role" not in target:
|
|
307
|
+
errors.append(f"{path} name is only valid with role")
|
|
308
|
+
if len(locators) > 1:
|
|
309
|
+
errors.append(f"{path} must contain exactly one locator form")
|
|
310
|
+
if "css" in target and any(pattern.search(str(target["css"])) for pattern in FRAGILE_CSS_PATTERNS):
|
|
311
|
+
errors.append(f"{path}.css uses a fragile generated/structural selector; use table/column, within, row, or a mapping anchor")
|
|
312
|
+
if "table" in target:
|
|
313
|
+
table = target["table"]
|
|
314
|
+
if not isinstance(table, dict) or sum(key in table for key in ("near_heading", "testid", "only")) != 1:
|
|
315
|
+
errors.append(f"{path}.table must select by exactly one of near_heading, testid, or only")
|
|
316
|
+
if "column" not in target and "row" not in target:
|
|
317
|
+
errors.append(f"{path}.table requires column or row")
|
|
318
|
+
if "table_kind" in target and target["table_kind"] not in {"html", "element-plus"}:
|
|
319
|
+
errors.append(f"{path}.table_kind must be html or element-plus")
|
|
320
|
+
if "column" in target and "table" not in target:
|
|
321
|
+
errors.append(f"{path}.column requires table")
|
|
322
|
+
if "row" in target:
|
|
323
|
+
row = target["row"]
|
|
324
|
+
if not isinstance(row, dict) or ("equals" in row) == ("contains" in row):
|
|
325
|
+
errors.append(f"{path}.row requires exactly one of equals or contains")
|
|
326
|
+
if isinstance(row, dict) and "column" not in row:
|
|
327
|
+
errors.append(f"{path}.row requires column")
|
|
328
|
+
if "table" in target and "row" not in target and action != "assert_each_text":
|
|
329
|
+
errors.append(f"{path} table/column targets require assert_each_text or a row restriction")
|
|
330
|
+
if "within" in target:
|
|
331
|
+
if depth >= 2:
|
|
332
|
+
errors.append(f"{path}.within exceeds maximum nesting depth 2")
|
|
333
|
+
elif isinstance(target["within"], dict):
|
|
334
|
+
_target_errors(target["within"], f"{path}.within", errors, depth + 1, action)
|
|
335
|
+
|
|
336
|
+
|
|
287
337
|
def validate(dsl: Any) -> list[str]:
|
|
288
338
|
try:
|
|
289
339
|
errors = schema_errors(dsl)
|
|
@@ -24,6 +24,7 @@ POLICY = {
|
|
|
24
24
|
"api-supplement" => { "checkers" => %w[requirement-graph-check contract-coverage-check], "agents" => ["implementation-worker"], "budget" => 1, "allowed_graph_delta" => %w[api decision] },
|
|
25
25
|
"integration-verify" => { "checkers" => %w[requirement-graph-check slice-independence-check contract-coverage-check change-impact-check cross-track-consistency-check business-claim-reference-check business-cross-model-check], "agents" => ["verification-runner"], "budget" => 1, "allowed_graph_delta" => %w[requirement risk decision] },
|
|
26
26
|
"acceptance-test-design" => { "checkers" => %w[requirement-graph-check contract-coverage-check acceptance-coverage-check api-test-matrix-check test-coverage-check test-obligation-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[test_case] },
|
|
27
|
+
"regression-test-design" => { "checkers" => %w[test-coverage-check test-obligation-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[test_case] },
|
|
27
28
|
"change-plan" => { "checkers" => %w[requirement-graph-check slice-independence-check contract-coverage-check api-test-matrix-check test-coverage-check test-obligation-check test-task-binding-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[acceptance] },
|
|
28
29
|
"execute" => { "checkers" => %w[tdd-evidence-check change-impact-check], "agents" => ["implementation-worker"], "budget" => 1, "allowed_graph_delta" => %w[requirement rule boundary behavior] },
|
|
29
30
|
"run-acceptance-tests" => { "checkers" => %w[api-test-matrix-check test-coverage-check acceptance-test-results-check change-impact-check], "agents" => ["verification-runner"], "budget" => 1, "allowed_graph_delta" => [] },
|
|
@@ -129,6 +130,7 @@ begin
|
|
|
129
130
|
read grep find glob lsp search write edit apply_patch bash shell exec_command task todo
|
|
130
131
|
devflow_api_test_matrix devflow_tdd_evidence devflow_run_acceptance_tests devflow_mainflow
|
|
131
132
|
devflow_requirement_graph devflow_static_runtime_context
|
|
133
|
+
devflow_explore
|
|
132
134
|
devflow_architecture_constraint devflow_architecture_feasibility devflow_architecture_brief
|
|
133
135
|
devflow_approval
|
|
134
136
|
],
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Read-only gate for the devflow_explore host.
|
|
3
|
+
|
|
4
|
+
This deliberately shares the profile loader and approval-store reader used by
|
|
5
|
+
the Playwright acceptance adapter. It never reads secret values and never
|
|
6
|
+
writes the control plane.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from approval_store import REQUIRES_DATABASE, valid_approval
|
|
15
|
+
from environment_profile import load as load_profile, profile_path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def preflight(root: Path, feature: str, name: str) -> dict[str, object]:
|
|
19
|
+
profile, sha256, errors = load_profile(root, name)
|
|
20
|
+
result: dict[str, object] = {
|
|
21
|
+
"ok": False,
|
|
22
|
+
"profile": name,
|
|
23
|
+
"profile_path": str(profile_path(root, name).resolve()),
|
|
24
|
+
"sha256": sha256,
|
|
25
|
+
"approved": False,
|
|
26
|
+
"secret_names": [],
|
|
27
|
+
"errors": list(errors),
|
|
28
|
+
}
|
|
29
|
+
if profile is None or sha256 is None:
|
|
30
|
+
return result
|
|
31
|
+
approval = valid_approval(root, feature, "environment_profile", name, {"profile_sha256": sha256})
|
|
32
|
+
if not approval.get("ok"):
|
|
33
|
+
reason = approval.get("reason")
|
|
34
|
+
message = str(approval.get("message") or "environment profile approval is missing or stale")
|
|
35
|
+
result["errors"] = [f"{reason}: {message}" if reason else message]
|
|
36
|
+
result["reason"] = reason
|
|
37
|
+
return result
|
|
38
|
+
secret_env = profile.get("secret_env", {})
|
|
39
|
+
if isinstance(secret_env, dict):
|
|
40
|
+
result["secret_names"] = sorted(str(value) for value in secret_env.values() if isinstance(value, str) and value)
|
|
41
|
+
result["approved"] = True
|
|
42
|
+
result["ok"] = True
|
|
43
|
+
return result
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def main() -> int:
|
|
47
|
+
parser = argparse.ArgumentParser()
|
|
48
|
+
parser.add_argument("--root", type=Path, default=Path.cwd())
|
|
49
|
+
parser.add_argument("--feature", required=True)
|
|
50
|
+
parser.add_argument("--environment-profile", required=True)
|
|
51
|
+
args = parser.parse_args()
|
|
52
|
+
result = preflight(args.root.resolve(), args.feature, args.environment_profile)
|
|
53
|
+
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
|
|
54
|
+
return 0 if result["ok"] else (3 if result.get("reason") == REQUIRES_DATABASE else 2)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
raise SystemExit(main())
|
|
@@ -59,6 +59,7 @@ end
|
|
|
59
59
|
targets = {
|
|
60
60
|
"feature-development.yaml" => ".devflow/planning/workflows/feature-development.yaml",
|
|
61
61
|
"acceptance-remediation.yaml" => ".devflow/planning/workflows/acceptance-remediation.yaml",
|
|
62
|
+
"regression-acceptance.yaml" => ".devflow/planning/workflows/regression-acceptance.yaml",
|
|
62
63
|
"workflows-README.md" => ".devflow/planning/workflows/README.md"
|
|
63
64
|
}
|
|
64
65
|
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Load and strictly expand Devflow locator mapping files.
|
|
3
|
+
|
|
4
|
+
The mapping is a planning artefact, never a runtime input. Keeping this
|
|
5
|
+
module independent of the compiler makes it useful to other generators while
|
|
6
|
+
ensuring that an anchor can only expand to one of the DSL target forms.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import copy
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LocatorMapError(ValueError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_PAGE_RE = re.compile(r"^[^/\\\x00-\x1f\x7f]+$")
|
|
22
|
+
_ANCHOR_RE = re.compile(r"^anchor:([^/\\\x00-\x1f\x7f]+)/([^/\\\x00-\x1f\x7f]+)$")
|
|
23
|
+
_TARGET_KEYS = {"testid", "role", "name", "label", "placeholder", "text", "css", "exact",
|
|
24
|
+
"within", "table", "column", "table_kind", "row"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _error(path: str, message: str) -> LocatorMapError:
|
|
28
|
+
return LocatorMapError(f"{path}: {message}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _target(value: Any, path: str) -> None:
|
|
32
|
+
if not isinstance(value, dict) or not value:
|
|
33
|
+
raise _error(path, "target must be a non-empty object")
|
|
34
|
+
unknown = set(value) - _TARGET_KEYS
|
|
35
|
+
if unknown:
|
|
36
|
+
raise _error(path, f"unsupported target keys {sorted(unknown)}")
|
|
37
|
+
forms = [key for key in ("testid", "role", "label", "placeholder", "text", "css", "table", "row") if key in value]
|
|
38
|
+
if "within" in value:
|
|
39
|
+
if len(forms) != 1:
|
|
40
|
+
raise _error(path, "within must wrap exactly one locator form")
|
|
41
|
+
_target(value["within"], f"{path}.within")
|
|
42
|
+
elif len(forms) != 1:
|
|
43
|
+
raise _error(path, "target must contain exactly one locator form")
|
|
44
|
+
if "role" in value and (not isinstance(value["role"], str) or not isinstance(value.get("name"), str)):
|
|
45
|
+
raise _error(path, "role target requires string role and name")
|
|
46
|
+
if "role" not in value and "name" in value:
|
|
47
|
+
raise _error(path, "name is only valid with role")
|
|
48
|
+
for key in ("testid", "label", "placeholder", "text", "css"):
|
|
49
|
+
if key in value and (not isinstance(value[key], str) or not value[key].strip()):
|
|
50
|
+
raise _error(path, f"{key} must be a non-empty string")
|
|
51
|
+
if "exact" in value and not isinstance(value["exact"], bool):
|
|
52
|
+
raise _error(path, "exact must be boolean")
|
|
53
|
+
if "table" in value:
|
|
54
|
+
table = value["table"]
|
|
55
|
+
if not isinstance(table, dict) or set(table) not in ({"near_heading"}, {"testid"}, {"only"}, {"ref"}):
|
|
56
|
+
raise _error(f"{path}.table", "must be near_heading, testid, only, or ref")
|
|
57
|
+
key = next(iter(table))
|
|
58
|
+
if key == "only" and table[key] is not True:
|
|
59
|
+
raise _error(f"{path}.table.only", "must be true")
|
|
60
|
+
if key != "only" and (not isinstance(table[key], str) or not table[key].strip()):
|
|
61
|
+
raise _error(f"{path}.table.{key}", "must be a non-empty string")
|
|
62
|
+
if "column" not in value or not isinstance(value["column"], str) or not value["column"].strip():
|
|
63
|
+
raise _error(path, "table target requires a non-empty column")
|
|
64
|
+
if value.get("table_kind", "html") not in {"html", "element-plus"}:
|
|
65
|
+
raise _error(path, "table_kind must be html or element-plus")
|
|
66
|
+
if "row" in value:
|
|
67
|
+
row = value["row"]
|
|
68
|
+
if not isinstance(row, dict) or set(row) != {"column", "equals"} and set(row) != {"column", "contains"}:
|
|
69
|
+
raise _error(f"{path}.row", "must be {column, equals} or {column, contains}")
|
|
70
|
+
if not isinstance(row.get("column"), str) or not row["column"].strip() or not isinstance(row[next(k for k in row if k != "column")], str):
|
|
71
|
+
raise _error(f"{path}.row", "column and match value must be strings")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def validate_mapping(document: Any, source: str = "mapping") -> dict[str, Any]:
|
|
75
|
+
if not isinstance(document, dict):
|
|
76
|
+
raise _error(source, "must be an object")
|
|
77
|
+
unknown = set(document) - {"schema_version", "page", "anchors"}
|
|
78
|
+
if unknown:
|
|
79
|
+
raise _error(source, f"unsupported keys {sorted(unknown)}")
|
|
80
|
+
if document.get("schema_version") != 1:
|
|
81
|
+
raise _error(source, "schema_version must be 1")
|
|
82
|
+
page = document.get("page")
|
|
83
|
+
if not isinstance(page, str) or not _PAGE_RE.fullmatch(page):
|
|
84
|
+
raise _error(f"{source}.page", "must be a single non-empty path-safe string")
|
|
85
|
+
anchors = document.get("anchors")
|
|
86
|
+
if not isinstance(anchors, dict) or not anchors:
|
|
87
|
+
raise _error(f"{source}.anchors", "must be a non-empty object")
|
|
88
|
+
for name, target in anchors.items():
|
|
89
|
+
if not isinstance(name, str) or not name.strip() or "/" in name:
|
|
90
|
+
raise _error(f"{source}.anchors", "anchor names must be non-empty and must not contain '/'")
|
|
91
|
+
if isinstance(target, dict) and set(target) == {"ref"}:
|
|
92
|
+
if not isinstance(target["ref"], str) or not target["ref"].strip():
|
|
93
|
+
raise _error(f"{source}.anchors.{name}.ref", "must be a non-empty string")
|
|
94
|
+
else:
|
|
95
|
+
_target(target, f"{source}.anchors.{name}")
|
|
96
|
+
return copy.deepcopy(document)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def load_locator_map(path: Path) -> dict[str, Any]:
|
|
100
|
+
try:
|
|
101
|
+
document = json.loads(path.read_text(encoding="utf-8"))
|
|
102
|
+
except FileNotFoundError as error:
|
|
103
|
+
raise LocatorMapError(f"locator map not found: {path}") from error
|
|
104
|
+
except json.JSONDecodeError as error:
|
|
105
|
+
raise LocatorMapError(f"locator map is not valid JSON: {path}: {error}") from error
|
|
106
|
+
return validate_mapping(document, str(path))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def expand_mapping(document: dict[str, Any], anchor: str) -> dict[str, Any]:
|
|
110
|
+
mapping = validate_mapping(document)
|
|
111
|
+
anchors = mapping["anchors"]
|
|
112
|
+
visiting: list[str] = []
|
|
113
|
+
|
|
114
|
+
def resolve(name: str) -> dict[str, Any]:
|
|
115
|
+
if name in visiting:
|
|
116
|
+
cycle = " -> ".join(visiting + [name])
|
|
117
|
+
raise LocatorMapError(f"anchor reference cycle: {cycle}")
|
|
118
|
+
if name not in anchors:
|
|
119
|
+
raise LocatorMapError(f"unknown anchor '{name}' (known: {', '.join(sorted(anchors))})")
|
|
120
|
+
visiting.append(name)
|
|
121
|
+
value = anchors[name]
|
|
122
|
+
if isinstance(value, dict) and set(value) == {"ref"}:
|
|
123
|
+
result = resolve(value["ref"])
|
|
124
|
+
else:
|
|
125
|
+
result = copy.deepcopy(value)
|
|
126
|
+
table = result.get("table") if isinstance(result, dict) else None
|
|
127
|
+
if isinstance(table, dict) and set(table) == {"ref"}:
|
|
128
|
+
referenced = resolve(table["ref"])
|
|
129
|
+
if "table" not in referenced:
|
|
130
|
+
raise LocatorMapError(f"anchor '{table['ref']}' is not a table target")
|
|
131
|
+
result["table"] = copy.deepcopy(referenced["table"])
|
|
132
|
+
if "table_kind" not in result and "table_kind" in referenced:
|
|
133
|
+
result["table_kind"] = referenced["table_kind"]
|
|
134
|
+
visiting.pop()
|
|
135
|
+
return result
|
|
136
|
+
|
|
137
|
+
result = resolve(anchor)
|
|
138
|
+
_target(result, f"anchor:{mapping['page']}/{anchor}")
|
|
139
|
+
return result
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def expand_anchor_reference(reference: str, locator_dir: Path) -> tuple[dict[str, Any], str]:
|
|
143
|
+
match = _ANCHOR_RE.fullmatch(reference.strip()) if isinstance(reference, str) else None
|
|
144
|
+
if not match:
|
|
145
|
+
raise LocatorMapError("anchor locator must be 'anchor:<page-key>/<anchor-name>'")
|
|
146
|
+
page, name = match.groups()
|
|
147
|
+
if not _PAGE_RE.fullmatch(page):
|
|
148
|
+
raise LocatorMapError("anchor page key is not path-safe")
|
|
149
|
+
path = locator_dir / f"{page}.json"
|
|
150
|
+
mapping = load_locator_map(path)
|
|
151
|
+
if mapping["page"] != page:
|
|
152
|
+
raise LocatorMapError(f"{path}: page must equal filename key '{page}'")
|
|
153
|
+
return expand_mapping(mapping, name), reference.strip()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
__all__ = ["LocatorMapError", "expand_anchor_reference", "expand_mapping", "load_locator_map", "validate_mapping"]
|
|
@@ -127,6 +127,7 @@ def configured_checkers(workflow, step_id)
|
|
|
127
127
|
when "integration-verify" then %w[requirement-graph-check slice-independence-check contract-coverage-check change-impact-check]
|
|
128
128
|
when "change-plan" then %w[requirement-graph-check slice-independence-check contract-coverage-check api-test-matrix-check test-coverage-check test-obligation-check test-task-binding-check change-impact-check]
|
|
129
129
|
when "acceptance-test-design" then %w[requirement-graph-check contract-coverage-check acceptance-coverage-check api-test-matrix-check test-coverage-check test-obligation-check change-impact-check]
|
|
130
|
+
when "regression-test-design" then %w[test-coverage-check test-obligation-check change-impact-check]
|
|
130
131
|
when "run-acceptance-tests" then %w[api-test-matrix-check test-coverage-check acceptance-test-results-check change-impact-check]
|
|
131
132
|
when "verify" then %w[acceptance-coverage-check acceptance-test-results-check change-impact-check]
|
|
132
133
|
when "execute" then %w[tdd-evidence-check change-impact-check]
|
|
@@ -43,7 +43,7 @@ ARTIFACT_KINDS = frozenset({"trace", "screenshot", "network"})
|
|
|
43
43
|
EXECUTION_ID_RE = re.compile(r"^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{8}$")
|
|
44
44
|
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
45
45
|
KILL_GRACE_SECONDS = 5
|
|
46
|
-
STEP_SUMMARY_KEYS = ("id", "phase", "driver", "action", "status", "duration_ms", "locator", "match_count", "http_status", "assertion", "error")
|
|
46
|
+
STEP_SUMMARY_KEYS = ("id", "phase", "driver", "action", "status", "duration_ms", "locator", "locator_strategy", "locator_details", "match_count", "http_status", "assertion", "error")
|
|
47
47
|
|
|
48
48
|
|
|
49
49
|
def utc_now() -> str:
|