@topy-ai/maggie 0.7.40 → 0.7.41
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-zh-TW.md +29 -4
- package/README.md +35 -1
- package/bin/maggie.js +24 -5
- package/bundled-contracts/maggie-clone/interaction-state-v1.schema.json +26 -0
- package/bundled-contracts/maggie-content/provenance-v1.schema.json +20 -0
- package/bundled-contracts/maggie-design/brand-kit-v1.schema.json +18 -0
- package/bundled-contracts/maggie-design/browser-interactions-v1.schema.json +27 -0
- package/bundled-contracts/maggie-design/style-editing-v1.schema.json +35 -0
- package/bundled-contracts/maggie-media/image-generation-policy-v1.json +28 -0
- package/bundled-contracts/maggie-media/video-generation-policy-v1.json +40 -0
- package/bundled-contracts/maggie-media/video-job-v1.schema.json +20 -0
- package/bundled-contracts/maggie-media/video-playback-evidence-v1.schema.json +15 -0
- package/bundled-contracts/maggie-ops/npm11-preflight-v1.schema.json +17 -0
- package/bundled-contracts/maggie-scaffold/host-scaffold-v1.schema.json +25 -0
- package/bundled-contracts/maggie-seo/gsc-readiness-v1.schema.json +19 -0
- package/bundled-contracts/maggie-service-booking/delivery-provider-default-v1.json +8 -0
- package/bundled-contracts/maggie-service-booking/delivery-provider-v1.schema.json +16 -0
- package/bundled-contracts/maggiedash/browser-session-v1.schema.json +18 -0
- package/bundled-contracts/maggiedash/content-overrides-v1.schema.json +19 -0
- package/bundled-contracts/maggiedash/public-session-cache-v1.schema.json +17 -0
- package/bundled-references/browser-inspection.md +21 -0
- package/bundled-skills/maggie-blog/SKILL.md +12 -0
- package/bundled-skills/maggie-blog-bootstrap/SKILL.md +23 -0
- package/bundled-skills/maggie-booking/SKILL.md +15 -0
- package/bundled-skills/maggie-clone/SKILL.md +13 -0
- package/bundled-skills/maggie-deployment/SKILL.md +6 -0
- package/bundled-skills/maggie-design/SKILL.md +29 -3
- package/bundled-skills/maggie-ops/SKILL.md +12 -0
- package/bundled-skills/maggie-seo-geo/SKILL.md +33 -0
- package/bundled-tools/clis/maggie_analytics.py +43 -1
- package/bundled-tools/clis/maggie_browser_audit.py +99 -3
- package/bundled-tools/clis/maggie_clone.py +46 -1
- package/bundled-tools/clis/maggie_contracts.py +111 -0
- package/bundled-tools/clis/maggie_design.py +55 -0
- package/bundled-tools/clis/maggie_workflows.py +387 -0
- package/bundled-tools/clis/site_audit.py +28 -1
- package/bundled-tools/integrations/analytics.md +14 -0
- package/bundled-tools/runtime/site_baseline.py +3 -0
- package/package.json +1 -1
- package/references/browser-inspection.md +21 -0
|
@@ -27,6 +27,27 @@ interaction states and triggers
|
|
|
27
27
|
responsive differences
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
+
Behavioral checks may be declared instead of hidden in a bespoke script. The
|
|
31
|
+
manifest is safe to commit because input values are not copied into evidence:
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
{
|
|
35
|
+
"schemaVersion": "maggie-browser-interactions.v1",
|
|
36
|
+
"steps": [
|
|
37
|
+
{"id": "open", "action": "click", "selector": "[data-menu]"},
|
|
38
|
+
{"id": "visible", "action": "assert-visible", "selector": "[data-drawer]"},
|
|
39
|
+
{"id": "styles", "action": "assert-style", "selector": "[data-drawer]", "property": "display", "value": "block"},
|
|
40
|
+
{"id": "privacy", "action": "assert-no-request", "origin": "third-party.example"}
|
|
41
|
+
]
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Run it with `maggie browser-audit ... --interactions .maggie/interactions.json`.
|
|
46
|
+
Supported actions cover click, fill, type, select, press, wait, visibility and
|
|
47
|
+
state assertions, computed-style assertions, and new-request assertions. The
|
|
48
|
+
browser adapter owns the session and the audit records only step IDs and
|
|
49
|
+
redacted outcomes.
|
|
50
|
+
|
|
30
51
|
If a target requires login, a bot challenge, a consent interaction, or a
|
|
31
52
|
private browser profile, stop at that boundary and ask the user to provide
|
|
32
53
|
authorized access. Do not bypass access controls or record cookies in project
|
|
@@ -103,3 +103,15 @@ maggie blog integration-state --configured --authorized # ready
|
|
|
103
103
|
|
|
104
104
|
Provider adapters should preserve the same `not-configured`,
|
|
105
105
|
`awaiting-consent`, `awaiting-authorization`, `ready`, and `error` semantics.
|
|
106
|
+
|
|
107
|
+
Content evidence should also capture the Git source revision before an agent
|
|
108
|
+
writes or publishes a post:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
maggie provenance --project . --source src/content/post.md \
|
|
112
|
+
--output .maggie/content-provenance.json
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The report records commit, branch, dirty state, and authoring boundary. Host
|
|
116
|
+
write paths should reject stale writes unless the source revision still
|
|
117
|
+
matches; private paths and secrets are never included.
|
|
@@ -116,3 +116,26 @@ The project must have a passing foundation state, a real public route graph,
|
|
|
116
116
|
published-only sitemap/canonical metadata, responsive UI evidence, an explicit
|
|
117
117
|
Ops decision, and a documented rollback/deployment plan. A rendered button or
|
|
118
118
|
mock editor is not evidence that a backend operation works.
|
|
119
|
+
|
|
120
|
+
### Authenticated browser QA and CDN safety
|
|
121
|
+
|
|
122
|
+
Authenticated browser evidence must use a host-owned short-lived session
|
|
123
|
+
contract. The host mints and revokes the session, passwords never enter the
|
|
124
|
+
browser harness, TTL is bounded, and the token is never written to Maggie
|
|
125
|
+
artifacts. Validate the contract with:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
maggie dash session-check --manifest .maggie/browser-session.json
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Public HTML must be identical across sessions so a CDN cannot cache an
|
|
132
|
+
administrator control for anonymous visitors. Keep the real session cookie
|
|
133
|
+
`httpOnly`; use a non-authoritative readable hint cookie only to decide whether
|
|
134
|
+
the browser should request identity client-side. Validate evidence with:
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
maggie dash cache-boundary-check --evidence .maggie/public-session-cache.json
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
If a host cannot provide these routes, record the browser session feature as
|
|
141
|
+
deferred rather than inserting a database session from a shared CLI.
|
|
@@ -191,6 +191,21 @@ placeholders, owner/admin/manager access, audit and idempotency requirements,
|
|
|
191
191
|
and the server-only provider/recipient privacy boundary. It does not connect to
|
|
192
192
|
Resend, send a message, or expose customer data.
|
|
193
193
|
|
|
194
|
+
The selected transactional delivery boundary is Resend for email and Twilio
|
|
195
|
+
for SMS. Validate the host policy before wiring provider adapters:
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
maggie booking-delivery \
|
|
199
|
+
--policy .maggie/booking-delivery-policy.json
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
The policy requires provider acknowledgement before a booking can display a
|
|
203
|
+
confirmed state, uses a bounded exponential-jitter retry with an idempotency
|
|
204
|
+
key per booking event and channel, and moves exhausted work to a dead-letter
|
|
205
|
+
queue. Replay is an explicit manager action and must be audited. `RESEND_API_KEY`
|
|
206
|
+
and `TWILIO_AUTH_TOKEN` remain server-only host secrets; this gate never sends
|
|
207
|
+
email or SMS.
|
|
208
|
+
|
|
194
209
|
After the first owner signs in, the Booking Overview includes the same
|
|
195
210
|
secret-safe readiness path: Stripe connection status, test/live mode, and a
|
|
196
211
|
copyable `/api/maggie/booking/webhooks/stripe` URL. The preferred path is still
|
|
@@ -41,6 +41,19 @@ Each run persists `manifest.json`, `state.json`, and phase outputs under
|
|
|
41
41
|
`failed` and must be resumed after the defect is fixed; an incomplete run must
|
|
42
42
|
not be presented as a successful clone.
|
|
43
43
|
|
|
44
|
+
Replay approved interaction states with the same manifest used by
|
|
45
|
+
`maggie browser-audit`:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
maggie clone interactions --project <project-root> --run-id <stable-run-id> \
|
|
49
|
+
--browse <browse-cli> --interactions .maggie/browser-interactions.json
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The adapter replays at desktop, tablet, and mobile viewports and writes a
|
|
53
|
+
redacted `maggie-clone-interaction-state.v1` evidence file. It never stores
|
|
54
|
+
typed values, credentials, or raw provider payloads. A clone is not
|
|
55
|
+
interaction-complete until every approved step passes at all three viewports.
|
|
56
|
+
|
|
44
57
|
An exported local HTML file is also a valid source, for example:
|
|
45
58
|
|
|
46
59
|
```text
|
|
@@ -414,3 +414,9 @@ both units being enabled in systemd. Data-dependent releases additionally
|
|
|
414
414
|
require `rollback.backupId` and `rollback.restoreCommand` in
|
|
415
415
|
`.maggie/deployment/data-release.json`, because switching code alone does not
|
|
416
416
|
restore incompatible data.
|
|
417
|
+
|
|
418
|
+
`maggie update --help` is read-only. Help flags must be handled before project
|
|
419
|
+
discovery, install-manifest writes, or file synchronization. For fixture-backed
|
|
420
|
+
browser QA, the host owns the isolated dev server and data source; document a
|
|
421
|
+
second port/process and fixture mode in the host runbook instead of making the
|
|
422
|
+
shared updater or deployment CLI start an unmanaged daemon.
|
|
@@ -95,9 +95,20 @@ fallback. Mobile evidence must not assume that `preload="none"` will emit
|
|
|
95
95
|
`canplaythrough` without user interaction. Record poster visibility, playback
|
|
96
96
|
state, console errors, network errors, and a screenshot per viewport.
|
|
97
97
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
and
|
|
98
|
+
The playback contract is provider-neutral, while the selected generation
|
|
99
|
+
policy is Google Gemini: `gemini-omni-1.1-flash` for the default hero-video
|
|
100
|
+
route and `veo-3.1-generate-preview` for specialized continuation/frame
|
|
101
|
+
control. Validate the policy and job evidence separately:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
maggie media video-policy \
|
|
105
|
+
--policy contracts/maggie-media/video-generation-policy-v1.json
|
|
106
|
+
maggie media video-job --job .maggie/video-job.json
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Generation remains host-owned. The host must supply cost budget, rights and
|
|
110
|
+
moderation evidence, idempotency, object storage, and a redacted provenance
|
|
111
|
+
record before a generated asset is used in a release.
|
|
101
112
|
|
|
102
113
|
## Automatic memory hook
|
|
103
114
|
|
|
@@ -531,3 +542,18 @@ utilities. Dashboard plans require operator journeys and desktop/tablet/mobile,
|
|
|
531
542
|
collapsed-rail, mobile-drawer, and modal evidence. Sample plans require
|
|
532
543
|
noindex, sitemap exclusion, a visible sample banner, placeholders, and a
|
|
533
544
|
finish checklist.
|
|
545
|
+
|
|
546
|
+
For generated or converted pages, run the markup gate before handing the page
|
|
547
|
+
to a host editor:
|
|
548
|
+
|
|
549
|
+
```bash
|
|
550
|
+
maggie design markup-check --html src/pages/about.astro --require-content-keys
|
|
551
|
+
maggie design style-check --manifest .maggie/style-editing.json
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
Editable copy uses stable `data-maggie-content-key` values. Never key prices,
|
|
555
|
+
durations, opening times, contact details, entity identifiers, or mixed markup
|
|
556
|
+
without an explicit host contract. Decode HTML entities exactly once; unknown
|
|
557
|
+
or double-escaped entities stop the check. Optional empty attributes such as
|
|
558
|
+
`style=""` must be omitted. Codemods using compiler positions must anchor at
|
|
559
|
+
the opening `<tag` before slicing source text.
|
|
@@ -60,6 +60,18 @@ requires HTTP 200, a readable square image, and an engine-supported ICO, PNG,
|
|
|
60
60
|
or GIF response. A logo-shaped source filename is only an unverified source
|
|
61
61
|
candidate; it is never evidence that the public favicon route works.
|
|
62
62
|
|
|
63
|
+
For npm 11 installation or package release work, run the safe preflight before
|
|
64
|
+
an install mutation:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
maggie npm11 --project . --output .maggie/npm11-preflight.json
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The gate records the observed Node/npm versions, lockfile policy, lifecycle
|
|
71
|
+
script policy, and an ignore-scripts-first smoke boundary. A project with
|
|
72
|
+
`install`, `preinstall`, or `postinstall` scripts requires an explicit
|
|
73
|
+
maintainer decision; Maggie never runs an untrusted install script implicitly.
|
|
74
|
+
|
|
63
75
|
If the user does not name a mode, inspect the project and propose the smallest
|
|
64
76
|
mode that satisfies the request. Do not rebuild the public blog or change its
|
|
65
77
|
framework just to add Ops.
|
|
@@ -104,6 +104,29 @@ The strict flag is for a review window where a crawler request is expected; it
|
|
|
104
104
|
fails only when the supplied log contains no sitemap request. Never upload raw
|
|
105
105
|
logs or include IPs, credentials, or query data in feedback or reports.
|
|
106
106
|
|
|
107
|
+
### GSC readiness release gate
|
|
108
|
+
|
|
109
|
+
GSC readiness is part of the existing analytics release gate, not an implied
|
|
110
|
+
result of having a sitemap. Produce redacted evidence for the property,
|
|
111
|
+
verification, canonical origin, robots, sitemap, read-only authorization,
|
|
112
|
+
query/readback, and production smoke:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
maggie analytics gsc-readiness \
|
|
116
|
+
--gsc-evidence .maggie/gsc-readiness.json
|
|
117
|
+
maggie analytics release-gate --project . --environment staging \
|
|
118
|
+
--contract .maggie/analytics-contract.json \
|
|
119
|
+
--render-report .maggie/analytics-browser.json \
|
|
120
|
+
--network-report .maggie/analytics-network.json \
|
|
121
|
+
--provider-report .maggie/analytics-provider.json \
|
|
122
|
+
--smoke-report .maggie/analytics-smoke.json \
|
|
123
|
+
--gsc-evidence .maggie/gsc-readiness.json --require-gsc
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Use `contracts/maggie-seo/gsc-readiness-v1.schema.json` as the evidence
|
|
127
|
+
boundary. Credentials and mutation scopes remain host-owned; a passing local
|
|
128
|
+
contract is not proof of property ownership or live readback.
|
|
129
|
+
|
|
107
130
|
## Freeze and compare a reviewed site
|
|
108
131
|
|
|
109
132
|
```bash
|
|
@@ -393,3 +416,13 @@ same-origin page linked from primary navigation when it is `noindex`,
|
|
|
393
416
|
`nofollow`, or `none`. The privacy check fails in both directions: an embedded
|
|
394
417
|
origin missing from the policy, or a policy origin never observed in rendered
|
|
395
418
|
pages, requires review.
|
|
419
|
+
|
|
420
|
+
Noindex pages are still audited structurally. The crawl reports
|
|
421
|
+
`indexabilityIfIndexed` with title, description, canonical, heading, and
|
|
422
|
+
JSON-LD checks that would fail if the route became indexable. This is
|
|
423
|
+
diagnostic evidence only; it does not change robots directives or sitemap
|
|
424
|
+
membership. Baseline drift recapture requires a reviewer reason and records an
|
|
425
|
+
expiring acknowledgement (`--reason-ttl-days`, default 30 days).
|
|
426
|
+
|
|
427
|
+
Internal-link extraction is DOM-only. Text inside `script`, `style`,
|
|
428
|
+
`noscript`, and comments is never treated as an anchor or link target.
|
|
@@ -48,6 +48,33 @@ def load_json(path: Path, label: str) -> tuple[dict | None, list[str]]:
|
|
|
48
48
|
return value, []
|
|
49
49
|
|
|
50
50
|
|
|
51
|
+
def gsc_checks(evidence: dict) -> dict[str, bool]:
|
|
52
|
+
required = {
|
|
53
|
+
"property": isinstance(evidence.get("property"), dict) and bool(evidence["property"].get("siteUrl")),
|
|
54
|
+
"verification": isinstance(evidence.get("verification"), dict) and evidence["verification"].get("passed") is True,
|
|
55
|
+
"canonical": isinstance(evidence.get("canonical"), dict) and evidence["canonical"].get("passed") is True,
|
|
56
|
+
"robots": isinstance(evidence.get("robots"), dict) and evidence["robots"].get("passed") is True,
|
|
57
|
+
"sitemap": isinstance(evidence.get("sitemap"), dict) and evidence["sitemap"].get("passed") is True,
|
|
58
|
+
"authorization": isinstance(evidence.get("authorization"), dict) and evidence["authorization"].get("readOnly") is True and evidence["authorization"].get("authorized") is True,
|
|
59
|
+
"query-readback": isinstance(evidence.get("queryReadback"), dict) and evidence["queryReadback"].get("passed") is True,
|
|
60
|
+
"production-smoke": isinstance(evidence.get("productionSmoke"), dict) and evidence["productionSmoke"].get("passed") is True,
|
|
61
|
+
}
|
|
62
|
+
return required
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def gsc_readiness(args: argparse.Namespace) -> int:
|
|
66
|
+
evidence, errors = load_json(Path(args.evidence).resolve(), "gsc evidence")
|
|
67
|
+
checks = gsc_checks(evidence or {})
|
|
68
|
+
failed = [name for name, passed in checks.items() if not passed]
|
|
69
|
+
errors.extend(failed)
|
|
70
|
+
if evidence and evidence.get("schemaVersion") != "maggie-gsc-readiness.v1":
|
|
71
|
+
errors.append("schemaVersion must be maggie-gsc-readiness.v1")
|
|
72
|
+
result = {"schemaVersion": "maggie-gsc-readiness-report.v1", "passed": not errors, "checks": checks, "failedChecks": failed, "errors": errors, "readOnly": True}
|
|
73
|
+
return_code = 0 if result["passed"] else 1
|
|
74
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
75
|
+
return return_code
|
|
76
|
+
|
|
77
|
+
|
|
51
78
|
def release_gate(args: argparse.Namespace) -> int:
|
|
52
79
|
project = Path(args.project).resolve()
|
|
53
80
|
checks: dict[str, dict] = {}
|
|
@@ -87,6 +114,14 @@ def release_gate(args: argparse.Namespace) -> int:
|
|
|
87
114
|
errors.extend(provider_errors)
|
|
88
115
|
smoke, smoke_errors = load_json(Path(args.smoke_report).resolve(), "smoke-report")
|
|
89
116
|
errors.extend(smoke_errors)
|
|
117
|
+
if args.gsc_evidence:
|
|
118
|
+
gsc, gsc_errors = load_json(Path(args.gsc_evidence).resolve(), "gsc-evidence")
|
|
119
|
+
errors.extend(gsc_errors)
|
|
120
|
+
checks.update({f"gsc-{name}": {"passed": passed, "evidence": "maggie-gsc-readiness.v1"} for name, passed in gsc_checks(gsc or {}).items()})
|
|
121
|
+
if gsc and gsc.get("schemaVersion") != "maggie-gsc-readiness.v1":
|
|
122
|
+
errors.append("gsc-evidence-schema")
|
|
123
|
+
elif args.require_gsc:
|
|
124
|
+
errors.append("gsc-evidence-required")
|
|
90
125
|
if browser:
|
|
91
126
|
check("browser-schema", browser.get("schemaVersion") == "maggie-analytics-browser.v1", "versioned browser evidence", checks)
|
|
92
127
|
check("browser-render", browser.get("passed") is True and isinstance(browser.get("routes"), list) and bool(browser["routes"]), "routes rendered without a browser failure", checks)
|
|
@@ -144,7 +179,7 @@ def release_gate(args: argparse.Namespace) -> int:
|
|
|
144
179
|
|
|
145
180
|
def main() -> int:
|
|
146
181
|
parser = argparse.ArgumentParser()
|
|
147
|
-
parser.add_argument("command", nargs="?", choices=("release-gate", "traffic-audit"))
|
|
182
|
+
parser.add_argument("command", nargs="?", choices=("release-gate", "traffic-audit", "gsc-readiness"))
|
|
148
183
|
parser.add_argument("--project", default=".")
|
|
149
184
|
parser.add_argument("--environment", choices=("development", "staging", "production"), default="staging")
|
|
150
185
|
parser.add_argument("--env-file", help="optional env file; values are never printed")
|
|
@@ -154,8 +189,15 @@ def main() -> int:
|
|
|
154
189
|
parser.add_argument("--network-report", help="redacted network evidence for release-gate")
|
|
155
190
|
parser.add_argument("--provider-report", help="read-only provider evidence for release-gate")
|
|
156
191
|
parser.add_argument("--smoke-report", help="production smoke evidence for release-gate")
|
|
192
|
+
parser.add_argument("--gsc-evidence", help="versioned GSC readiness evidence for release-gate")
|
|
193
|
+
parser.add_argument("--require-gsc", action="store_true", help="require GSC readiness evidence in release-gate")
|
|
157
194
|
parser.add_argument("--events", help="redacted JSON array of analytics events for traffic-audit")
|
|
158
195
|
args = parser.parse_args()
|
|
196
|
+
if args.command == "gsc-readiness":
|
|
197
|
+
if not args.gsc_evidence:
|
|
198
|
+
parser.error("gsc-readiness requires --gsc-evidence")
|
|
199
|
+
args.evidence = args.gsc_evidence
|
|
200
|
+
return gsc_readiness(args)
|
|
159
201
|
if args.command == "release-gate":
|
|
160
202
|
required = ("contract", "render_report", "network_report", "provider_report", "smoke_report")
|
|
161
203
|
if any(not getattr(args, name) for name in required):
|
|
@@ -13,6 +13,98 @@ from browser_behavior import validate_samples
|
|
|
13
13
|
from localization_runner import checkpoint
|
|
14
14
|
|
|
15
15
|
|
|
16
|
+
INTERACTION_ACTIONS = {
|
|
17
|
+
"click", "fill", "type", "select", "press", "wait",
|
|
18
|
+
"assert-visible", "assert-enabled", "assert-disabled", "assert-checked",
|
|
19
|
+
"assert-editable", "assert-text", "assert-style", "assert-no-request",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_interactions(path: Path | None) -> list[dict]:
|
|
24
|
+
if not path:
|
|
25
|
+
return []
|
|
26
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
27
|
+
if not isinstance(value, dict) or value.get("schemaVersion") != "maggie-browser-interactions.v1":
|
|
28
|
+
raise ValueError("interaction manifest schemaVersion must be maggie-browser-interactions.v1")
|
|
29
|
+
steps = value.get("steps")
|
|
30
|
+
if not isinstance(steps, list) or not steps:
|
|
31
|
+
raise ValueError("interaction manifest steps must be a non-empty array")
|
|
32
|
+
for step in steps:
|
|
33
|
+
if not isinstance(step, dict) or step.get("action") not in INTERACTION_ACTIONS:
|
|
34
|
+
raise ValueError("each interaction step must use a supported action")
|
|
35
|
+
if step.get("action") not in {"wait", "press", "assert-no-request"} and not str(step.get("selector") or "").strip():
|
|
36
|
+
raise ValueError("interaction step selector is required")
|
|
37
|
+
return steps
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def js_value(call, expression: str):
|
|
41
|
+
raw = call("js", expression).strip()
|
|
42
|
+
try:
|
|
43
|
+
return json.loads(raw)
|
|
44
|
+
except json.JSONDecodeError:
|
|
45
|
+
return raw.strip('"')
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def resource_urls(call) -> set[str]:
|
|
49
|
+
value = js_value(call, "JSON.stringify(performance.getEntriesByType('resource').map(entry => entry.name))")
|
|
50
|
+
return set(value if isinstance(value, list) else [])
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def run_interactions(call, steps: list[dict]) -> list[dict]:
|
|
54
|
+
baseline = resource_urls(call)
|
|
55
|
+
evidence = []
|
|
56
|
+
for step in steps:
|
|
57
|
+
action = step["action"]
|
|
58
|
+
selector = str(step.get("selector") or "")
|
|
59
|
+
record = {"id": str(step.get("id") or f"step-{len(evidence) + 1}"), "action": action, "selector": selector, "status": "passed"}
|
|
60
|
+
try:
|
|
61
|
+
if action == "click":
|
|
62
|
+
call("click", selector)
|
|
63
|
+
elif action == "fill":
|
|
64
|
+
call("fill", selector, str(step.get("value") or ""))
|
|
65
|
+
elif action == "type":
|
|
66
|
+
call("click", selector)
|
|
67
|
+
call("type", str(step.get("text") or ""))
|
|
68
|
+
elif action == "select":
|
|
69
|
+
call("select", selector, str(step.get("value") or ""))
|
|
70
|
+
elif action == "press":
|
|
71
|
+
call("press", str(step.get("key") or "Enter"))
|
|
72
|
+
elif action == "wait":
|
|
73
|
+
call("wait", str(step.get("target") or "--load"))
|
|
74
|
+
elif action.startswith("assert-") and action in {"assert-visible", "assert-enabled", "assert-disabled", "assert-checked", "assert-editable"}:
|
|
75
|
+
call("is", action.removeprefix("assert-"), selector)
|
|
76
|
+
elif action == "assert-text":
|
|
77
|
+
expected = str(step.get("text") or "")
|
|
78
|
+
expression = f"(() => {{ const node = document.querySelector({json.dumps(selector)}); return Boolean(node && node.textContent.includes({json.dumps(expected)})); }})()"
|
|
79
|
+
if js_value(call, expression) is not True:
|
|
80
|
+
raise ValueError("text assertion failed")
|
|
81
|
+
elif action == "assert-style":
|
|
82
|
+
prop = str(step.get("property") or "")
|
|
83
|
+
expected = str(step.get("value") or "")
|
|
84
|
+
if not prop:
|
|
85
|
+
raise ValueError("style assertion property is required")
|
|
86
|
+
expression = f"(() => {{ const node = document.querySelector({json.dumps(selector)}); return node ? getComputedStyle(node)[{json.dumps(prop)}] : null; }})()"
|
|
87
|
+
actual = js_value(call, expression)
|
|
88
|
+
if actual != expected:
|
|
89
|
+
raise ValueError("style assertion failed")
|
|
90
|
+
elif action == "assert-no-request":
|
|
91
|
+
forbidden = str(step.get("origin") or step.get("url") or "")
|
|
92
|
+
if not forbidden:
|
|
93
|
+
raise ValueError("request assertion origin is required")
|
|
94
|
+
new_urls = sorted(url for url in resource_urls(call) - baseline if forbidden in url)
|
|
95
|
+
if new_urls:
|
|
96
|
+
raise ValueError("third-party request assertion failed")
|
|
97
|
+
else:
|
|
98
|
+
raise ValueError("unsupported interaction action")
|
|
99
|
+
except (OSError, ValueError, subprocess.TimeoutExpired) as error:
|
|
100
|
+
record["status"] = "failed"
|
|
101
|
+
record["error"] = str(error)
|
|
102
|
+
evidence.append(record)
|
|
103
|
+
break
|
|
104
|
+
evidence.append(record)
|
|
105
|
+
return evidence
|
|
106
|
+
|
|
107
|
+
|
|
16
108
|
def audit(args):
|
|
17
109
|
if urlparse(args.url).scheme not in {"http", "https", "file"}:
|
|
18
110
|
raise ValueError("URL must use http, https or file")
|
|
@@ -20,13 +112,15 @@ def audit(args):
|
|
|
20
112
|
raise ValueError("at least one --required selector is necessary")
|
|
21
113
|
output = args.output.resolve()
|
|
22
114
|
output.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
interactions = load_interactions(args.interactions)
|
|
23
116
|
def call(*command):
|
|
24
117
|
result = subprocess.run([str(args.browse), *command], capture_output=True, text=True, timeout=45)
|
|
25
118
|
if result.returncode:
|
|
26
119
|
raise ValueError("browser command failed: " + command[0])
|
|
27
120
|
return result.stdout
|
|
28
121
|
report = {"schemaVersion": "maggie-browser-audit.v1", "url": args.url,
|
|
29
|
-
"passed": False, "viewports": [], "evidence": "browser-captured"
|
|
122
|
+
"passed": False, "viewports": [], "evidence": "browser-captured",
|
|
123
|
+
"interactionManifest": str(args.interactions.resolve()) if args.interactions else None}
|
|
30
124
|
try:
|
|
31
125
|
for index, viewport in enumerate(args.viewport or ["390x844", "768x1024", "1440x900"]):
|
|
32
126
|
width, height = [int(value) for value in viewport.split("x")]
|
|
@@ -36,6 +130,7 @@ def audit(args):
|
|
|
36
130
|
call("goto", args.url)
|
|
37
131
|
selectors = list(dict.fromkeys(args.required + args.sticky))
|
|
38
132
|
call("js", "window.__maggieAuditSelectors=" + json.dumps(selectors))
|
|
133
|
+
interaction_evidence = run_interactions(call, interactions) if interactions else []
|
|
39
134
|
samples = []
|
|
40
135
|
for step, fraction in enumerate([0, 0.5, 0.9]):
|
|
41
136
|
call("js", f"window.scrollTo(0, (document.documentElement.scrollHeight-innerHeight)*{fraction})")
|
|
@@ -50,8 +145,8 @@ def audit(args):
|
|
|
50
145
|
raise ValueError("browser did not create screenshot")
|
|
51
146
|
finding = validate_samples(samples, args.required, args.sticky)
|
|
52
147
|
report["viewports"].append({"viewport": viewport, "samples": samples,
|
|
53
|
-
"screenshot": str(screenshot), **finding})
|
|
54
|
-
report["passed"] = all(item["passed"] for item in report["viewports"])
|
|
148
|
+
"screenshot": str(screenshot), "interactionEvidence": interaction_evidence, **finding})
|
|
149
|
+
report["passed"] = all(item["passed"] and all(step["status"] == "passed" for step in item.get("interactionEvidence", [])) for item in report["viewports"])
|
|
55
150
|
finally:
|
|
56
151
|
checkpoint(output / "report.json", report)
|
|
57
152
|
print(json.dumps(report, indent=2))
|
|
@@ -66,6 +161,7 @@ def main():
|
|
|
66
161
|
parser.add_argument("--viewport", action="append")
|
|
67
162
|
parser.add_argument("--required", action="append", default=[])
|
|
68
163
|
parser.add_argument("--sticky", action="append", default=[])
|
|
164
|
+
parser.add_argument("--interactions", type=Path, help="declarative interaction/assertion manifest")
|
|
69
165
|
args = parser.parse_args()
|
|
70
166
|
try:
|
|
71
167
|
return audit(args)
|
|
@@ -316,6 +316,48 @@ def command_verify(args):
|
|
|
316
316
|
return 0
|
|
317
317
|
|
|
318
318
|
|
|
319
|
+
def command_interactions(args):
|
|
320
|
+
"""Replay the shared browser-audit manifest for every clone target/viewport."""
|
|
321
|
+
root, manifest = read_manifest(args.project.resolve(), args.run_id)
|
|
322
|
+
interaction_path = args.interactions.resolve()
|
|
323
|
+
try:
|
|
324
|
+
interaction_manifest = json.loads(interaction_path.read_text(encoding="utf-8"))
|
|
325
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
326
|
+
raise ValueError(f"cannot read interaction manifest: {error}") from error
|
|
327
|
+
if interaction_manifest.get("schemaVersion") != "maggie-browser-interactions.v1" or not isinstance(interaction_manifest.get("steps"), list) or not interaction_manifest["steps"]:
|
|
328
|
+
raise ValueError("interaction manifest must be a non-empty maggie-browser-interactions.v1 document")
|
|
329
|
+
evidence_root = root / "interactions"
|
|
330
|
+
evidence_root.mkdir(parents=True, exist_ok=True)
|
|
331
|
+
reports = []
|
|
332
|
+
states = []
|
|
333
|
+
audit_cli = Path(__file__).with_name("maggie_browser_audit.py")
|
|
334
|
+
for target in manifest["targets"]:
|
|
335
|
+
output = evidence_root / target["page_key"]
|
|
336
|
+
selectors = {str(step.get("selector")) for step in interaction_manifest["steps"] if step.get("selector")}
|
|
337
|
+
command = [sys.executable, str(audit_cli), target.get("capture_url", target["source_url"]), "--browse", str(args.browse), "--output", str(output), "--interactions", str(interaction_path), "--required", "body"]
|
|
338
|
+
for selector in sorted(selectors):
|
|
339
|
+
command.extend(["--required", selector])
|
|
340
|
+
for viewport in VIEWPORTS:
|
|
341
|
+
command.extend(["--viewport", f"{VIEWPORTS[viewport][0]}x{VIEWPORTS[viewport][1]}"])
|
|
342
|
+
completed = subprocess.run(command, capture_output=True, text=True, check=False)
|
|
343
|
+
report_path = output / "report.json"
|
|
344
|
+
reports.append({"pageKey": target["page_key"], "status": "passed" if completed.returncode == 0 else "failed", "report": str(report_path), "stderr": "browser audit failed" if completed.returncode else None})
|
|
345
|
+
try:
|
|
346
|
+
audit_report = json.loads(report_path.read_text(encoding="utf-8"))
|
|
347
|
+
except (OSError, json.JSONDecodeError):
|
|
348
|
+
audit_report = {}
|
|
349
|
+
for viewport, viewport_report in zip(("desktop", "tablet", "mobile"), audit_report.get("viewports") or []):
|
|
350
|
+
for step in interaction_manifest["steps"]:
|
|
351
|
+
step_evidence = next((item for item in viewport_report.get("interactionEvidence", []) if item.get("id") == step.get("id")), {})
|
|
352
|
+
states.append({"viewport": viewport, "stepId": step["id"], "status": step_evidence.get("status", "failed"), "screenshot": viewport_report.get("screenshot"), "error": step_evidence.get("error")})
|
|
353
|
+
evidence = {"schemaVersion": "maggie-clone-interaction-state.v1", "sourceRunId": manifest["run_id"], "interactionManifest": str(interaction_path), "viewports": list(VIEWPORTS), "states": states, "redacted": True, "reports": reports, "passed": bool(reports) and bool(states) and all(item["status"] == "passed" for item in reports) and all(item["status"] == "passed" for item in states)}
|
|
354
|
+
save_json(evidence_root / "evidence.json", evidence)
|
|
355
|
+
manifest["interactionEvidence"] = str(evidence_root / "evidence.json")
|
|
356
|
+
save_json(root / "manifest.json", manifest)
|
|
357
|
+
print(json.dumps(evidence, indent=2, ensure_ascii=False))
|
|
358
|
+
return 0 if evidence["passed"] else 1
|
|
359
|
+
|
|
360
|
+
|
|
319
361
|
def command_status(args):
|
|
320
362
|
root = run_root(args.project.resolve(), args.run_id)
|
|
321
363
|
manifest_path = root / "manifest.json"
|
|
@@ -373,13 +415,16 @@ def main():
|
|
|
373
415
|
status_parser.add_argument("--project", type=Path, default=Path.cwd())
|
|
374
416
|
status_parser.add_argument("--run-id", required=True)
|
|
375
417
|
status_parser.set_defaults(func=command_status)
|
|
376
|
-
commands = {"init": command_init, "capture": command_capture, "extract": command_extract, "assets": command_assets, "compare": command_compare, "verify": command_verify}
|
|
418
|
+
commands = {"init": command_init, "capture": command_capture, "extract": command_extract, "assets": command_assets, "compare": command_compare, "verify": command_verify, "interactions": command_interactions}
|
|
377
419
|
for name, func in commands.items():
|
|
378
420
|
cmd = sub.add_parser(name); cmd.add_argument("--project", type=Path, default=Path.cwd()); cmd.add_argument("--run-id", required=True); cmd.add_argument("--force", action="store_true")
|
|
379
421
|
if name == "init": cmd.add_argument("urls", nargs="+")
|
|
380
422
|
if name == "assets": cmd.add_argument("--max-bytes", type=int, default=20_000_000)
|
|
381
423
|
if name == "compare": cmd.add_argument("--local-url", required=True)
|
|
382
424
|
if name == "verify": cmd.add_argument("--allow-uncompared", action="store_true")
|
|
425
|
+
if name == "interactions":
|
|
426
|
+
cmd.add_argument("--browse", type=Path, required=True)
|
|
427
|
+
cmd.add_argument("--interactions", type=Path, required=True, help="shared maggie-browser-interactions.v1 manifest")
|
|
383
428
|
cmd.set_defaults(func=func)
|
|
384
429
|
args = parser.parse_args()
|
|
385
430
|
try: return int(args.func(args) or 0)
|
|
@@ -237,6 +237,105 @@ def validate_capabilities(args: argparse.Namespace) -> int:
|
|
|
237
237
|
return emit_and_exit(result("maggie-host-capabilities.v1", errors, enabled=sum(1 for item in endpoints if isinstance(item, dict) and item.get("enabled") is True), endpointCount=len(endpoints)), Path(args.output) if args.output else None)
|
|
238
238
|
|
|
239
239
|
|
|
240
|
+
def validate_style_editing(args: argparse.Namespace) -> int:
|
|
241
|
+
"""Validate a closed, host-rendered style editing contract."""
|
|
242
|
+
manifest = read_json(Path(args.manifest))
|
|
243
|
+
errors: list[str] = []
|
|
244
|
+
if manifest.get("schemaVersion") != "maggie-style-editing.v1":
|
|
245
|
+
errors.append("manifest schemaVersion must be maggie-style-editing.v1")
|
|
246
|
+
properties = manifest.get("properties")
|
|
247
|
+
if not isinstance(properties, list) or not properties:
|
|
248
|
+
errors.append("properties must be a non-empty array")
|
|
249
|
+
properties = []
|
|
250
|
+
allowed: set[str] = set()
|
|
251
|
+
patterns: dict[str, str] = {}
|
|
252
|
+
dangerous = re.compile(r"(?:;|url\s*\(|expression\s*\(|<|>|javascript:)", re.I)
|
|
253
|
+
for item in properties:
|
|
254
|
+
if not isinstance(item, dict):
|
|
255
|
+
errors.append("each style property must be an object")
|
|
256
|
+
continue
|
|
257
|
+
name = str(item.get("name") or "")
|
|
258
|
+
pattern = str(item.get("pattern") or "")
|
|
259
|
+
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9-]*", name):
|
|
260
|
+
errors.append(f"invalid style property name: {name or 'unknown'}")
|
|
261
|
+
if name in allowed:
|
|
262
|
+
errors.append(f"duplicate style property: {name}")
|
|
263
|
+
allowed.add(name)
|
|
264
|
+
if not pattern:
|
|
265
|
+
errors.append(f"style property {name or 'unknown'} needs a value pattern")
|
|
266
|
+
else:
|
|
267
|
+
try:
|
|
268
|
+
re.compile(pattern)
|
|
269
|
+
patterns[name] = pattern
|
|
270
|
+
except re.error:
|
|
271
|
+
errors.append(f"style property {name or 'unknown'} has an invalid value pattern")
|
|
272
|
+
for template in manifest.get("templates") or []:
|
|
273
|
+
if not isinstance(template, dict) or not str(template.get("id") or ""):
|
|
274
|
+
errors.append("each style template needs an id")
|
|
275
|
+
continue
|
|
276
|
+
for name, value in (template.get("styles") or {}).items():
|
|
277
|
+
if name not in allowed:
|
|
278
|
+
errors.append(f"style template {template['id']} uses undeclared property: {name}")
|
|
279
|
+
continue
|
|
280
|
+
if not isinstance(value, str) or dangerous.search(value) or not re.fullmatch(patterns[name], value):
|
|
281
|
+
errors.append(f"style template {template['id']} has an unsafe value for {name}")
|
|
282
|
+
return emit_and_exit(result("maggie-style-editing.v1", errors, properties=sorted(allowed), templates=len(manifest.get("templates") or [])), Path(args.output) if args.output else None)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def validate_content_overrides(args: argparse.Namespace) -> int:
|
|
286
|
+
manifest = read_json(Path(args.manifest))
|
|
287
|
+
errors: list[str] = []
|
|
288
|
+
if manifest.get("schemaVersion") != "maggie-content-overrides.v1":
|
|
289
|
+
errors.append("manifest schemaVersion must be maggie-content-overrides.v1")
|
|
290
|
+
expected = {
|
|
291
|
+
"readAtRequestTime": True,
|
|
292
|
+
"sourceLiteralIsFallback": True,
|
|
293
|
+
"writesCreateRevision": True,
|
|
294
|
+
"conflictRequiresSourceRevision": True,
|
|
295
|
+
"orphanPolicy": "report-and-never-publish",
|
|
296
|
+
}
|
|
297
|
+
for key, value in expected.items():
|
|
298
|
+
if manifest.get(key) != value:
|
|
299
|
+
errors.append(f"{key} must be {value!r}")
|
|
300
|
+
if not isinstance(manifest.get("protectedFields"), list) or not manifest.get("protectedFields"):
|
|
301
|
+
errors.append("protectedFields must list factual fields that cannot be overridden")
|
|
302
|
+
return emit_and_exit(result("maggie-content-overrides.v1", errors, protectedFields=manifest.get("protectedFields", [])), Path(args.output) if args.output else None)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def validate_session_contract(args: argparse.Namespace) -> int:
|
|
306
|
+
manifest = read_json(Path(args.manifest))
|
|
307
|
+
errors: list[str] = []
|
|
308
|
+
if manifest.get("schemaVersion") != "maggie-browser-session.v1":
|
|
309
|
+
errors.append("manifest schemaVersion must be maggie-browser-session.v1")
|
|
310
|
+
if manifest.get("mintingBoundary") != "host-adapter":
|
|
311
|
+
errors.append("mintingBoundary must be host-adapter")
|
|
312
|
+
if manifest.get("passwordHandling") != "never-exposed":
|
|
313
|
+
errors.append("passwordHandling must be never-exposed")
|
|
314
|
+
ttl = manifest.get("ttlMinutes")
|
|
315
|
+
if not isinstance(ttl, int) or not 1 <= ttl <= 240:
|
|
316
|
+
errors.append("ttlMinutes must be between 1 and 240")
|
|
317
|
+
for key in ("mintRoute", "revokeRoute"):
|
|
318
|
+
if not str(manifest.get(key) or "").startswith("/"):
|
|
319
|
+
errors.append(f"{key} must be a relative host route")
|
|
320
|
+
return emit_and_exit(result("maggie-browser-session.v1", errors, ttlMinutes=ttl), Path(args.output) if args.output else None)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def validate_cache_boundary(args: argparse.Namespace) -> int:
|
|
324
|
+
evidence = read_json(Path(args.evidence))
|
|
325
|
+
errors: list[str] = []
|
|
326
|
+
if evidence.get("schemaVersion") != "maggie-public-session-cache.v1":
|
|
327
|
+
errors.append("evidence schemaVersion must be maggie-public-session-cache.v1")
|
|
328
|
+
if evidence.get("publicHtmlVariesBySession") is not False:
|
|
329
|
+
errors.append("publicHtmlVariesBySession must be false")
|
|
330
|
+
if evidence.get("sessionCookieHttpOnly") is not True:
|
|
331
|
+
errors.append("sessionCookieHttpOnly must be true")
|
|
332
|
+
if evidence.get("identityHintCookieReadable") is not True:
|
|
333
|
+
errors.append("identityHintCookieReadable must be true")
|
|
334
|
+
if evidence.get("identityRequestWhenHintAbsent") is not False:
|
|
335
|
+
errors.append("identityRequestWhenHintAbsent must be false")
|
|
336
|
+
return emit_and_exit(result("maggie-public-session-cache.v1", errors), Path(args.output) if args.output else None)
|
|
337
|
+
|
|
338
|
+
|
|
240
339
|
def main() -> int:
|
|
241
340
|
parser = argparse.ArgumentParser(prog="maggie contracts")
|
|
242
341
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
@@ -270,6 +369,18 @@ def main() -> int:
|
|
|
270
369
|
capabilities = sub.add_parser("capabilities-validate")
|
|
271
370
|
capabilities.add_argument("--manifest", required=True); capabilities.add_argument("--output")
|
|
272
371
|
capabilities.set_defaults(func=validate_capabilities)
|
|
372
|
+
style = sub.add_parser("style-check")
|
|
373
|
+
style.add_argument("--manifest", required=True); style.add_argument("--output")
|
|
374
|
+
style.set_defaults(func=validate_style_editing)
|
|
375
|
+
overrides = sub.add_parser("content-overrides-check")
|
|
376
|
+
overrides.add_argument("--manifest", required=True); overrides.add_argument("--output")
|
|
377
|
+
overrides.set_defaults(func=validate_content_overrides)
|
|
378
|
+
session = sub.add_parser("session-check")
|
|
379
|
+
session.add_argument("--manifest", required=True); session.add_argument("--output")
|
|
380
|
+
session.set_defaults(func=validate_session_contract)
|
|
381
|
+
cache = sub.add_parser("cache-boundary-check")
|
|
382
|
+
cache.add_argument("--evidence", required=True); cache.add_argument("--output")
|
|
383
|
+
cache.set_defaults(func=validate_cache_boundary)
|
|
273
384
|
args = parser.parse_args()
|
|
274
385
|
try:
|
|
275
386
|
return args.func(args)
|