@topy-ai/maggie 0.7.52 → 0.7.54

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.
Files changed (79) hide show
  1. package/README-zh-TW.md +28 -4
  2. package/README.md +93 -60
  3. package/bin/maggie.js +67 -12
  4. package/bundled-contracts/maggie-assistant-tools/booking-receptionist-example-v1.json +161 -0
  5. package/bundled-contracts/maggie-assistant-tools/tool-registry-v1.schema.json +268 -20
  6. package/bundled-contracts/maggie-deployment/bridge-host-example-v1.json +49 -7
  7. package/bundled-contracts/maggiedash/booking-email-templates-v1.json +181 -13
  8. package/bundled-contracts/maggiedash/booking-rules-v1.json +1699 -0
  9. package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +398 -39
  10. package/bundled-references/maggiedash-booking/PRD.md +22 -11
  11. package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +1 -1
  12. package/bundled-references/maggiedash-booking/RULES.md +167 -0
  13. package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +2 -2
  14. package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +42 -4
  15. package/bundled-references/provider-adapter-contract.md +16 -3
  16. package/bundled-skills/catalog.json +1 -1
  17. package/bundled-skills/maggie-assistant-tools/SKILL.md +54 -3
  18. package/bundled-skills/maggie-assistant-tools/references/approvals-and-audit.md +27 -0
  19. package/bundled-skills/maggie-assistant-tools/references/booking-receptionist.md +45 -0
  20. package/bundled-skills/maggie-assistant-tools/references/bridge-host.md +54 -0
  21. package/bundled-skills/maggie-auth-reference/SKILL.md +18 -0
  22. package/bundled-skills/maggie-blog/SKILL.md +8 -0
  23. package/bundled-skills/maggie-booking/SKILL.md +66 -14
  24. package/bundled-skills/maggie-content-localization/SKILL.md +26 -0
  25. package/bundled-skills/maggie-dash/SKILL.md +28 -2
  26. package/bundled-skills/maggie-deployment/SKILL.md +48 -0
  27. package/bundled-skills/maggie-deployment/references/cloudflare.md +19 -0
  28. package/bundled-skills/maggie-deployment/references/vps.md +92 -0
  29. package/bundled-skills/maggie-design/SKILL.md +98 -3
  30. package/bundled-skills/maggie-design/references/app-ui-patterns.md +79 -0
  31. package/bundled-skills/maggie-feedback/SKILL.md +27 -2
  32. package/bundled-skills/maggie-marketplace/SKILL.md +12 -0
  33. package/bundled-skills/maggie-marketplace/references/directory-search.md +51 -0
  34. package/bundled-skills/maggie-marketplace/references/directory.mjs +112 -0
  35. package/bundled-skills/maggie-mobile-app/SKILL.md +65 -6
  36. package/bundled-skills/maggie-mobile-app/references/payments-and-push.md +78 -0
  37. package/bundled-skills/maggie-qa-workflow/SKILL.md +60 -0
  38. package/bundled-skills/maggie-seo-geo/SKILL.md +33 -0
  39. package/bundled-skills/maggie-service-booking/SKILL.md +28 -3
  40. package/bundled-skills/maggie-social-share/SKILL.md +56 -4
  41. package/bundled-skills/maggie-social-share/references/queue-and-connections.md +28 -0
  42. package/bundled-tools/clis/maggie_assistant_tools.py +84 -3
  43. package/bundled-tools/clis/maggie_booking.py +24 -12
  44. package/bundled-tools/clis/maggie_booking_connect_qa.py +136 -0
  45. package/bundled-tools/clis/maggie_booking_rules.py +476 -0
  46. package/bundled-tools/clis/maggie_bridge_host.py +77 -1
  47. package/bundled-tools/clis/maggie_dash.py +173 -3
  48. package/bundled-tools/clis/maggie_deployment_ops.py +524 -0
  49. package/bundled-tools/clis/maggie_design.py +15 -2
  50. package/bundled-tools/clis/maggie_design_bulk.py +290 -0
  51. package/bundled-tools/clis/maggie_design_lint.py +205 -3
  52. package/bundled-tools/clis/maggie_feedback.py +138 -28
  53. package/bundled-tools/clis/maggie_live_urls.py +138 -0
  54. package/bundled-tools/clis/maggie_localization.py +3 -0
  55. package/bundled-tools/clis/maggie_localization_keys.py +151 -0
  56. package/bundled-tools/clis/maggie_media_generate.py +174 -0
  57. package/bundled-tools/clis/maggie_mobile.py +219 -4
  58. package/bundled-tools/clis/maggie_product_release.py +49 -1
  59. package/bundled-tools/clis/maggie_qa_app_preflight.py +122 -4
  60. package/bundled-tools/clis/maggie_qa_fit.py +217 -0
  61. package/bundled-tools/clis/maggie_qa_workflow.py +5 -0
  62. package/bundled-tools/clis/maggie_schema_complexity.py +84 -0
  63. package/bundled-tools/clis/maggie_seo_routes.py +154 -0
  64. package/bundled-tools/clis/maggie_service_migration.py +272 -0
  65. package/bundled-tools/clis/maggie_social_queue.py +2 -0
  66. package/bundled-tools/clis/maggie_social_share.py +174 -7
  67. package/bundled-tools/integrations/maggie-api-pull.md +22 -0
  68. package/bundled-tools/runtime/assistant_approval.py +98 -0
  69. package/package.json +1 -1
  70. package/references/provider-adapter-contract.md +16 -3
  71. package/bundled-contracts/maggiedash/execution-board.json +0 -5463
  72. package/bundled-references/maggiedash-booking/CURRENT-STATE.md +0 -210
  73. package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +0 -5463
  74. package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +0 -270
  75. package/bundled-references/maggiedash-booking/PROGRESS.md +0 -279
  76. package/bundled-references/maggiedash-booking/README.md +0 -288
  77. package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +0 -551
  78. package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +0 -107
  79. package/bundled-references/maggiedash-booking/TASKS.md +0 -178
