@topy-ai/maggie 0.7.12 → 0.7.14

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.
@@ -24,6 +24,7 @@ from maggie_dash_store import MaggieDashStore # noqa: E402
24
24
  from service_variants import ServiceVariantStore # noqa: E402
25
25
  from maggie_dash_ui import load_and_validate # noqa: E402
26
26
  from maggie_sections import catalogue, remap_translations, section_id_migration, validate_registry, copy_notes, validate_values, validate_fanout, reconcile_fields, validate_locale_coverage # noqa: E402
27
+ from maggie_quality import validate_variant_copy, validate_media_uniqueness, classify_inventory, validate_bindings, validate_reconcile_contract # noqa: E402
27
28
  from route_imports import classify_bindings # noqa: E402
28
29
 
29
30
 
@@ -358,7 +359,29 @@ def command_sections(args: argparse.Namespace) -> int:
358
359
  if args.sections_command == "locale-validate":
359
360
  sections = json.loads(Path(args.sections_file).resolve().read_text(encoding="utf-8"))
360
361
  translations = json.loads(Path(args.translations_file).resolve().read_text(encoding="utf-8"))
361
- result = validate_locale_coverage(args.page_id, sections, translations, args.locale)
362
+ registry = json.loads(Path(args.registry).resolve().read_text(encoding="utf-8")) if args.registry else None
363
+ result = validate_locale_coverage(args.page_id, sections, translations, args.locale, registry=registry)
364
+ emit(result)
365
+ return 0 if result["passed"] else 1
366
+ if args.sections_command == "variant-copy-validate":
367
+ value = json.loads(Path(args.pages_file).resolve().read_text(encoding="utf-8"))
368
+ result = validate_variant_copy(value, args.field or ["faq"])
369
+ emit(result)
370
+ return 0 if result["passed"] else 1
371
+ if args.sections_command == "media-validate":
372
+ value = json.loads(Path(args.pages_file).resolve().read_text(encoding="utf-8"))
373
+ result = validate_media_uniqueness(value, across_siblings=args.across_siblings)
374
+ emit(result)
375
+ return 0 if result["passed"] else 1
376
+ if args.sections_command == "bindings-validate":
377
+ sections = json.loads(Path(args.sections_file).resolve().read_text(encoding="utf-8"))
378
+ references = json.loads(Path(args.references_file).resolve().read_text(encoding="utf-8"))
379
+ result = validate_bindings(sections, references)
380
+ emit(result)
381
+ return 0 if result["passed"] else 1
382
+ if args.sections_command == "idempotency-validate":
383
+ contract = json.loads(Path(args.contract_file).resolve().read_text(encoding="utf-8"))
384
+ result = validate_reconcile_contract(contract)
362
385
  emit(result)
363
386
  return 0 if result["passed"] else 1
364
387
  registry = json.loads(Path(args.registry).resolve().read_text(encoding="utf-8"))
@@ -396,6 +419,14 @@ def command_components_audit(args: argparse.Namespace) -> int:
396
419
  return 0 if result["passed"] else 1
397
420
 
398
421
 
422
+ def command_inventory(args: argparse.Namespace) -> int:
423
+ """Classify each published page once, with no broad predicate overlap."""
424
+ value = json.loads(Path(args.pages_file).resolve().read_text(encoding="utf-8"))
425
+ result = classify_inventory(value)
426
+ emit(result)
427
+ return 0 if result["passed"] else 1
428
+
429
+
399
430
  def variant_store(args: argparse.Namespace) -> ServiceVariantStore:
400
431
  return ServiceVariantStore(project_root(args) / ".maggie" / "service-variants.json")
401
432
 
@@ -518,8 +549,20 @@ def parser() -> argparse.ArgumentParser:
518
549
  sections_reconcile.add_argument("--current-file", required=True); sections_reconcile.add_argument("--desired-file", required=True); sections_reconcile.add_argument("--output", required=True); sections_reconcile.add_argument("--force", action="store_true"); sections_reconcile.add_argument("--confirm", action="store_true")
519
550
  sections_reconcile.set_defaults(func=command_sections)
520
551
  sections_locale = sections_sub.add_parser("locale-validate", help="check locale sidecar coverage for stored image alt fields")
