@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.
- package/README.md +43 -11
- package/README.zh-TW.md +19 -3
- package/bin/maggie.js +5 -1
- package/bundled-contracts/maggiedash/README.md +3 -0
- package/bundled-contracts/maggiedash/quality-contracts.md +58 -0
- package/bundled-skills/README.md +1 -0
- package/bundled-skills/catalog.json +4 -0
- package/bundled-skills/maggie-dash/SKILL.md +31 -8
- package/bundled-skills/maggie-deployment/SKILL.md +15 -0
- package/bundled-skills/maggie-qa-workflow/SKILL.md +102 -0
- package/bundled-skills/maggie-seo-geo/SKILL.md +1 -1
- package/bundled-templates/maggiedash/section-fanout.json +84 -8
- package/bundled-templates/maggiedash/section-registry.json +3 -3
- package/bundled-tools/clis/maggie_dash.py +48 -2
- package/bundled-tools/clis/maggie_migration.py +67 -0
- package/bundled-tools/clis/maggie_qa_workflow.py +367 -0
- package/bundled-tools/runtime/maggie_quality.py +231 -0
- package/bundled-tools/runtime/maggie_sections.py +112 -12
- package/bundled-tools/runtime/maggie_sitemap.py +9 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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())
|