@@ -12,6 +12,13 @@ broken change or quietly damaged real data:
12
12
  (iframes, tabs, parallel viewports) refresh at once, and a local dev server
13
13
  that talks to a production API turns QA clicks into production writes.
14
14
 
15
+ Two more failures hid stale code: a dev server killed and replaced by another
16
+ session's server on the same port, and a watcher that kept serving the old
17
+ bundle after edits. ``--expect-symbol NAME`` fetches the served entry bundle
18
+ from ``--base-url`` and fails when it lacks a name from the edit just made;
19
+ the report also names the process holding the base URL's port and when it
20
+ started, so a server restarted by someone else is obvious.
21
+
15
22
  The command inspects running dev-server processes (Linux ``/proc``; other
16
23
  systems fall back to ``ps``), the app's environment files and auth code, and
17
24
  reports environment variable names and host classes only — never values,
@@ -28,7 +35,8 @@ import subprocess
28
35
  import sys
29
36
  from datetime import datetime, timezone
30
37
  from pathlib import Path
31
- from urllib.parse import urlsplit
38
+ from urllib.parse import urljoin, urlsplit
39
+ from urllib.request import Request, urlopen
32
40
 
33
41
  SCHEMA = "maggie-qa-app-preflight.v1"
34
42
  DEV_SERVER = re.compile(r"(expo(?:/cli)?[^ ]*\s+start|@expo/cli.*\bstart\b|react-native\s+start|metro(?:\.js)?\s|node_modules/\.bin/metro|expo-router)", re.I)
@@ -78,6 +86,84 @@ def list_processes() -> list[dict]:
78
86
  return found
79
87
 
80
88
 
89
+ def process_started_at(pid: int) -> str | None:
90
+ """ISO start time of a Linux process from /proc/<pid>/stat and the boot time; None elsewhere."""
91
+ try:
92
+ stat = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8")
93
+ ticks = int(stat.rsplit(")", 1)[1].split()[19])
94
+ boot = next(int(line.split()[1]) for line in Path("/proc/stat").read_text(encoding="utf-8").splitlines() if line.startswith("btime "))
95
+ return datetime.fromtimestamp(boot + ticks / os.sysconf("SC_CLK_TCK"), timezone.utc).isoformat(timespec="seconds")
96
+ except (OSError, ValueError, IndexError, StopIteration):
97
+ return None
98
+
99
+
100
+ def port_owner(port: int) -> dict | None:
101
+ """The process listening on a TCP port (Linux /proc); command line trimmed to the program and script."""
102
+ inodes = set()
103
+ for table in ("/proc/net/tcp", "/proc/net/tcp6"):
104
+ try:
105
+ lines = Path(table).read_text(encoding="utf-8").splitlines()[1:]
106
+ except OSError:
107
+ continue
108
+ for line in lines:
109
+ fields = line.split()
110
+ if len(fields) > 9 and fields[3] == "0A" and int(fields[1].rsplit(":", 1)[1], 16) == port:
111
+ inodes.add(fields[9])
112
+ if not inodes:
113
+ return None
114
+ for entry in Path("/proc").iterdir():
115
+ if not entry.name.isdigit():
116
+ continue
117
+ links = set()
118
+ try:
119
+ descriptors = list((entry / "fd").iterdir())
120
+ except OSError:
121
+ continue
122
+ for fd in descriptors:
123
+ try:
124
+ links.add(os.readlink(fd))
125
+ except OSError:
126
+ continue
127
+ if any(f"socket:[{inode}]" in links for inode in inodes):
128
+ try:
129
+ cmdline = (entry / "cmdline").read_bytes().replace(b"\0", b" ").decode("utf-8", "replace").strip()
130
+ cwd = os.readlink(entry / "cwd")
131
+ except OSError:
132
+ cmdline, cwd = "", None
133
+ return {"pid": int(entry.name), "command": " ".join(cmdline.split()[:3])[:160], "cwd": cwd, "startedAt": process_started_at(int(entry.name))}
134
+ return {"pid": None, "command": "unknown (owned by another user)", "cwd": None, "startedAt": None}
135
+
136
+
137
+ SCRIPT_SRC = re.compile(r"<script[^>]+src=[\"']([^\"']+)[\"']", re.I)
138
+
139
+
140
+ def _fetch(url: str, limit: int) -> bytes:
141
+ request = Request(url, headers={"User-Agent": "maggie-qa-app-preflight"})
142
+ with urlopen(request, timeout=60) as response: # noqa: S310 - local dev server URL given by the operator
143
+ return response.read(limit)
144
+
145
+
146
+ def bundle_symbols(base_url: str, symbols: list[str], limit: int = 80_000_000) -> dict:
147
+ """Fetch the page at base_url and its same-origin scripts; report which expected names they contain."""
148
+ origin = urlsplit(base_url)
149
+ html = _fetch(base_url, 5_000_000).decode("utf-8", "replace")
150
+ scripts = []
151
+ for src in SCRIPT_SRC.findall(html):
152
+ url = urljoin(base_url, src)
153
+ if urlsplit(url).netloc == origin.netloc:
154
+ scripts.append(url)
155
+ found: dict[str, list[str]] = {symbol: [] for symbol in symbols}
156
+ fetched = []
157
+ for url in scripts[:12]:
158
+ body = _fetch(url, limit).decode("utf-8", "replace")
159
+ path = urlsplit(url).path
160
+ fetched.append({"path": path, "bytes": len(body)})
161
+ for symbol in symbols:
162
+ if symbol in body:
163
+ found[symbol].append(path)
164
+ return {"scripts": fetched, "found": {symbol: bool(paths) for symbol, paths in found.items()}, "foundIn": found}
165
+
166
+
81
167
  def find_app_dirs(project: Path) -> list[Path]:
82
168
  """Expo apps in the project: the root itself, or monorepo apps/* and packages/*."""
83
169
  candidates = [project] + sorted(path for pattern in ("apps/*", "packages/*") for path in project.glob(pattern) if path.is_dir())
@@ -158,7 +244,7 @@ def refresh_token_signals(app_dir: Path, limit: int = 4000) -> dict:
158
244
  return {"filesScanned": files, "refreshTokenFiles": refresh, "rotationHintFiles": rotation, "examples": examples}
159
245
 
160
246
 
161
- def preflight(project: Path, app_dir: Path | None, base_url: str | None, rotating: str, processes: list[dict] | None = None) -> dict:
247
+ def preflight(project: Path, app_dir: Path | None, base_url: str | None, rotating: str, processes: list[dict] | None = None, expect_symbols: list[str] | None = None) -> dict:
162
248
  project = project.resolve()
163
249
  errors: list[str] = []
164
250
  warnings: list[str] = []
@@ -174,7 +260,7 @@ def preflight(project: Path, app_dir: Path | None, base_url: str | None, rotatin
174
260
  for item in (list_processes() if processes is None else processes):
175
261
  cwd = item.get("cwd")
176
262
  command = item.get("cmdline", "")
177
- record = {"pid": item.get("pid"), "cwd": rel(cwd) if cwd else None, "ciMode": bool(item.get("ci")), "noWatch": "--no-watch" in command}
263
+ record = {"pid": item.get("pid"), "cwd": rel(cwd) if cwd else None, "ciMode": bool(item.get("ci")), "noWatch": "--no-watch" in command, "startedAt": item.get("startedAt") or (process_started_at(item["pid"]) if isinstance(item.get("pid"), int) else None)}
178
264
  if cwd and not Path(cwd).resolve().is_relative_to(project):
179
265
  record["scope"] = "other project"
180
266
  servers.append(record)
@@ -189,6 +275,35 @@ def preflight(project: Path, app_dir: Path | None, base_url: str | None, rotatin
189
275
  in_project = [server for server in servers if server.get("scope") != "other project"]
190
276
  if not in_project:
191
277
  warnings.append("no running Metro/Expo dev server found for this project; start one from the app folder without CI=1")
278
+ owner = None
279
+ if base_url:
280
+ parts = urlsplit(base_url)
281
+ port = parts.port or (443 if parts.scheme == "https" else 80)
282
+ if classify_host(base_url) == "local":
283
+ owner = port_owner(port)
284
+ if owner is None:
285
+ errors.append(f"nothing is listening on port {port}; the dev server was stopped or killed (check for exit 137) — start it from {rel(app_dir)}")
286
+ else:
287
+ owner = {**owner, "port": port, "cwd": rel(owner["cwd"]) if owner.get("cwd") else None}
288
+ known = {server["pid"] for server in in_project}
289
+ if owner["pid"] is not None and owner["pid"] not in known:
290
+ where = owner["cwd"] or "an unknown folder"
291
+ errors.append(f"port {port} is held by pid {owner['pid']} ({owner['command']}, started {owner['startedAt'] or 'unknown'}) from {where}, not by a dev server of {rel(app_dir)}; another session may have replaced it")
292
+ bundle = None
293
+ if expect_symbols:
294
+ if not base_url:
295
+ errors.append("--expect-symbol needs --base-url to fetch the served bundle")
296
+ else:
297
+ try:
298
+ bundle = bundle_symbols(base_url, expect_symbols)
299
+ except (OSError, ValueError) as error:
300
+ errors.append(f"could not fetch the served bundle from --base-url: {error}")
301
+ else:
302
+ if not bundle["scripts"]:
303
+ errors.append("the page at --base-url has no same-origin script, so the served bundle could not be checked")
304
+ missing = [symbol for symbol, ok in bundle["found"].items() if not ok]
305
+ if bundle["scripts"] and missing:
306
+ errors.append("the served bundle does not contain " + ", ".join(missing) + ": the dev server is serving old code (stale watcher, wrong folder or another session's server); restart it before QA")
192
307
  signals = refresh_token_signals(app_dir)
193
308
  rotating_tokens = rotating == "yes" or (rotating == "auto" and signals["refreshTokenFiles"] > 0)
194
309
  if rotating_tokens:
@@ -213,6 +328,8 @@ def preflight(project: Path, app_dir: Path | None, base_url: str | None, rotatin
213
328
  "appDir": rel(app_dir),
214
329
  "appCandidates": [rel(path) for path in apps],
215
330
  "devServers": servers,
331
+ "portOwner": owner,
332
+ "servedBundle": bundle,
216
333
  "refreshTokens": {"rotating": rotating_tokens, "source": rotating, **signals},
217
334
  "apiTargets": targets,
218
335
  "status": "failed" if errors else ("warning" if warnings else "passed"),
@@ -229,10 +346,11 @@ def main(argv: list[str] | None = None) -> int:
229
346
  parser.add_argument("--app-dir", type=Path, help="app folder inside a monorepo, e.g. apps/app")
230
347
  parser.add_argument("--base-url", help="URL the browser QA will open")
231
348
  parser.add_argument("--rotating-refresh-tokens", choices=("auto", "yes", "no"), default="auto")
349
+ parser.add_argument("--expect-symbol", action="append", default=[], metavar="NAME", help="a name from the edit just made (component, string, export); fails when the served bundle lacks it")
232
350
  parser.add_argument("--output", type=Path)
233
351
  args = parser.parse_args(argv)
234
352
  try:
235
- report = preflight(args.project, args.app_dir, args.base_url, args.rotating_refresh_tokens)
353
+ report = preflight(args.project, args.app_dir, args.base_url, args.rotating_refresh_tokens, expect_symbols=args.expect_symbol)
236
354
  except (OSError, ValueError) as error:
237
355
  print(f"BLOCKED: maggie qa app-preflight: {error}", file=sys.stderr)
238
356
  return 2
@@ -0,0 +1,217 @@
1
+ #!/usr/bin/env python3
2
+ """Fit-to-screen check for dashboards (``maggie qa fit-check``).
3
+
4
+ Some dashboards must never scroll the page: everything fits the viewport, and
5
+ long content scrolls inside a pane, a drawer or a pager. Checking this by hand
6
+ misses states — a tab that is hidden at load measures zero, so a list inside
7
+ it looks fine until it is shown. This command loads each route at each
8
+ viewport (default a short laptop 1536x687 and a 390x844 phone), measures page
9
+ overflow, then **activates every tab** (DOM click, so a background browser
10
+ window still works) and **opens every drawer trigger** and measures again
11
+ after each change. Hidden panels are only measured after activation.
12
+
13
+ Requests other than GET/HEAD/OPTIONS are failed by default (``--allow-write``
14
+ lets a token refresh through), so opening drawers on a production-backed app
15
+ cannot change data. One tab, one state at a time: safe with rotating refresh
16
+ tokens. Output is ``maggie-qa-fit-check.v1`` JSON; exit 1 when a state
17
+ overflows.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import json
24
+ import re
25
+ import sys
26
+ from datetime import datetime, timezone
27
+ from pathlib import Path
28
+
29
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
30
+ from cdp_client import Browser, CdpError, attach, launch # noqa: E402
31
+
32
+ SCHEMA = "maggie-qa-fit-check.v1"
33
+ DEFAULT_VIEWPORTS = ("1536x687", "390x844")
34
+ WRITE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
35
+
36
+ # Icon fonts put private-use glyphs into innerText (React Native Web tabs read
37
+ # "\nImage" or "Home"); labels drop them and prefer aria-label.
38
+ HELPERS = r"""
39
+ const __fit = window.__maggieFit = window.__maggieFit || {};
40
+ __fit.label = (el) => {
41
+ const aria = el.getAttribute('aria-label') || '';
42
+ const text = (aria || el.innerText || el.textContent || '').replace(/[-]/g, '').replace(/\s+/g, ' ').trim();
43
+ return text.slice(0, 60);
44
+ };
45
+ __fit.visible = (el) => { const r = el.getBoundingClientRect(); const s = getComputedStyle(el); return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none'; };
46
+ __fit.measure = (config) => {
47
+ const doc = document.scrollingElement || document.documentElement;
48
+ // Mobile emulation widens the layout viewport to fit wide content, so compare with the requested size.
49
+ const W = config.width || window.innerWidth, H = config.height || window.innerHeight;
50
+ const result = {width: W, height: H, pageOverflowY: Math.max(0, doc.scrollHeight - H), pageOverflowX: Math.max(0, doc.scrollWidth - W)};
51
+ if (result.pageOverflowX > 1) {
52
+ const wide = [...document.body.querySelectorAll('*')].find((el) => { const r = el.getBoundingClientRect(); return r.right > W + 1 && r.width > 0 && __fit.visible(el); });
53
+ if (wide) result.widest = (wide.tagName.toLowerCase() + (wide.id ? '#' + wide.id : '') + (typeof wide.className === 'string' && wide.className ? '.' + wide.className.trim().split(/\s+/).slice(0, 2).join('.') : '')).slice(0, 80);
54
+ }
55
+ if (config.sidebarSelector) {
56
+ const side = document.querySelector(config.sidebarSelector);
57
+ if (side) result.sidebarOverflowY = Math.max(0, side.scrollHeight - side.clientHeight);
58
+ }
59
+ const dialog = [...document.querySelectorAll('[role=dialog], dialog[open], [aria-modal=true]')].find(__fit.visible);
60
+ if (dialog) {
61
+ const r = dialog.getBoundingClientRect();
62
+ const inner = [dialog, ...dialog.querySelectorAll('*')].some((el) => el.scrollHeight > el.clientHeight + 1 && /(auto|scroll)/.test(getComputedStyle(el).overflowY));
63
+ result.dialog = {label: __fit.label(dialog), fits: r.left >= -1 && r.top >= -1 && r.right <= W + 1 && r.bottom <= H + 1, scrollsInside: inner};
64
+ }
65
+ return result;
66
+ };
67
+ __fit.targets = (selector) => [...document.querySelectorAll(selector)].filter(__fit.visible);
68
+ """
69
+
70
+
71
+ def parse_viewport(value: str) -> tuple[int, int]:
72
+ match = re.fullmatch(r"(\d{3,4})x(\d{3,4})", value.strip())
73
+ if not match:
74
+ raise ValueError(f"viewport must look like 1536x687, got {value!r}")
75
+ return int(match.group(1)), int(match.group(2))
76
+
77
+
78
+ def verdict(states: list[dict], tolerance: int = 1) -> list[str]:
79
+ """Failures for measured states: page overflow on either axis, a drawer outside the viewport."""
80
+ failures = []
81
+ for state in states:
82
+ where = f"{state['route']} @ {state['viewport']} [{state['state']}]"
83
+ m = state.get("measure") or {}
84
+ if state.get("error"):
85
+ failures.append(f"{where}: {state['error']}")
86
+ continue
87
+ if m.get("pageOverflowY", 0) > tolerance:
88
+ failures.append(f"{where}: the page scrolls vertically by {m['pageOverflowY']} px; move long content into a pane, drawer or pager")
89
+ if m.get("pageOverflowX", 0) > tolerance:
90
+ failures.append(f"{where}: the page scrolls sideways by {m['pageOverflowX']} px" + (f" (first wide element: {m['widest']})" if m.get("widest") else ""))
91
+ if m.get("sidebarOverflowY", 0) > tolerance:
92
+ failures.append(f"{where}: the sidebar overflows by {m['sidebarOverflowY']} px")
93
+ dialog = m.get("dialog")
94
+ if dialog and not dialog.get("fits"):
95
+ failures.append(f"{where}: drawer {dialog.get('label') or ''!r} extends past the viewport")
96
+ return failures
97
+
98
+
99
+ def _install_read_only(browser: Browser, allow: list[str], blocked: list[str]) -> None:
100
+ def paused(params: dict) -> None:
101
+ request = params.get("request", {})
102
+ url = request.get("url", "")
103
+ if request.get("method", "GET").upper() in WRITE_METHODS and not any(token in url for token in allow):
104
+ blocked.append(f"{request.get('method')} {url.split('?')[0][:120]}")
105
+ browser.call("Fetch.failRequest", {"requestId": params["requestId"], "errorReason": "BlockedByClient"})
106
+ else:
107
+ browser.call("Fetch.continueRequest", {"requestId": params["requestId"]})
108
+ browser.handlers["Fetch.requestPaused"] = paused
109
+ browser.call("Fetch.enable", {"patterns": [{"urlPattern": "*", "requestStage": "Request"}]})
110
+
111
+
112
+ def _load(browser: Browser, url: str, settle: float, budget: float) -> None:
113
+ browser.call("Page.navigate", {"url": url})
114
+ browser.wait_for("Page.loadEventFired", budget)
115
+ browser.pump(settle)
116
+ browser.evaluate(HELPERS)
117
+
118
+
119
+ def check(browser: Browser, base_url: str, routes: list[str], viewports: list[tuple[int, int]], options: dict, settle_ms: int = 800, budget_ms: int = 20000, read_only: bool = True, allow_write: list[str] | None = None) -> dict:
120
+ settle, budget = settle_ms / 1000, budget_ms / 1000
121
+ blocked: list[str] = []
122
+ browser.open_tab()
123
+ browser.call("Page.enable")
124
+ if read_only:
125
+ _install_read_only(browser, allow_write or [], blocked)
126
+ states: list[dict] = []
127
+ for width, height in viewports: # sequential: one app instance at a time
128
+ browser.call("Emulation.setDeviceMetricsOverride", {"width": width, "height": height, "deviceScaleFactor": 1, "mobile": width < 768})
129
+ viewport = f"{width}x{height}"
130
+ config = json.dumps({"sidebarSelector": options.get("sidebarSelector"), "width": width, "height": height})
131
+ for route in routes:
132
+ url = base_url.rstrip("/") + route
133
+ _load(browser, url, settle, budget)
134
+ states.append({"route": route, "viewport": viewport, "state": "initial", "measure": browser.evaluate(f"__maggieFit.measure({config})")})
135
+ tab_selector = json.dumps(options["tabSelector"])
136
+ tabs = browser.evaluate(f"__maggieFit.targets({tab_selector}).map(__maggieFit.label)") or []
137
+ for index, label in enumerate(tabs):
138
+ clicked = browser.evaluate(f"(() => {{ const el = __maggieFit.targets({tab_selector})[{index}]; if (!el) return false; el.click(); return true; }})()")
139
+ browser.pump(settle)
140
+ entry = {"route": route, "viewport": viewport, "state": f"tab {index + 1}: {label or '(no label)'}"}
141
+ if clicked:
142
+ entry["measure"] = browser.evaluate(f"__maggieFit.measure({config})")
143
+ entry["hash"] = browser.evaluate("location.hash")
144
+ else:
145
+ entry["error"] = "tab disappeared before it could be activated"
146
+ states.append(entry)
147
+ if options.get("drawerSelector"):
148
+ drawer_selector = json.dumps(options["drawerSelector"])
149
+ _load(browser, url, settle, budget)
150
+ triggers = browser.evaluate(f"__maggieFit.targets({drawer_selector}).map(__maggieFit.label)") or []
151
+ for index, label in enumerate(triggers[: options.get("maxDrawers", 12)]):
152
+ if index:
153
+ _load(browser, url, settle, budget) # a fresh page per drawer, so states never stack
154
+ browser.evaluate(f"(() => {{ const el = __maggieFit.targets({drawer_selector})[{index}]; if (el) el.click(); }})()")
155
+ browser.pump(settle)
156
+ measure = browser.evaluate(f"__maggieFit.measure({config})")
157
+ entry = {"route": route, "viewport": viewport, "state": f"drawer {index + 1}: {label or '(no label)'}", "measure": measure}
158
+ if not (measure or {}).get("dialog"):
159
+ entry["warning"] = "no visible role=dialog after the click"
160
+ states.append(entry)
161
+ failures = verdict(states)
162
+ return {
163
+ "schemaVersion": SCHEMA,
164
+ "baseUrl": base_url,
165
+ "routes": routes,
166
+ "viewports": [f"{w}x{h}" for w, h in viewports],
167
+ "states": states,
168
+ "blockedWrites": sorted(set(blocked)),
169
+ "status": "failed" if failures else "passed",
170
+ "failures": failures,
171
+ "checkedAt": datetime.now(timezone.utc).isoformat(),
172
+ }
173
+
174
+
175
+ def main(argv: list[str] | None = None) -> int:
176
+ parser = argparse.ArgumentParser(prog="maggie qa fit-check", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
177
+ parser.add_argument("--base-url", required=True)
178
+ parser.add_argument("--route", action="append", required=True, help="path to check; repeat for every dashboard route")
179
+ parser.add_argument("--viewport", action="append", default=[], help="WIDTHxHEIGHT; repeatable (default 1536x687 and 390x844)")
180
+ session = parser.add_mutually_exclusive_group(required=True)
181
+ session.add_argument("--cdp-url", help="attach to a signed-in Chrome started with --remote-debugging-port")
182
+ session.add_argument("--profile-dir", type=Path, help="dedicated QA Chrome profile (never your everyday profile)")
183
+ parser.add_argument("--chrome", default="")
184
+ parser.add_argument("--headed", action="store_true")
185
+ parser.add_argument("--tab-selector", default="[role=tab]")
186
+ parser.add_argument("--drawer-selector", help="elements that open a drawer or dialog, e.g. [data-drawer-trigger]")
187
+ parser.add_argument("--sidebar-selector", help="sidebar that must not overflow either")
188
+ parser.add_argument("--allow-write", action="append", default=[], help="URL substring allowed to POST (token refresh)")
189
+ parser.add_argument("--allow-writes", action="store_true", help="do not block write requests (non-production API only)")
190
+ parser.add_argument("--settle-ms", type=int, default=800)
191
+ parser.add_argument("--output", type=Path)
192
+ args = parser.parse_args(argv)
193
+ browser = None
194
+ try:
195
+ viewports = [parse_viewport(value) for value in (args.viewport or DEFAULT_VIEWPORTS)]
196
+ if args.cdp_url:
197
+ browser = attach(args.cdp_url)
198
+ else:
199
+ from maggie_icon_glyph_check import find_chrome
200
+ browser = launch(args.chrome or find_chrome(), args.profile_dir.resolve(), headless=not args.headed)
201
+ report = check(browser, args.base_url, args.route, viewports, {"tabSelector": args.tab_selector, "drawerSelector": args.drawer_selector, "sidebarSelector": args.sidebar_selector}, args.settle_ms, read_only=not args.allow_writes, allow_write=args.allow_write)
202
+ except (OSError, ValueError, CdpError, RuntimeError) as error:
203
+ print(f"BLOCKED: maggie qa fit-check: {error}", file=sys.stderr)
204
+ return 2
205
+ finally:
206
+ if browser:
207
+ browser.close()
208
+ text = json.dumps(report, indent=2, ensure_ascii=False)
209
+ if args.output:
210
+ args.output.parent.mkdir(parents=True, exist_ok=True)
211
+ args.output.write_text(text + "\n", encoding="utf-8")
212
+ print(text)
213
+ return 1 if report["failures"] else 0
214
+
215
+
216
+ if __name__ == "__main__":
217
+ raise SystemExit(main())
@@ -615,6 +615,11 @@ def parser() -> argparse.ArgumentParser:
615
615
 
616
616
 
617
617
  def main() -> int:
618
+ if "fit-check" in sys.argv[1:3]:
619
+ from maggie_qa_fit import main as fit_main
620
+ argv = sys.argv[1:]
621
+ index = argv.index("fit-check")
622
+ return fit_main(argv[:index] + argv[index + 1:])
618
623
  if "app-preflight" in sys.argv[1:3]:
619
624
  from maggie_qa_app_preflight import main as app_preflight_main
620
625
  argv = sys.argv[1:]
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env python3
2
+ """Estimate structured-output JSON schema complexity before a model rejects it.
3
+
4
+ Gemini answers ``400 INVALID_ARGUMENT`` (with no detail) when a response
5
+ schema is too complex. The limit is complexity, not vocabulary size: an enum
6
+ of 5-6 values inside the items of an array with ``maxItems: 20`` fails, while
7
+ 4 values or ``maxItems: 10`` pass. The size of the state space is roughly
8
+ enum values x repeated array items x nesting, so the check:
9
+
10
+ - fails on any ``enum``/``const`` list inside repeated array items: describe
11
+ the allowed values in the prompt or the field description and validate
12
+ server-side instead;
13
+ - fails on enums over 20 values anywhere, and on arrays without ``maxItems``
14
+ around nested objects;
15
+ - reports an estimated complexity score (sum of enum values and properties,
16
+ each multiplied by the ``maxItems`` of every enclosing array) and warns
17
+ above 200, plus nesting deeper than 5.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import json
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ ENUM_LIMIT = 20
28
+ SCORE_WARN = 200
29
+ DEPTH_WARN = 5
30
+
31
+
32
+ def check_schema(schema: dict) -> dict:
33
+ errors: list[str] = []
34
+ warnings: list[str] = []
35
+ score = 0
36
+ deepest = 0
37
+
38
+ def walk(node: object, path: str, multiplier: int, in_array: bool, depth: int) -> None:
39
+ nonlocal score, deepest
40
+ if not isinstance(node, dict):
41
+ return
42
+ deepest = max(deepest, depth)
43
+ values = node.get("enum") if isinstance(node.get("enum"), list) else None
44
+ if values is not None:
45
+ score += len(values) * multiplier
46
+ if in_array:
47
+ errors.append(f"{path}: enum of {len(values)} inside repeated array items (x{multiplier}); describe the allowed values in the prompt and validate server-side")
48
+ elif len(values) > ENUM_LIMIT:
49
+ errors.append(f"{path}: enum of {len(values)} values; use a string, list examples in the prompt, validate server-side")
50
+ properties = node.get("properties") if isinstance(node.get("properties"), dict) else {}
51
+ score += len(properties) * multiplier
52
+ for name, child in properties.items():
53
+ walk(child, f"{path}.{name}", multiplier, in_array, depth + 1)
54
+ items = node.get("items")
55
+ if isinstance(items, dict):
56
+ max_items = node.get("maxItems")
57
+ if not isinstance(max_items, int):
58
+ if isinstance(items.get("properties"), dict) or isinstance(items.get("enum"), list):
59
+ errors.append(f"{path}: array of objects/enums without maxItems; set a small maxItems")
60
+ max_items = 10
61
+ walk(items, f"{path}[]", multiplier * max(1, max_items), True, depth + 1)
62
+ for key in ("anyOf", "oneOf", "allOf"):
63
+ for index, child in enumerate(node.get(key) or []):
64
+ walk(child, f"{path}.{key}[{index}]", multiplier, in_array, depth + 1)
65
+
66
+ walk(schema, "$", 1, False, 0)
67
+ if score > SCORE_WARN:
68
+ warnings.append(f"estimated complexity {score} is high; lower maxItems, split the call, or move vocabularies into the prompt")
69
+ if deepest > DEPTH_WARN:
70
+ warnings.append(f"nesting depth {deepest}; flatten the schema")
71
+ return {"schemaVersion": "maggie-schema-complexity.v1", "valid": not errors, "score": score, "depth": deepest, "errors": errors, "warnings": warnings}
72
+
73
+
74
+ def main(argv: list[str] | None = None) -> int:
75
+ parser = argparse.ArgumentParser(prog="maggie social schema-check", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
76
+ parser.add_argument("input", type=Path, help="JSON schema file (responseJsonSchema)")
77
+ args = parser.parse_args(argv)
78
+ result = check_schema(json.loads(args.input.read_text(encoding="utf-8")))
79
+ print(json.dumps(result, indent=2, ensure_ascii=False))
80
+ return 0 if result["valid"] else 1
81
+
82
+
83
+ if __name__ == "__main__":
84
+ raise SystemExit(main())
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env python3
2
+ """Route-check guards for SEO release checks (``maggie seo origin-check|fragment-check``).
3
+
4
+ ``origin-check`` proves which site a base URL serves before route checks run
5
+ against it. Check scripts that default to ``http://127.0.0.1:4321`` silently hit
6
+ whatever else runs on that port and report false "foreign URL" and sitemap
7
+ failures. The command fetches the page and ``/robots.txt`` and compares the
8
+ canonical / ``og:url`` / sitemap host with the project's configured site
9
+ (``site`` in ``astro.config.*``, or ``--expect-host``), and prints the origin it
10
+ checked.
11
+
12
+ ``fragment-check`` checks personalised HTML fragments (recently viewed,
13
+ recommendations, "your bookings" partials): each must answer
14
+ ``Cache-Control: private, no-store`` (no ``public``/``s-maxage``) and
15
+ ``X-Robots-Tag: noindex``. Global middleware that sets public caching on every
16
+ ``text/html`` response must keep a route's private header; otherwise a CDN
17
+ caches one person's fragment and serves it to others.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import json
24
+ import re
25
+ import sys
26
+ from pathlib import Path
27
+ from urllib.error import HTTPError, URLError
28
+ from urllib.parse import urljoin, urlsplit
29
+ from urllib.request import Request, urlopen
30
+
31
+ CANONICAL = re.compile(r"<link[^>]+rel=[\"']canonical[\"'][^>]*href=[\"']([^\"']+)|<link[^>]+href=[\"']([^\"']+)[\"'][^>]*rel=[\"']canonical[\"']", re.I)
32
+ OG_URL = re.compile(r"<meta[^>]+property=[\"']og:url[\"'][^>]*content=[\"']([^\"']+)", re.I)
33
+ SITE_CONFIG = re.compile(r"\bsite\s*:\s*[\"'`](https?://[^\"'`]+)[\"'`]")
34
+
35
+
36
+ def fetch(url: str, headers: dict | None = None, limit: int = 2_000_000) -> tuple[int, dict, str]:
37
+ request = Request(url, headers={"User-Agent": "maggie-seo-route-check", **(headers or {})})
38
+ try:
39
+ with urlopen(request, timeout=20) as response: # noqa: S310 - operator-supplied check URL
40
+ return response.status, {k.lower(): v for k, v in response.headers.items()}, response.read(limit).decode("utf-8", "replace")
41
+ except HTTPError as error:
42
+ return error.code, {k.lower(): v for k, v in error.headers.items()}, ""
43
+
44
+
45
+ def host(url: str) -> str:
46
+ return (urlsplit(url).hostname or "").lower().removeprefix("www.")
47
+
48
+
49
+ def configured_host(project: Path) -> str | None:
50
+ for name in ("astro.config.mjs", "astro.config.ts", "astro.config.js", "astro.config.mts"):
51
+ path = project / name
52
+ if path.is_file():
53
+ match = SITE_CONFIG.search(path.read_text(encoding="utf-8", errors="replace"))
54
+ if match:
55
+ return host(match.group(1))
56
+ return None
57
+
58
+
59
+ def origin_check(base_url: str, project: Path, expect_host: str | None = None, fetcher=fetch) -> dict:
60
+ parts = urlsplit(base_url)
61
+ if parts.scheme not in {"http", "https"} or not parts.netloc:
62
+ raise ValueError("--base-url must be an absolute http(s) URL; route checks must never assume a default port")
63
+ origin = f"{parts.scheme}://{parts.netloc}"
64
+ expected = (expect_host or configured_host(project) or "").lower().removeprefix("www.") or None
65
+ errors: list[str] = []
66
+ warnings: list[str] = []
67
+ try:
68
+ status, _, html = fetcher(base_url)
69
+ robots_status, _, robots = fetcher(urljoin(origin + "/", "robots.txt"))
70
+ except (URLError, OSError) as error:
71
+ return {"schemaVersion": "maggie-seo-origin-check.v1", "origin": origin, "passed": False, "errors": [f"nothing answered at {origin}: {error}"], "warnings": []}
72
+ seen = {}
73
+ canonical = CANONICAL.search(html)
74
+ if canonical:
75
+ seen["canonical"] = host(canonical.group(1) or canonical.group(2))
76
+ og = OG_URL.search(html)
77
+ if og:
78
+ seen["og:url"] = host(og.group(1))
79
+ sitemap = re.search(r"^\s*sitemap:\s*(\S+)", robots, re.I | re.M) if robots_status == 200 else None
80
+ if sitemap:
81
+ seen["robots sitemap"] = host(sitemap.group(1))
82
+ if status >= 400:
83
+ errors.append(f"{base_url} answered {status}")
84
+ if not expected:
85
+ warnings.append("no expected host: set `site` in astro.config or pass --expect-host, so a different local project on this port is detected")
86
+ elif not seen:
87
+ warnings.append(f"{origin} shows no canonical, og:url or robots sitemap to identify the site")
88
+ else:
89
+ foreign = {source: value for source, value in seen.items() if value and value != expected and not value.endswith("." + expected)}
90
+ if foreign:
91
+ errors.append(f"{origin} serves another site ({', '.join(f'{k}: {v}' for k, v in foreign.items())}), not {expected}; start this project's server or pass the right --base-url")
92
+ return {"schemaVersion": "maggie-seo-origin-check.v1", "origin": origin, "expectedHost": expected, "seenHosts": seen, "passed": not errors, "errors": errors, "warnings": warnings}
93
+
94
+
95
+ def fragment_problems(headers: dict) -> list[str]:
96
+ cache = headers.get("cache-control", "").lower()
97
+ directives = {item.strip().split("=")[0] for item in cache.split(",") if item.strip()}
98
+ problems = []
99
+ if "private" not in directives:
100
+ problems.append("Cache-Control lacks private")
101
+ if "no-store" not in directives:
102
+ problems.append("Cache-Control lacks no-store")
103
+ if directives & {"public", "s-maxage"}:
104
+ problems.append("Cache-Control allows shared caching (" + ", ".join(sorted(directives & {"public", "s-maxage"})) + "); middleware must keep the route's private header")
105
+ if "noindex" not in headers.get("x-robots-tag", "").lower():
106
+ problems.append("X-Robots-Tag lacks noindex")
107
+ if headers.get("cdn-cache-control") or headers.get("surrogate-control"):
108
+ problems.append("a CDN-specific cache header is set; remove it for personalised fragments")
109
+ return problems
110
+
111
+
112
+ def fragment_check(urls: list[str], cookie: str | None = None, fetcher=fetch) -> dict:
113
+ rows = []
114
+ errors = []
115
+ for url in urls:
116
+ try:
117
+ status, headers, _ = fetcher(url, {"Cookie": cookie} if cookie else None)
118
+ except (URLError, OSError) as error:
119
+ errors.append(f"{url}: {error}")
120
+ continue
121
+ problems = fragment_problems(headers)
122
+ rows.append({"url": url, "status": status, "cacheControl": headers.get("cache-control"), "xRobotsTag": headers.get("x-robots-tag"), "problems": problems})
123
+ errors += [f"{url}: {problem}" for problem in problems]
124
+ return {"schemaVersion": "maggie-seo-fragment-check.v1", "fragments": rows, "passed": not errors, "errors": errors}
125
+
126
+
127
+ def main(argv: list[str] | None = None) -> int:
128
+ parser = argparse.ArgumentParser(prog="maggie seo", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
129
+ sub = parser.add_subparsers(dest="command", required=True)
130
+ origin = sub.add_parser("origin-check")
131
+ origin.add_argument("--base-url", required=True)
132
+ origin.add_argument("--project", type=Path, default=Path("."))
133
+ origin.add_argument("--expect-host")
134
+ fragment = sub.add_parser("fragment-check")
135
+ fragment.add_argument("--url", action="append", required=True, help="personalised fragment URL; repeat")
136
+ fragment.add_argument("--cookie-env", help="environment variable holding a test session cookie (never pass the value on the command line)")
137
+ args = parser.parse_args(argv)
138
+ try:
139
+ if args.command == "origin-check":
140
+ report = origin_check(args.base_url, args.project.resolve(), args.expect_host)
141
+ else:
142
+ import os
143
+ report = fragment_check(args.url, os.environ.get(args.cookie_env) if args.cookie_env else None)
144
+ except ValueError as error:
145
+ print(f"BLOCKED: maggie seo {args.command}: {error}", file=sys.stderr)
146
+ return 2
147
+ print(json.dumps(report, indent=2, ensure_ascii=False))
148
+ if args.command == "origin-check":
149
+ print(f"checked origin: {report['origin']}", file=sys.stderr)
150
+ return 0 if report["passed"] else 1
151
+
152
+
153
+ if __name__ == "__main__":
154
+ raise SystemExit(main())