521
- sections_locale.add_argument("--page-id", required=True); sections_locale.add_argument("--sections-file", required=True); sections_locale.add_argument("--translations-file", required=True); sections_locale.add_argument("--locale", action="append", required=True)
552
+ sections_locale.add_argument("--page-id", required=True); sections_locale.add_argument("--sections-file", required=True); sections_locale.add_argument("--translations-file", required=True); sections_locale.add_argument("--locale", action="append", required=True); sections_locale.add_argument("--registry", help="registry for all declared translatable fields")
522
553
  sections_locale.set_defaults(func=command_sections)
554
+ sections_variants = sections_sub.add_parser("variant-copy-validate", help="detect duplicated copy across sibling variants")
555
+ sections_variants.add_argument("--pages-file", required=True); sections_variants.add_argument("--field", action="append", default=[])
556
+ sections_variants.set_defaults(func=command_sections)
557
+ sections_media = sections_sub.add_parser("media-validate", help="detect duplicate media within pages or sibling families")
558
+ sections_media.add_argument("--pages-file", required=True); sections_media.add_argument("--across-siblings", action="store_true")
559
+ sections_media.set_defaults(func=command_sections)
560
+ sections_bindings = sections_sub.add_parser("bindings-validate", help="resolve stored section references against a reference inventory")
561
+ sections_bindings.add_argument("--sections-file", required=True); sections_bindings.add_argument("--references-file", required=True)
562
+ sections_bindings.set_defaults(func=command_sections)
563
+ sections_idempotency = sections_sub.add_parser("idempotency-validate", help="validate a migration repair selection and convergence contract")
564
+ sections_idempotency.add_argument("--contract-file", required=True)
565
+ sections_idempotency.set_defaults(func=command_sections)
523
566
  sections_keys.set_defaults(func=command_sections)
524
567
  components = sub.add_parser("components-audit", help="classify route component bindings against live section/page inventories")
525
568
  components.add_argument("--bindings-file", required=True)
@@ -527,6 +570,9 @@ def parser() -> argparse.ArgumentParser:
527
570
  components.add_argument("--pages-file", required=True, help="JSON array/object of live page paths")
528
571
  components.add_argument("--output")
529
572
  components.set_defaults(func=command_components_audit)
573
+ inventory = sub.add_parser("inventory", help="classify published pages into disjoint band/code-rendered kinds")
574
+ inventory.add_argument("--pages-file", required=True, help="JSON page inventory")
575
+ inventory.set_defaults(func=command_inventory)
530
576
  variant = sub.add_parser("variant", help="manage service variant lifecycle")
531
577
  variant_sub = variant.add_subparsers(dest="variant_command", required=True)
532
578
  create = variant_sub.add_parser("create"); create.add_argument("--project", default="."); create.add_argument("--service-id", required=True); create.add_argument("--variant-id", required=True); create.add_argument("--variant-type", required=True); create.add_argument("--locale", required=True); create.add_argument("--market", required=True); create.add_argument("--slug", required=True); create.add_argument("--title", required=True); create.add_argument("--facts", required=True); create.add_argument("--source-revision", required=True); create.add_argument("--canonical-variant-id"); create.add_argument("--cluster-link", action="append", default=[]); create.add_argument("--layout-family", default="service-default"); create.add_argument("--confirm", action="store_true")
@@ -4,6 +4,7 @@
4
4
  from __future__ import annotations
5
5
 
6
6
  import argparse
7
+ import hashlib
7
8
  import json
8
9
  import re
9
10
  import sys
@@ -54,7 +55,73 @@ def validate_project(project: Path, environment: str) -> dict:
54
55
  return result
55
56
 
56
57
 
