design-playbook 0.7.0
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/LICENSE +28 -0
- package/NOTICE +37 -0
- package/README.md +143 -0
- package/commands/design-io.md +8 -0
- package/commands/ui-review.md +8 -0
- package/commands/ux-spec.md +8 -0
- package/mcp/__init__.py +0 -0
- package/mcp/_transport.py +242 -0
- package/mcp/evidence/README.md +40 -0
- package/mcp/evidence/__init__.py +0 -0
- package/mcp/evidence/server.py +450 -0
- package/mcp/evidence/test_server_stdio.py +645 -0
- package/mcp/preview/__init__.py +0 -0
- package/mcp/preview/browser.py +661 -0
- package/mcp/preview/confirm.py +255 -0
- package/mcp/preview/control.py +1293 -0
- package/mcp/preview/i18n.py +162 -0
- package/mcp/preview/server.py +126 -0
- package/mcp/preview/test_browser_control.py +663 -0
- package/mcp/preview/test_server_stdio.py +630 -0
- package/mcp/preview/test_transaction.py +436 -0
- package/mcp/preview/transaction.py +536 -0
- package/mcp/preview/util.py +19 -0
- package/mcp/test_transport.py +39 -0
- package/package.json +42 -0
- package/skills/craft-guard/SKILL.md +59 -0
- package/skills/craft-guard/references/craft.md +29 -0
- package/skills/craft-guard/references/detectors.md +124 -0
- package/skills/design-baseline/SKILL.md +134 -0
- package/skills/design-baseline/agents/openai.yaml +4 -0
- package/skills/design-baseline/references/design-template.md +73 -0
- package/skills/design-baseline/references/extraction-guidance.md +39 -0
- package/skills/design-baseline/scripts/design_baseline.py +780 -0
- package/skills/design-playbook/SKILL.md +219 -0
- package/skills/native-craft/SKILL.md +59 -0
- package/skills/native-craft/references/native-feel.md +79 -0
- package/skills/reference-intake/SKILL.md +86 -0
- package/skills/reference-intake/references/contract-template.md +82 -0
- package/skills/ui-evaluator/SKILL.md +110 -0
- package/skills/ui-evaluator/references/rubric.md +45 -0
- package/skills/ui-picker/SKILL.md +63 -0
- package/skills/ui-picker/references/components.md +31 -0
- package/skills/ui-picker/references/design.md +21 -0
- package/skills/ui-picker/references/domain.md +26 -0
- package/skills/ui-picker/references/template.md +24 -0
- package/skills/ux-spec/SKILL.md +51 -0
- package/skills/ux-spec/references/spec-template.md +43 -0
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Minimal stdio MCP server: single tool `execute_capture_plan`.
|
|
3
|
+
|
|
4
|
+
Evidence Provider adapter (ticket 02). Captures artifacts via Playwright.
|
|
5
|
+
Never writes manifest.jsonl; never accepts criterion refs (orchestrator binds).
|
|
6
|
+
Returns relative ``artifact`` plus absolute ``written_path`` so RUN_ROOT/cwd
|
|
7
|
+
misconfig is visible to the orchestrator.
|
|
8
|
+
|
|
9
|
+
Run (plugin-bundled MCP config uses ${CLAUDE_PLUGIN_ROOT}):
|
|
10
|
+
{ "command": "python", "args": ["<plugin>/mcp/evidence/server.py"],
|
|
11
|
+
"env": {"DESIGN_PLAYBOOK_RUN_ROOT": "."} }
|
|
12
|
+
DESIGN_PLAYBOOK_RUN_ROOT="." is process-cwd-relative — point it at the
|
|
13
|
+
absolute run root when the host workspace is not the MCP cwd.
|
|
14
|
+
Compatibility launcher remains at packages/design-playbook-evidence/server.py.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
# Shared stdio JSON-RPC framing + single-tool dispatch live one level up in
|
|
25
|
+
# mcp/_transport.py (both bundled adapters speak the same wire format and
|
|
26
|
+
# run the same JSON-RPC protocol; ADR-0009).
|
|
27
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
28
|
+
from _transport import serve_stdio # noqa: E402
|
|
29
|
+
|
|
30
|
+
TOOL_NAME = "execute_capture_plan"
|
|
31
|
+
SERVER_NAME = "design-playbook-evidence"
|
|
32
|
+
SERVER_VERSION = "0.1.0"
|
|
33
|
+
CAPTURE_TYPES = frozenset({"screenshot", "a11y tree", "interaction trace"})
|
|
34
|
+
ALLOWED_ARGUMENTS = frozenset(
|
|
35
|
+
{"url", "type", "state", "actions", "artifact_path", "overwrite"}
|
|
36
|
+
)
|
|
37
|
+
RUN_ROOT_ENV = "DESIGN_PLAYBOOK_RUN_ROOT"
|
|
38
|
+
EVIDENCE_SUBDIR = "evidence"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _log(msg: str) -> None:
|
|
42
|
+
print(msg, file=sys.stderr, flush=True)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _tool_schema() -> dict[str, Any]:
|
|
46
|
+
return {
|
|
47
|
+
"name": TOOL_NAME,
|
|
48
|
+
"description": (
|
|
49
|
+
"Execute a capture plan snapshot: navigate, run actions, write one "
|
|
50
|
+
"artifact (screenshot / a11y tree / interaction trace). Returns "
|
|
51
|
+
"capture result only (artifact, observed_state, result, error, "
|
|
52
|
+
"written_path absolute) — never writes manifest; never judges criteria."
|
|
53
|
+
),
|
|
54
|
+
"inputSchema": {
|
|
55
|
+
"type": "object",
|
|
56
|
+
"properties": {
|
|
57
|
+
"url": {
|
|
58
|
+
"type": "string",
|
|
59
|
+
"description": "Target host URL (or file://) to capture.",
|
|
60
|
+
},
|
|
61
|
+
"type": {
|
|
62
|
+
"type": "string",
|
|
63
|
+
"description": 'v1: "screenshot" | "a11y tree" | "interaction trace".',
|
|
64
|
+
"enum": ["screenshot", "a11y tree", "interaction trace"],
|
|
65
|
+
},
|
|
66
|
+
"state": {
|
|
67
|
+
"type": "string",
|
|
68
|
+
"description": "Expected page state label (error/loading/ok/...).",
|
|
69
|
+
},
|
|
70
|
+
"actions": {
|
|
71
|
+
"type": "array",
|
|
72
|
+
"description": (
|
|
73
|
+
"Trigger sequence until state (may be empty). Each "
|
|
74
|
+
"action is an object: do=click|fill|type|press|"
|
|
75
|
+
"select_option|wait_for_selector|wait_for_state|wait "
|
|
76
|
+
"with selector (click/fill/type/press/select_option/"
|
|
77
|
+
"wait_for_selector), value/label (fill/type/select_option), "
|
|
78
|
+
"key (press), state (wait_for_state), ms (wait). "
|
|
79
|
+
"select_option drives a native <select> by option value "
|
|
80
|
+
"(or visible label) and fires change."
|
|
81
|
+
),
|
|
82
|
+
"items": {"type": "object"},
|
|
83
|
+
},
|
|
84
|
+
"artifact_path": {
|
|
85
|
+
"type": "string",
|
|
86
|
+
"description": (
|
|
87
|
+
"Relative artifact path under the evidence/ subtree of "
|
|
88
|
+
"the configured run root (must already start with "
|
|
89
|
+
"'evidence/'). Provider only writes this file."
|
|
90
|
+
),
|
|
91
|
+
},
|
|
92
|
+
"overwrite": {
|
|
93
|
+
"type": "boolean",
|
|
94
|
+
"description": (
|
|
95
|
+
"Opt in to replacing an existing artifact. Default "
|
|
96
|
+
"false: an existing file is refused (G6 write boundary)."
|
|
97
|
+
),
|
|
98
|
+
"default": False,
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
"required": ["url", "type", "state", "artifact_path"],
|
|
102
|
+
"additionalProperties": False,
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _failed(
|
|
108
|
+
artifact: str, error: str, written_path: str = "",
|
|
109
|
+
) -> dict[str, Any]:
|
|
110
|
+
"""Capture failure payload.
|
|
111
|
+
|
|
112
|
+
``written_path`` is the absolute path under the resolved run root when
|
|
113
|
+
known (empty when path resolution never ran). Callers must not treat a
|
|
114
|
+
non-empty path as proof the file exists — only as where the write was
|
|
115
|
+
attempted. Exposing the absolute path makes cwd / RUN_ROOT misconfig
|
|
116
|
+
visible to the orchestrator without a post-hoc filesystem search.
|
|
117
|
+
"""
|
|
118
|
+
return {
|
|
119
|
+
"artifact": artifact,
|
|
120
|
+
"observed_state": "unknown",
|
|
121
|
+
"result": "failed",
|
|
122
|
+
"error": error,
|
|
123
|
+
"written_path": written_path,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _captured(
|
|
128
|
+
artifact: str, observed_state: str, written_path: str,
|
|
129
|
+
) -> dict[str, Any]:
|
|
130
|
+
"""Successful capture payload.
|
|
131
|
+
|
|
132
|
+
``written_path`` is always the absolute path of the written artifact
|
|
133
|
+
(resolved under DESIGN_PLAYBOOK_RUN_ROOT or process cwd). Relative
|
|
134
|
+
``artifact`` stays the run-root-relative path for manifest binding.
|
|
135
|
+
"""
|
|
136
|
+
return {
|
|
137
|
+
"artifact": artifact,
|
|
138
|
+
"observed_state": observed_state,
|
|
139
|
+
"result": "captured",
|
|
140
|
+
"error": "",
|
|
141
|
+
"written_path": written_path,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _run_root() -> Path:
|
|
146
|
+
configured = os.environ.get(RUN_ROOT_ENV)
|
|
147
|
+
return Path(configured).resolve() if configured else Path.cwd().resolve()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _resolve_artifact_path(artifact_path: str) -> Path:
|
|
151
|
+
"""Resolve ``artifact_path`` to an absolute path under ``<run_root>/evidence/``.
|
|
152
|
+
|
|
153
|
+
G6 write boundary (issue 04):
|
|
154
|
+
* reject absolute paths (POSIX + Windows + native);
|
|
155
|
+
* reject any ``..`` segment in the requested path (defence in depth
|
|
156
|
+
before resolution — also catches ``evidence/../spec.md``);
|
|
157
|
+
* the resolved candidate must stay under ``<run_root>/evidence/``;
|
|
158
|
+
* explicit ``realpath`` check so a symlink chain that resolves out of
|
|
159
|
+
the evidence subtree is rejected even when ``Path.resolve`` and
|
|
160
|
+
``os.path.realpath`` disagree across platforms.
|
|
161
|
+
|
|
162
|
+
The caller is responsible for providing a path that already starts with
|
|
163
|
+
``evidence/``; we do not prepend it (``spec.md`` and ``skills/x`` are
|
|
164
|
+
refused because they land outside the evidence subtree).
|
|
165
|
+
"""
|
|
166
|
+
requested = Path(artifact_path)
|
|
167
|
+
if (
|
|
168
|
+
requested.is_absolute()
|
|
169
|
+
or PureWindowsPath(artifact_path).is_absolute()
|
|
170
|
+
or PurePosixPath(artifact_path).is_absolute()
|
|
171
|
+
):
|
|
172
|
+
raise ValueError("artifact_path must be relative to the configured run root")
|
|
173
|
+
|
|
174
|
+
if any(part == ".." for part in requested.parts):
|
|
175
|
+
raise ValueError("artifact_path must not contain '..' segments")
|
|
176
|
+
|
|
177
|
+
root = _run_root()
|
|
178
|
+
evidence_root = (root / EVIDENCE_SUBDIR).resolve(strict=False)
|
|
179
|
+
candidate = (root / requested).resolve(strict=False)
|
|
180
|
+
try:
|
|
181
|
+
candidate.relative_to(evidence_root)
|
|
182
|
+
except ValueError as exc:
|
|
183
|
+
raise ValueError(
|
|
184
|
+
"artifact_path must stay under the evidence/ subtree"
|
|
185
|
+
) from exc
|
|
186
|
+
|
|
187
|
+
# Defence in depth: realpath must also stay under evidence/. Catches
|
|
188
|
+
# symlink chains that Path.resolve may normalise differently per platform.
|
|
189
|
+
try:
|
|
190
|
+
Path(os.path.realpath(candidate)).relative_to(
|
|
191
|
+
os.path.realpath(evidence_root)
|
|
192
|
+
)
|
|
193
|
+
except ValueError as exc:
|
|
194
|
+
raise ValueError(
|
|
195
|
+
"artifact_path symlink escapes the evidence/ subtree"
|
|
196
|
+
) from exc
|
|
197
|
+
|
|
198
|
+
return candidate
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _run_actions(page: Any, actions: list[dict[str, Any]]) -> None:
|
|
202
|
+
for i, action in enumerate(actions):
|
|
203
|
+
if not isinstance(action, dict):
|
|
204
|
+
raise ValueError(f"actions[{i}] must be an object")
|
|
205
|
+
do = action.get("do")
|
|
206
|
+
if not isinstance(do, str) or not do.strip():
|
|
207
|
+
raise ValueError(f"actions[{i}].do is required")
|
|
208
|
+
do = do.strip().lower()
|
|
209
|
+
selector = action.get("selector")
|
|
210
|
+
if do == "click":
|
|
211
|
+
if not isinstance(selector, str) or not selector:
|
|
212
|
+
raise ValueError(f"actions[{i}].selector required for click")
|
|
213
|
+
page.click(selector, timeout=10_000)
|
|
214
|
+
elif do in ("fill", "type"):
|
|
215
|
+
if not isinstance(selector, str) or not selector:
|
|
216
|
+
raise ValueError(f"actions[{i}].selector required for {do}")
|
|
217
|
+
value = action.get("value")
|
|
218
|
+
if value is None:
|
|
219
|
+
value = action.get("text", "")
|
|
220
|
+
if not isinstance(value, str):
|
|
221
|
+
raise ValueError(f"actions[{i}].value must be a string")
|
|
222
|
+
if do == "fill":
|
|
223
|
+
page.fill(selector, value, timeout=10_000)
|
|
224
|
+
else:
|
|
225
|
+
page.click(selector, timeout=10_000)
|
|
226
|
+
page.keyboard.type(value)
|
|
227
|
+
elif do == "press":
|
|
228
|
+
key = action.get("key") or action.get("value")
|
|
229
|
+
if not isinstance(key, str) or not key:
|
|
230
|
+
raise ValueError(f"actions[{i}].key required for press")
|
|
231
|
+
if isinstance(selector, str) and selector:
|
|
232
|
+
page.press(selector, key, timeout=10_000)
|
|
233
|
+
else:
|
|
234
|
+
page.keyboard.press(key)
|
|
235
|
+
elif do == "wait_for_selector":
|
|
236
|
+
if not isinstance(selector, str) or not selector:
|
|
237
|
+
raise ValueError(
|
|
238
|
+
f"actions[{i}].selector required for wait_for_selector"
|
|
239
|
+
)
|
|
240
|
+
page.wait_for_selector(selector, timeout=10_000)
|
|
241
|
+
elif do == "wait_for_state":
|
|
242
|
+
state = action.get("state")
|
|
243
|
+
if not isinstance(state, str) or not state:
|
|
244
|
+
raise ValueError(f"actions[{i}].state required for wait_for_state")
|
|
245
|
+
# Prefer explicit selector; else body[data-state].
|
|
246
|
+
if isinstance(selector, str) and selector:
|
|
247
|
+
page.wait_for_selector(selector, timeout=10_000)
|
|
248
|
+
else:
|
|
249
|
+
page.wait_for_selector(
|
|
250
|
+
f'[data-state="{state}"]',
|
|
251
|
+
timeout=10_000,
|
|
252
|
+
)
|
|
253
|
+
elif do in ("wait", "sleep"):
|
|
254
|
+
ms = action.get("ms")
|
|
255
|
+
if ms is None:
|
|
256
|
+
ms = action.get("timeout_ms", 200)
|
|
257
|
+
page.wait_for_timeout(int(ms))
|
|
258
|
+
elif do == "select_option":
|
|
259
|
+
# Native <select> — page.fill raises "Fill did not work on <select>";
|
|
260
|
+
# select_option drives <option> by value (or visible label) and
|
|
261
|
+
# fires change.
|
|
262
|
+
if not isinstance(selector, str) or not selector:
|
|
263
|
+
raise ValueError(
|
|
264
|
+
f"actions[{i}].selector required for select_option")
|
|
265
|
+
value = action.get("value")
|
|
266
|
+
label = action.get("label")
|
|
267
|
+
if value is None and label is None:
|
|
268
|
+
raise ValueError(
|
|
269
|
+
f"actions[{i}].value or label required for select_option")
|
|
270
|
+
if value is not None:
|
|
271
|
+
page.select_option(selector, value=value, timeout=10_000)
|
|
272
|
+
else:
|
|
273
|
+
page.select_option(selector, label=label, timeout=10_000)
|
|
274
|
+
else:
|
|
275
|
+
raise ValueError(f"actions[{i}]: unsupported do={do!r}")
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _read_observed_state(page: Any) -> str:
|
|
279
|
+
try:
|
|
280
|
+
value = page.evaluate(
|
|
281
|
+
"""() => {
|
|
282
|
+
const body = document.body;
|
|
283
|
+
if (body && body.dataset && body.dataset.state) {
|
|
284
|
+
return body.dataset.state;
|
|
285
|
+
}
|
|
286
|
+
const root = document.documentElement;
|
|
287
|
+
if (root && root.dataset && root.dataset.state) {
|
|
288
|
+
return root.dataset.state;
|
|
289
|
+
}
|
|
290
|
+
const el = document.querySelector("[data-state]");
|
|
291
|
+
if (el && el.getAttribute("data-state")) {
|
|
292
|
+
return el.getAttribute("data-state");
|
|
293
|
+
}
|
|
294
|
+
return null;
|
|
295
|
+
}"""
|
|
296
|
+
)
|
|
297
|
+
if isinstance(value, str) and value.strip():
|
|
298
|
+
return value.strip()
|
|
299
|
+
except Exception as exc: # noqa: BLE001 ? report an honest unknown
|
|
300
|
+
_log(f"observed_state probe failed: {exc}")
|
|
301
|
+
return "unknown"
|
|
302
|
+
|
|
303
|
+
def _write_screenshot(page: Any, path: Path) -> None:
|
|
304
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
305
|
+
page.screenshot(path=str(path), full_page=True)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _write_a11y_tree(page: Any, path: Path) -> None:
|
|
309
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
310
|
+
# Playwright removed page.accessibility; aria_snapshot is the v1 tree.
|
|
311
|
+
if hasattr(page, "aria_snapshot"):
|
|
312
|
+
tree = page.aria_snapshot()
|
|
313
|
+
payload: Any = {"format": "aria_snapshot", "tree": tree}
|
|
314
|
+
elif hasattr(page, "accessibility"):
|
|
315
|
+
payload = page.accessibility.snapshot()
|
|
316
|
+
else:
|
|
317
|
+
raise RuntimeError("page has no aria_snapshot/accessibility API")
|
|
318
|
+
path.write_text(
|
|
319
|
+
json.dumps(payload, ensure_ascii=False, indent=2),
|
|
320
|
+
encoding="utf-8",
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _write_interaction_trace(
|
|
325
|
+
context: Any, page: Any, path: Path, actions: list[dict[str, Any]]
|
|
326
|
+
) -> None:
|
|
327
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
328
|
+
# Restart tracing for this capture only.
|
|
329
|
+
try:
|
|
330
|
+
context.tracing.stop()
|
|
331
|
+
except Exception: # noqa: BLE001 — may not have started
|
|
332
|
+
pass
|
|
333
|
+
context.tracing.start(screenshots=True, snapshots=True, sources=False)
|
|
334
|
+
try:
|
|
335
|
+
_run_actions(page, actions)
|
|
336
|
+
context.tracing.stop(path=str(path))
|
|
337
|
+
except Exception:
|
|
338
|
+
try:
|
|
339
|
+
context.tracing.stop()
|
|
340
|
+
except Exception: # noqa: BLE001
|
|
341
|
+
pass
|
|
342
|
+
raise
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def execute_capture_plan(args: dict[str, Any]) -> dict[str, Any]:
|
|
346
|
+
unknown = sorted(set(args) - ALLOWED_ARGUMENTS)
|
|
347
|
+
if unknown:
|
|
348
|
+
names = ", ".join(unknown)
|
|
349
|
+
raise ValueError(
|
|
350
|
+
f"unsupported argument(s): {names}; provider accepts Runtime Object fields only"
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
url = args.get("url")
|
|
354
|
+
cap_type = args.get("type")
|
|
355
|
+
state = args.get("state")
|
|
356
|
+
actions = args.get("actions")
|
|
357
|
+
artifact_path = args.get("artifact_path")
|
|
358
|
+
overwrite = args.get("overwrite", False)
|
|
359
|
+
|
|
360
|
+
if not isinstance(url, str) or not url.strip():
|
|
361
|
+
raise ValueError("url is required")
|
|
362
|
+
if not isinstance(cap_type, str) or cap_type not in CAPTURE_TYPES:
|
|
363
|
+
raise ValueError(
|
|
364
|
+
f'type must be one of {sorted(CAPTURE_TYPES)}; got {cap_type!r}'
|
|
365
|
+
)
|
|
366
|
+
if not isinstance(state, str) or not state.strip():
|
|
367
|
+
raise ValueError("state is required")
|
|
368
|
+
if not isinstance(artifact_path, str) or not artifact_path.strip():
|
|
369
|
+
raise ValueError("artifact_path is required")
|
|
370
|
+
if not isinstance(overwrite, bool):
|
|
371
|
+
raise ValueError("overwrite must be a boolean")
|
|
372
|
+
if actions is None:
|
|
373
|
+
actions = []
|
|
374
|
+
if not isinstance(actions, list):
|
|
375
|
+
raise ValueError("actions must be an array")
|
|
376
|
+
for i, a in enumerate(actions):
|
|
377
|
+
if not isinstance(a, dict):
|
|
378
|
+
raise ValueError(f"actions[{i}] must be an object")
|
|
379
|
+
|
|
380
|
+
rel = artifact_path.strip()
|
|
381
|
+
try:
|
|
382
|
+
out_path = _resolve_artifact_path(rel)
|
|
383
|
+
except ValueError as exc:
|
|
384
|
+
return _failed(rel, str(exc))
|
|
385
|
+
abs_written = str(out_path)
|
|
386
|
+
# Refuse every case variant of the manifest execution-record SSOT.
|
|
387
|
+
if out_path.name.casefold() == "manifest.jsonl":
|
|
388
|
+
return _failed(rel, "provider never writes manifest.jsonl", abs_written)
|
|
389
|
+
# G6 write boundary: refuse to overwrite an existing artifact unless the
|
|
390
|
+
# caller explicitly opts in via overwrite=true. Checked before any
|
|
391
|
+
# Playwright launch so a misconfigured re-run cannot clobber prior evidence.
|
|
392
|
+
if out_path.exists() and not overwrite:
|
|
393
|
+
return _failed(
|
|
394
|
+
rel,
|
|
395
|
+
f"artifact already exists: {out_path} "
|
|
396
|
+
"(pass overwrite=true to replace)",
|
|
397
|
+
abs_written,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
try:
|
|
401
|
+
from playwright.sync_api import sync_playwright
|
|
402
|
+
except ImportError as exc:
|
|
403
|
+
return _failed(
|
|
404
|
+
rel,
|
|
405
|
+
f"playwright not installed: {exc}",
|
|
406
|
+
abs_written,
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
try:
|
|
410
|
+
with sync_playwright() as p:
|
|
411
|
+
browser = p.chromium.launch(headless=True)
|
|
412
|
+
try:
|
|
413
|
+
context = browser.new_context(viewport={"width": 1280, "height": 800})
|
|
414
|
+
page = context.new_page()
|
|
415
|
+
page.goto(url.strip(), wait_until="domcontentloaded", timeout=30_000)
|
|
416
|
+
|
|
417
|
+
if cap_type == "interaction trace":
|
|
418
|
+
_write_interaction_trace(context, page, out_path, actions)
|
|
419
|
+
else:
|
|
420
|
+
_run_actions(page, actions)
|
|
421
|
+
if cap_type == "screenshot":
|
|
422
|
+
_write_screenshot(page, out_path)
|
|
423
|
+
elif cap_type == "a11y tree":
|
|
424
|
+
_write_a11y_tree(page, out_path)
|
|
425
|
+
|
|
426
|
+
observed = _read_observed_state(page)
|
|
427
|
+
finally:
|
|
428
|
+
browser.close()
|
|
429
|
+
except Exception as exc: # noqa: BLE001 — surface as capture failure
|
|
430
|
+
_log(f"capture failed: {exc}")
|
|
431
|
+
return _failed(rel, str(exc), abs_written)
|
|
432
|
+
|
|
433
|
+
if not out_path.is_file():
|
|
434
|
+
return _failed(
|
|
435
|
+
rel,
|
|
436
|
+
f"artifact not written: {out_path}",
|
|
437
|
+
abs_written,
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
return _captured(rel, observed, abs_written)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
if __name__ == "__main__":
|
|
444
|
+
serve_stdio(
|
|
445
|
+
SERVER_NAME,
|
|
446
|
+
SERVER_VERSION,
|
|
447
|
+
_tool_schema(),
|
|
448
|
+
execute_capture_plan,
|
|
449
|
+
recover_from_malformed=True,
|
|
450
|
+
)
|