58
+ IDENTITY_SCHEMA = "maggie-database-identity.v1"
59
+
60
+
61
+ def _identity_fingerprint(identity: dict) -> str:
62
+ material = {key: identity.get(key) for key in ("environment", "targetName", "databaseName", "serverIdentity", "serviceIdentity", "rowCounts")}
63
+ return hashlib.sha256(json.dumps(material, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()).hexdigest()
64
+
65
+
66
+ def validate_identity(identity: object, expected: object | None = None) -> dict:
67
+ """Gate a write against a non-secret database identity report.
68
+
69
+ The report intentionally contains names, server identity and row counts,
70
+ never a connection string. Comparing it with the service/deployment
71
+ identity catches the common case where staging and production credentials
72
+ resolve to different or unexpectedly identical databases.
73
+ """
74
+ errors: list[str] = []
75
+ if not isinstance(identity, dict):
76
+ return {"schemaVersion": IDENTITY_SCHEMA, "passed": False, "errors": ["identity report must be an object"], "mutation": "not executed"}
77
+ if identity.get("schemaVersion") != IDENTITY_SCHEMA:
78
+ errors.append(f"schemaVersion must be {IDENTITY_SCHEMA}")
79
+ for field in ("environment", "targetName", "databaseName", "serverIdentity", "serviceIdentity"):
80
+ if not str(identity.get(field) or "").strip():
81
+ errors.append(f"{field} is required")
82
+ if not isinstance(identity.get("rowCounts"), dict):
83
+ errors.append("rowCounts must be an object")
84
+ fingerprint = str(identity.get("fingerprint") or "")
85
+ if not re.fullmatch(r"[0-9a-f]{64}", fingerprint):
86
+ errors.append("fingerprint must be a SHA-256 hex digest")
87
+ elif fingerprint != _identity_fingerprint(identity):
88
+ errors.append("fingerprint does not match the reported identity")
89
+ compared: list[str] = []
90
+ if expected is not None:
91
+ if not isinstance(expected, dict):
92
+ errors.append("expected identity report must be an object")
93
+ else:
94
+ for field in ("environment", "targetName", "databaseName", "serverIdentity", "serviceIdentity"):
95
+ if str(identity.get(field)) != str(expected.get(field)):
96
+ errors.append(f"target identity mismatch: {field}")
97
+ else:
98
+ compared.append(field)
99
+ return {"schemaVersion": IDENTITY_SCHEMA, "passed": not errors, "errors": errors, "mutation": "not executed", "compared": compared, "target": {key: identity.get(key) for key in ("environment", "targetName", "databaseName", "serverIdentity", "serviceIdentity", "rowCounts")}}
100
+
101
+
102
+ def identity_main(argv: list[str]) -> int:
103
+ parser = argparse.ArgumentParser(description="Validate a secret-free database target identity before migration writes.")
104
+ parser.add_argument("--identity-file", required=True)
105
+ parser.add_argument("--expected-file")
106
+ parser.add_argument("--output")
107
+ args = parser.parse_args(argv)
108
+ try:
109
+ actual = json.loads(Path(args.identity_file).read_text(encoding="utf-8"))
110
+ expected = json.loads(Path(args.expected_file).read_text(encoding="utf-8")) if args.expected_file else None
111
+ result = validate_identity(actual, expected)
112
+ except (OSError, json.JSONDecodeError) as error:
113
+ result = {"schemaVersion": IDENTITY_SCHEMA, "passed": False, "errors": [f"cannot read identity report: {error}"], "mutation": "not executed"}
114
+ if args.output:
115
+ output = Path(args.output)
116
+ output.parent.mkdir(parents=True, exist_ok=True)
117
+ output.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
118
+ print(json.dumps(result, indent=2, ensure_ascii=False))
119
+ return 0 if result["passed"] else 1
120
+
121
+
57
122
  def main() -> int:
123
+ if sys.argv[1:2] == ["identity"]:
124
+ return identity_main(sys.argv[2:])
58
125
  parser = argparse.ArgumentParser(description=__doc__)
59
126
  parser.add_argument("manifest", nargs="?")
60
127
  parser.add_argument("--project")
@@ -0,0 +1,367 @@
1
+ #!/usr/bin/env python3
2
+ """Track project scenario-based browser QA from test through retest.
3
+
4
+ The command stores secret-free run state in ``.maggie/qa-runs``. Browser
5
+ interaction remains with the selected browser skill; this CLI records the
6
+ scenario, evidence references, fix event and final release decision.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import hashlib
13
+ import json
14
+ import os
15
+ import re
16
+ import sys
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+ from urllib.parse import urlsplit, urlunsplit
20
+
21
+
22
+ DEFAULT_SCENARIOS = Path(".maggie") / "scenario-manifest.json"
23
+ VALID_STATUSES = {"pending", "pass", "fail", "blocked", "inconclusive"}
24
+ VALID_PHASES = {"test", "fix", "retest"}
25
+ SECRET_RE = re.compile(
26
+ r"(?i)(bearer\s+|(?:api[_-]?key|token|secret|password|authorization|cookie)\s*[=:]\s*)[^\s,;]+"
27
+ )
28
+ EMAIL_RE = re.compile(r"\b[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}\b")
29
+ QUERY_SECRET_RE = re.compile(r"(?i)([?&](?:token|key|secret|password|code|id_token|access_token)=)[^&\s]+")
30
+
31
+
32
+ def now() -> str:
33
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
34
+
35
+
36
+ def safe_text(value: object, limit: int = 1200) -> str:
37
+ text = str(value or "").strip()
38
+ text = SECRET_RE.sub(r"\1[REDACTED]", text)
39
+ text = QUERY_SECRET_RE.sub(r"\1[REDACTED]", text)
40
+ text = EMAIL_RE.sub("[REDACTED_EMAIL]", text)
41
+ return text[:limit]
42
+
43
+
44
+ def safe_base_url(value: object) -> str:
45
+ """Keep the origin only so reports do not retain private paths or queries."""
46
+ text = safe_text(value, 500)
47
+ try:
48
+ parsed = urlsplit(text)
49
+ if parsed.scheme and parsed.hostname:
50
+ netloc = parsed.hostname
51
+ if parsed.port:
52
+ netloc = f"{netloc}:{parsed.port}"
53
+ return urlunsplit((parsed.scheme, netloc, "", "", ""))
54
+ except ValueError:
55
+ pass
56
+ return text
57
+
58
+
59
+ def project_root(value: str | os.PathLike[str]) -> Path:
60
+ return Path(value).expanduser().resolve()
61
+
62
+
63
+ def load_json(path: Path) -> dict:
64
+ try:
65
+ value = json.loads(path.read_text(encoding="utf-8"))
66
+ except (OSError, json.JSONDecodeError) as error:
67
+ raise ValueError(f"could not read JSON {path}: {error}") from error
68
+ if not isinstance(value, dict):
69
+ raise ValueError(f"JSON root must be an object: {path}")
70
+ return value
71
+
72
+
73
+ def scenario_records(path: Path) -> list[dict]:
74
+ manifest = load_json(path)
75
+ scenarios = manifest.get("scenarios")
76
+ if not isinstance(scenarios, list) or not scenarios:
77
+ raise ValueError("scenario manifest must contain a non-empty scenarios array")
78
+ result = []
79
+ seen: set[str] = set()
80
+ for item in scenarios:
81
+ if not isinstance(item, dict) or not isinstance(item.get("id"), str):
82
+ raise ValueError("each scenario must be an object with an id")
83
+ scenario_id = item["id"]
84
+ if scenario_id in seen:
85
+ raise ValueError(f"duplicate scenario id: {scenario_id}")
86
+ seen.add(scenario_id)
87
+ result.append(item)
88
+ return result
89
+
90
+
91
+ def run_directory(project: Path) -> Path:
92
+ directory = project / ".maggie" / "qa-runs"
93
+ directory.mkdir(parents=True, exist_ok=True)
94
+ return directory
95
+
96
+
97
+ def normal_run_id(value: str | None) -> str:
98
+ if value:
99
+ value = re.sub(r"[^A-Za-z0-9._-]+", "-", value).strip("-")
100
+ if not value:
101
+ raise ValueError("run id cannot be empty")
102
+ return value
103
+ return "qa-" + datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
104
+
105
+
106
+ def run_path(project: Path, value: str) -> Path:
107
+ candidate = Path(value).expanduser()
108
+ if candidate.suffix == ".json" or candidate.parent != Path("."):
109
+ path = candidate if candidate.is_absolute() else project / candidate
110
+ else:
111
+ path = run_directory(project) / f"{normal_run_id(value)}.json"
112
+ path = path.resolve()
113
+ if not path.is_file():
114
+ raise ValueError(f"QA run does not exist: {path}")
115
+ return path
116
+
117
+
118
+ def write_json(path: Path, value: dict) -> None:
119
+ path.parent.mkdir(parents=True, exist_ok=True)
120
+ temporary = path.with_suffix(path.suffix + ".tmp")
121
+ temporary.write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
122
+ temporary.replace(path)
123
+
124
+
125
+ def calculate(run: dict) -> dict:
126
+ counts = {status: 0 for status in VALID_STATUSES}
127
+ lifecycle_counts = {"ready": 0, "fix_pending": 0}
128
+ for item in run.get("scenarios", {}).values():
129
+ status = item.get("status", "pending")
130
+ counts[status] = counts.get(status, 0) + 1
131
+ lifecycle_counts["fix_pending" if item.get("lifecycle") == "fix_pending" else "ready"] += 1
132
+ if counts["fail"]:
133
+ gate = "fail"
134
+ elif counts["pending"] or counts["blocked"] or counts["inconclusive"]:
135
+ gate = "blocked"
136
+ else:
137
+ gate = "pass"
138
+ return {"gate": gate, "counts": counts, "lifecycle": lifecycle_counts, "total": sum(counts.values())}
139
+
140
+
141
+ def start(args: argparse.Namespace) -> int:
142
+ project = project_root(args.project)
143
+ manifest_path = project_root(args.scenario_file) if Path(args.scenario_file).is_absolute() else project / args.scenario_file
144
+ records = scenario_records(manifest_path)
145
+ run_id = normal_run_id(args.run_id)
146
+ path = run_directory(project) / f"{run_id}.json"
147
+ if path.exists():
148
+ raise ValueError(f"QA run already exists: {path}")
149
+ scenarios = {
150
+ item["id"]: {
151
+ "id": item["id"],
152
+ "group": item.get("group", ""),
153
+ "title": item.get("title", ""),
154
+ "priority": item.get("priority", "P2"),
155
+ "routes": item.get("routes", []),
156
+ "auth": item.get("auth", ""),
157
+ "browser": bool(item.get("browser", True)),
158
+ "status": "pending",
159
+ "lifecycle": "ready",
160
+ "history": [],
161
+ }
162
+ for item in records
163
+ }
164
+ try:
165
+ manifest_label = str(manifest_path.relative_to(project))
166
+ except ValueError:
167
+ # Do not persist an absolute path outside the project. The basename is
168
+ # enough to identify the source without leaking a local filesystem
169
+ # layout into a shareable run artifact.
170
+ manifest_label = manifest_path.name
171
+ run = {
172
+ "schemaVersion": "maggie.qa-run.v1",
173
+ "runId": run_id,
174
+ "createdAt": now(),
175
+ "updatedAt": now(),
176
+ "project": hashlib.sha256(str(project).encode()).hexdigest()[:16],
177
+ "environment": safe_text(args.environment),
178
+ "baseUrl": safe_base_url(args.base_url),
179
+ "browser": safe_text(args.browser),
180
+ "commit": safe_text(args.commit),
181
+ "release": safe_text(args.release),
182
+ "scenarioManifest": manifest_label,
183
+ "scenarios": scenarios,
184
+ }
185
+ run["summary"] = calculate(run)
186
+ write_json(path, run)
187
+ print(json.dumps({"runId": run_id, "path": str(path), "summary": run["summary"]}, ensure_ascii=False, indent=2))
188
+ return 0
189
+
190
+
191
+ def evidence(value: str, project: Path) -> dict:
192
+ clean = safe_text(value, 500)
193
+ path = Path(value).expanduser()
194
+ if path.is_file():
195
+ resolved = path.resolve()
196
+ try:
197
+ display = str(resolved.relative_to(project))
198
+ except ValueError:
199
+ display = resolved.name
200
+ return {"path": display, "sha256": hashlib.sha256(resolved.read_bytes()).hexdigest()}
201
+ return {"reference": clean}
202
+
203
+
204
+ def record(args: argparse.Namespace) -> int:
205
+ project = project_root(args.project)
206
+ path = run_path(project, args.run)
207
+ run = load_json(path)
208
+ scenarios = run.get("scenarios", {})
209
+ item = scenarios.get(args.scenario)
210
+ if not isinstance(item, dict):
211
+ raise ValueError(f"scenario is not in this run: {args.scenario}")
212
+ phase = args.phase
213
+ if phase not in VALID_PHASES:
214
+ raise ValueError(f"phase must be one of: {', '.join(sorted(VALID_PHASES))}")
215
+ if phase in {"test", "retest"} and args.status not in VALID_STATUSES - {"pending"}:
216
+ raise ValueError("test and retest require status pass, fail, blocked or inconclusive")
217
+ if phase == "fix" and not args.resolution:
218
+ raise ValueError("fix requires --resolution")
219
+ if phase == "fix" and item.get("status") != "fail":
220
+ raise ValueError("fix can only follow a recorded fail; use retest after a blocked or inconclusive run")
221
+ if phase == "retest" and not any(event.get("phase") == "test" for event in item.get("history", [])):
222
+ raise ValueError("retest requires an earlier test event")
223
+ if phase == "retest" and item.get("status") == "fail" and not any(event.get("phase") == "fix" for event in item.get("history", [])):
224
+ raise ValueError("a failed scenario must have a recorded fix before retest")
225
+ if phase == "test" and args.status == "fail" and not (args.expected and args.actual and args.error_fingerprint):
226
+ raise ValueError("a failed test requires --expected, --actual and --error-fingerprint")
227
+ event = {
228
+ "at": now(),
229
+ "phase": phase,
230
+ "status": args.status if phase != "fix" else "fix_pending",
231
+ "summary": safe_text(args.summary),
232
+ "expected": safe_text(args.expected),
233
+ "actual": safe_text(args.actual),
234
+ "errorFingerprint": safe_text(args.error_fingerprint, 300),
235
+ "issue": safe_text(args.issue, 500),
236
+ "resolution": safe_text(args.resolution),
237
+ "validation": safe_text(args.validation),
238
+ "evidence": [evidence(value, project) for value in args.evidence],
239
+ }
240
+ item.setdefault("history", []).append(event)
241
+ if phase == "fix":
242
+ item["lifecycle"] = "fix_pending"
243
+ else:
244
+ item["status"] = args.status
245
+ item["lifecycle"] = "ready"
246
+ run["updatedAt"] = now()
247
+ run["summary"] = calculate(run)
248
+ write_json(path, run)
249
+ print(json.dumps({"runId": run.get("runId"), "scenario": args.scenario, "event": event, "summary": run["summary"]}, ensure_ascii=False, indent=2))
250
+ return 0
251
+
252
+
253
+ def markdown(run: dict) -> str:
254
+ summary = run.get("summary") or calculate(run)
255
+ lines = [
256
+ f"# Maggie QA run `{run.get('runId', '')}`",
257
+ "",
258
+ "<!-- Generated by maggie_qa_workflow.py. Evidence is metadata-only. -->",
259
+ "",
260
+ f"- Environment: `{run.get('environment') or 'not provided'}`",
261
+ f"- Base URL: `{run.get('baseUrl') or 'not provided'}`",
262
+ f"- Browser: `{run.get('browser') or 'not provided'}`",
263
+ f"- Commit/release: `{run.get('commit') or 'not provided'}` / `{run.get('release') or 'not provided'}`",
264
+ f"- Gate: **{summary.get('gate', 'blocked').upper()}**",
265
+ "",
266
+ "## Scenario summary",
267
+ "",
268
+ "| ID | Priority | Scenario | Status | Lifecycle | Last result |",
269
+ "|---|---|---|---|---|---|",
270
+ ]
271
+ for scenario in run.get("scenarios", {}).values():
272
+ history = scenario.get("history", [])
273
+ last = history[-1] if history else {}
274
+ result = safe_text(last.get("summary") or last.get("actual") or "Not tested", 180).replace("|", "\\|")
275
+ lines.append(f"| {scenario.get('id')} | {scenario.get('priority')} | {scenario.get('title')} | **{scenario.get('status')}** | {scenario.get('lifecycle')} | {result} |")
276
+ lines += ["", "## Fix and retest history", ""]
277
+ for scenario in run.get("scenarios", {}).values():
278
+ for event in scenario.get("history", []):
279
+ lines.append(f"### {scenario.get('id')} — {event.get('phase')} ({event.get('at')})")
280
+ lines.append("")
281
+ if event.get("summary"): lines.append(f"- Summary: {event['summary']}")
282
+ if event.get("expected"): lines.append(f"- Expected: {event['expected']}")
283
+ if event.get("actual"): lines.append(f"- Actual: {event['actual']}")
284
+ if event.get("errorFingerprint"): lines.append(f"- Error fingerprint: `{event['errorFingerprint']}`")
285
+ if event.get("resolution"): lines.append(f"- Resolution: {event['resolution']}")
286
+ if event.get("validation"): lines.append(f"- Validation: {event['validation']}")
287
+ references = event.get("evidence", [])
288
+ if references: lines.append(f"- Evidence: {', '.join(str(value.get('path') or value.get('reference')) for value in references)}")
289
+ lines.append("")
290
+ return "\n".join(lines).rstrip() + "\n"
291
+
292
+
293
+ def summary(args: argparse.Namespace) -> int:
294
+ project = project_root(args.project)
295
+ run = load_json(run_path(project, args.run))
296
+ result = run.get("summary") or calculate(run)
297
+ if args.format == "markdown":
298
+ print(markdown(run))
299
+ else:
300
+ print(json.dumps({"runId": run.get("runId"), **result}, ensure_ascii=False, indent=2))
301
+ return 0 if result.get("gate") == "pass" else 1
302
+
303
+
304
+ def export_run(args: argparse.Namespace) -> int:
305
+ project = project_root(args.project)
306
+ run = load_json(run_path(project, args.run))
307
+ output = Path(args.output).expanduser()
308
+ if not output.is_absolute(): output = project / output
309
+ output.parent.mkdir(parents=True, exist_ok=True)
310
+ output.write_text(markdown(run), encoding="utf-8")
311
+ print(json.dumps({"runId": run.get("runId"), "output": str(output.resolve()), "gate": run.get("summary", {}).get("gate")}, ensure_ascii=False, indent=2))
312
+ return 0
313
+
314
+
315
+ def parser() -> argparse.ArgumentParser:
316
+ root = argparse.ArgumentParser(description=__doc__)
317
+ root.add_argument("--project", default=".")
318
+ commands = root.add_subparsers(dest="command", required=True)
319
+
320
+ start_parser = commands.add_parser("start")
321
+ start_parser.add_argument("--project", default=argparse.SUPPRESS)
322
+ start_parser.add_argument("--scenario-file", default=str(DEFAULT_SCENARIOS))
323
+ start_parser.add_argument("--run-id")
324
+ start_parser.add_argument("--environment", required=True)
325
+ start_parser.add_argument("--base-url", required=True)
326
+ start_parser.add_argument("--browser", default="chrome")
327
+ start_parser.add_argument("--commit", default="")
328
+ start_parser.add_argument("--release", default="")
329
+
330
+ record_parser = commands.add_parser("record")
331
+ record_parser.add_argument("--project", default=argparse.SUPPRESS)
332
+ record_parser.add_argument("--run", required=True)
333
+ record_parser.add_argument("--scenario", required=True)
334
+ record_parser.add_argument("--phase", choices=sorted(VALID_PHASES), required=True)
335
+ record_parser.add_argument("--status", choices=sorted(VALID_STATUSES - {"pending"}))
336
+ record_parser.add_argument("--summary", default="")
337
+ record_parser.add_argument("--expected", default="")
338
+ record_parser.add_argument("--actual", default="")
339
+ record_parser.add_argument("--error-fingerprint", default="")
340
+ record_parser.add_argument("--issue", default="")
341
+ record_parser.add_argument("--resolution", default="")
342
+ record_parser.add_argument("--validation", default="")
343
+ record_parser.add_argument("--evidence", action="append", default=[])
344
+
345
+ for name in ("summary", "export"):
346
+ command = commands.add_parser(name)
347
+ command.add_argument("--project", default=argparse.SUPPRESS)
348
+ command.add_argument("--run", required=True)
349
+ if name == "summary": command.add_argument("--format", choices=("json", "markdown"), default="json")
350
+ else: command.add_argument("--output", required=True)
351
+ return root
352
+
353
+
354
+ def main() -> int:
355
+ args = parser().parse_args()
356
+ try:
357
+ if args.command == "start": return start(args)
358
+ if args.command == "record": return record(args)
359
+ if args.command == "summary": return summary(args)
360
+ return export_run(args)
361
+ except (OSError, ValueError, json.JSONDecodeError) as error:
362
+ print(f"QA workflow error: {safe_text(error)}", file=sys.stderr)
363
+ return 2
364
+
365
+
366
+ if __name__ == "__main__":
367
+ raise SystemExit(main())