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.
Files changed (47) hide show
  1. package/LICENSE +28 -0
  2. package/NOTICE +37 -0
  3. package/README.md +143 -0
  4. package/commands/design-io.md +8 -0
  5. package/commands/ui-review.md +8 -0
  6. package/commands/ux-spec.md +8 -0
  7. package/mcp/__init__.py +0 -0
  8. package/mcp/_transport.py +242 -0
  9. package/mcp/evidence/README.md +40 -0
  10. package/mcp/evidence/__init__.py +0 -0
  11. package/mcp/evidence/server.py +450 -0
  12. package/mcp/evidence/test_server_stdio.py +645 -0
  13. package/mcp/preview/__init__.py +0 -0
  14. package/mcp/preview/browser.py +661 -0
  15. package/mcp/preview/confirm.py +255 -0
  16. package/mcp/preview/control.py +1293 -0
  17. package/mcp/preview/i18n.py +162 -0
  18. package/mcp/preview/server.py +126 -0
  19. package/mcp/preview/test_browser_control.py +663 -0
  20. package/mcp/preview/test_server_stdio.py +630 -0
  21. package/mcp/preview/test_transaction.py +436 -0
  22. package/mcp/preview/transaction.py +536 -0
  23. package/mcp/preview/util.py +19 -0
  24. package/mcp/test_transport.py +39 -0
  25. package/package.json +42 -0
  26. package/skills/craft-guard/SKILL.md +59 -0
  27. package/skills/craft-guard/references/craft.md +29 -0
  28. package/skills/craft-guard/references/detectors.md +124 -0
  29. package/skills/design-baseline/SKILL.md +134 -0
  30. package/skills/design-baseline/agents/openai.yaml +4 -0
  31. package/skills/design-baseline/references/design-template.md +73 -0
  32. package/skills/design-baseline/references/extraction-guidance.md +39 -0
  33. package/skills/design-baseline/scripts/design_baseline.py +780 -0
  34. package/skills/design-playbook/SKILL.md +219 -0
  35. package/skills/native-craft/SKILL.md +59 -0
  36. package/skills/native-craft/references/native-feel.md +79 -0
  37. package/skills/reference-intake/SKILL.md +86 -0
  38. package/skills/reference-intake/references/contract-template.md +82 -0
  39. package/skills/ui-evaluator/SKILL.md +110 -0
  40. package/skills/ui-evaluator/references/rubric.md +45 -0
  41. package/skills/ui-picker/SKILL.md +63 -0
  42. package/skills/ui-picker/references/components.md +31 -0
  43. package/skills/ui-picker/references/design.md +21 -0
  44. package/skills/ui-picker/references/domain.md +26 -0
  45. package/skills/ui-picker/references/template.md +24 -0
  46. package/skills/ux-spec/SKILL.md +51 -0
  47. package/skills/ux-spec/references/spec-template.md +43 -0
@@ -0,0 +1,645 @@
1
+ #!/usr/bin/env python3
2
+ """Process-boundary tests for the evidence MCP stdio transport.
3
+
4
+ Tests are split into two classes so CI selects the group by class name:
5
+
6
+ * ``EvidencePurePathTests`` — contract/path validation that never reaches
7
+ the Playwright import in ``execute_capture_plan``. Runs with or without
8
+ chromium (CI gate: ``pytest -k PurePath``).
9
+ * ``EvidenceCaptureTests`` — real-browser captures (screenshot / a11y /
10
+ trace). Requires Playwright + chromium (CI gate: ``pytest -k Capture``).
11
+
12
+ Class-name grouping is unittest-native (no ``conftest.py`` / marker
13
+ registration needed) and any new test added to a class is picked up by
14
+ its ``-k`` filter automatically. This replaces the previous
15
+ hand-maintained ``-k`` keyword OR-list, which silently skipped new tests
16
+ whose names did not match any keyword. See review item M3.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import subprocess
22
+ import sys
23
+ import tempfile
24
+ import unittest
25
+ import zipfile
26
+ from pathlib import Path
27
+
28
+ SERVER = Path(__file__).resolve().with_name("server.py")
29
+ FIXTURE_HTML = """<!DOCTYPE html>
30
+ <html>
31
+ <head><meta charset="utf-8"><title>evidence-fixture</title></head>
32
+ <body data-state="error">
33
+ <h1 id="title">Export jobs</h1>
34
+ <button id="submit" type="button">Retry</button>
35
+ <p id="msg" class="error" hidden>failed</p>
36
+ <script>
37
+ document.getElementById("submit").addEventListener("click", () => {
38
+ document.getElementById("msg").hidden = false;
39
+ document.body.dataset.state = "error";
40
+ });
41
+ </script>
42
+ </body>
43
+ </html>
44
+ """
45
+
46
+
47
+ def _run_stdio(
48
+ requests: list[dict],
49
+ timeout: float = 30,
50
+ *,
51
+ cwd: Path | None = None,
52
+ no_site: bool = False,
53
+ ) -> subprocess.CompletedProcess[str]:
54
+ wire_input = "".join(
55
+ json.dumps(request, ensure_ascii=False) + "\n" for request in requests
56
+ )
57
+ command = [sys.executable]
58
+ if no_site:
59
+ command.append("-S")
60
+ command.append(str(SERVER))
61
+ return subprocess.run(
62
+ command,
63
+ input=wire_input,
64
+ text=True,
65
+ encoding="utf-8",
66
+ capture_output=True,
67
+ timeout=timeout,
68
+ check=False,
69
+ cwd=cwd,
70
+ )
71
+
72
+
73
+ def _run_wire(wire_input: bytes, timeout: float = 5) -> subprocess.CompletedProcess[bytes]:
74
+ return subprocess.run(
75
+ [sys.executable, str(SERVER)],
76
+ input=wire_input,
77
+ capture_output=True,
78
+ timeout=timeout,
79
+ check=False,
80
+ )
81
+
82
+
83
+ def _responses(completed: subprocess.CompletedProcess[str]) -> list[dict]:
84
+ return [
85
+ json.loads(line)
86
+ for line in completed.stdout.splitlines()
87
+ if line.strip()
88
+ ]
89
+
90
+
91
+ def _structured(call_response: dict) -> dict:
92
+ result = call_response["result"]
93
+ if result.get("structuredContent") is not None:
94
+ return result["structuredContent"]
95
+ text = result["content"][0]["text"]
96
+ return json.loads(text)
97
+
98
+
99
+ class EvidencePurePathTests(unittest.TestCase):
100
+ """No-chromium contract/path validation.
101
+
102
+ These never reach the Playwright import in execute_capture_plan
103
+ (server.py): unknown-arg / manifest.jsonl / G6 evidence-subtree
104
+ containment / overwrite opt-in / JSON-RPC parse-error all
105
+ short-circuit first, so they pass with or without chromium.
106
+ """
107
+
108
+ def test_tools_list_exposes_execute_capture_plan(self) -> None:
109
+ requests = [
110
+ {
111
+ "jsonrpc": "2.0",
112
+ "id": 1,
113
+ "method": "initialize",
114
+ "params": {"protocolVersion": "2025-06-18"},
115
+ },
116
+ {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}},
117
+ ]
118
+ completed = _run_stdio(requests, timeout=5)
119
+ self.assertEqual(completed.returncode, 0, completed.stderr)
120
+ responses = _responses(completed)
121
+ self.assertEqual([r["id"] for r in responses], [1, 2])
122
+ self.assertEqual(
123
+ responses[0]["result"]["serverInfo"]["name"],
124
+ "design-playbook-evidence",
125
+ )
126
+ self.assertEqual(
127
+ [tool["name"] for tool in responses[1]["result"]["tools"]],
128
+ ["execute_capture_plan"],
129
+ )
130
+
131
+ def test_provider_rejects_criterion_field(self) -> None:
132
+ """Provider does not accept criterion (ticket 02 / map premise 9)."""
133
+ requests = [
134
+ {
135
+ "jsonrpc": "2.0",
136
+ "id": 1,
137
+ "method": "initialize",
138
+ "params": {"protocolVersion": "2025-06-18"},
139
+ },
140
+ {
141
+ "jsonrpc": "2.0",
142
+ "id": 2,
143
+ "method": "tools/call",
144
+ "params": {
145
+ "name": "execute_capture_plan",
146
+ "arguments": {
147
+ "url": "about:blank",
148
+ "type": "screenshot",
149
+ "state": "ok",
150
+ "actions": [],
151
+ "artifact_path": "evidence/x.png",
152
+ "criterion": "L6.3",
153
+ },
154
+ },
155
+ },
156
+ ]
157
+ completed = _run_stdio(requests, timeout=15)
158
+ self.assertEqual(completed.returncode, 0, completed.stderr)
159
+ responses = _responses(completed)
160
+ result = responses[1]["result"]
161
+ # Either isError or structured result=failed — never silently accept criterion.
162
+ if result.get("isError"):
163
+ text = result["content"][0]["text"].lower()
164
+ self.assertIn("criterion", text)
165
+ else:
166
+ payload = _structured(responses[1])
167
+ self.assertEqual(payload["result"], "failed")
168
+ self.assertIn("criterion", payload["error"].lower())
169
+
170
+ def test_provider_rejects_manifest_variants_and_path_escape(self) -> None:
171
+ with tempfile.TemporaryDirectory() as tmp:
172
+ root = Path(tmp)
173
+ outside = root.parent / f"{root.name}-outside.png"
174
+ cases = [
175
+ "evidence/manifest.jsonl",
176
+ "evidence/Manifest.JSONL",
177
+ "../" + outside.name,
178
+ str(outside.resolve()),
179
+ ]
180
+ for artifact_path in cases:
181
+ with self.subTest(artifact_path=artifact_path):
182
+ requests = [
183
+ {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}},
184
+ {
185
+ "jsonrpc": "2.0",
186
+ "id": 2,
187
+ "method": "tools/call",
188
+ "params": {
189
+ "name": "execute_capture_plan",
190
+ "arguments": {
191
+ "url": "about:blank",
192
+ "type": "screenshot",
193
+ "state": "ok",
194
+ "actions": [],
195
+ "artifact_path": artifact_path,
196
+ },
197
+ },
198
+ },
199
+ ]
200
+ completed = _run_stdio(requests, timeout=15, cwd=root)
201
+ self.assertEqual(completed.returncode, 0, completed.stderr)
202
+ payload = _structured(_responses(completed)[1])
203
+ self.assertEqual(payload["result"], "failed")
204
+ self.assertEqual(payload["observed_state"], "unknown")
205
+ self.assertFalse(outside.exists())
206
+
207
+ def test_provider_rejects_non_evidence_subtree_paths(self) -> None:
208
+ """G6 containment: artifact_path must already live under evidence/."""
209
+ with tempfile.TemporaryDirectory() as tmp:
210
+ root = Path(tmp)
211
+ (root / "evidence").mkdir()
212
+ cases = [
213
+ "spec.md", # would land at run root, not evidence/
214
+ "../spec.md", # explicit .. segment
215
+ "evidence/../spec.md", # .. segment that resolves out of evidence/
216
+ "skills/x", # sibling subtree, not evidence/
217
+ ]
218
+ for artifact_path in cases:
219
+ with self.subTest(artifact_path=artifact_path):
220
+ requests = [
221
+ {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}},
222
+ {
223
+ "jsonrpc": "2.0",
224
+ "id": 2,
225
+ "method": "tools/call",
226
+ "params": {
227
+ "name": "execute_capture_plan",
228
+ "arguments": {
229
+ "url": "about:blank",
230
+ "type": "screenshot",
231
+ "state": "ok",
232
+ "actions": [],
233
+ "artifact_path": artifact_path,
234
+ },
235
+ },
236
+ },
237
+ ]
238
+ completed = _run_stdio(requests, timeout=15, cwd=root)
239
+ self.assertEqual(completed.returncode, 0, completed.stderr)
240
+ payload = _structured(_responses(completed)[1])
241
+ self.assertEqual(payload["result"], "failed", payload)
242
+ self.assertEqual(payload["observed_state"], "unknown")
243
+ # None of these wrote anywhere outside evidence/.
244
+ self.assertFalse((root / "spec.md").exists())
245
+ self.assertFalse((root / "skills").exists())
246
+
247
+ def test_provider_refuses_overwrite_without_opt_in(self) -> None:
248
+ """G6 write boundary: existing artifact is not overwritten by default."""
249
+ with tempfile.TemporaryDirectory() as tmp:
250
+ root = Path(tmp)
251
+ (root / "evidence").mkdir()
252
+ html = root / "page.html"
253
+ html.write_text(FIXTURE_HTML, encoding="utf-8")
254
+ artifact_rel = "evidence/L6.3-error.png"
255
+ sentinel = b"pre-existing-by-hand"
256
+ (root / artifact_rel).write_bytes(sentinel)
257
+
258
+ requests = [
259
+ {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}},
260
+ {
261
+ "jsonrpc": "2.0",
262
+ "id": 2,
263
+ "method": "tools/call",
264
+ "params": {
265
+ "name": "execute_capture_plan",
266
+ "arguments": {
267
+ "url": html.resolve().as_uri(),
268
+ "type": "screenshot",
269
+ "state": "error",
270
+ "actions": [],
271
+ "artifact_path": artifact_rel,
272
+ },
273
+ },
274
+ },
275
+ ]
276
+ completed = _run_stdio(requests, timeout=45, cwd=root)
277
+ self.assertEqual(completed.returncode, 0, completed.stderr)
278
+ payload = _structured(_responses(completed)[1])
279
+ self.assertEqual(payload["result"], "failed", payload)
280
+ self.assertEqual(payload["observed_state"], "unknown")
281
+ # Pre-existing bytes preserved (write boundary held).
282
+ self.assertEqual((root / artifact_rel).read_bytes(), sentinel)
283
+
284
+ def test_provider_rejects_unknown_and_criterion_ref_fields(self) -> None:
285
+ for forbidden in ("criterion", "criterion_ref", "criterion_id", "unexpected"):
286
+ with self.subTest(forbidden=forbidden):
287
+ requests = [
288
+ {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}},
289
+ {
290
+ "jsonrpc": "2.0",
291
+ "id": 2,
292
+ "method": "tools/call",
293
+ "params": {
294
+ "name": "execute_capture_plan",
295
+ "arguments": {
296
+ "url": "about:blank",
297
+ "type": "screenshot",
298
+ "state": "ok",
299
+ "actions": [],
300
+ "artifact_path": "evidence/x.png",
301
+ forbidden: "L6.3",
302
+ },
303
+ },
304
+ },
305
+ ]
306
+ completed = _run_stdio(requests, timeout=15)
307
+ self.assertEqual(completed.returncode, 0, completed.stderr)
308
+ result = _responses(completed)[1]["result"]
309
+ self.assertTrue(result.get("isError"), result)
310
+ self.assertIn(forbidden, result["content"][0]["text"])
311
+
312
+ def test_malformed_messages_return_parse_error_and_server_continues(self) -> None:
313
+ ping = json.dumps({"jsonrpc": "2.0", "id": 2, "method": "ping"}).encode()
314
+ newline = _run_wire(b"{not-json}\n" + ping + b"\n")
315
+ self.assertEqual(newline.returncode, 0, newline.stderr.decode(errors="replace"))
316
+ newline_responses = [json.loads(line) for line in newline.stdout.splitlines()]
317
+ self.assertEqual(newline_responses[0]["error"]["code"], -32700)
318
+ self.assertEqual(newline_responses[1]["id"], 2)
319
+
320
+ bad = b"{bad-json}"
321
+ framed = (
322
+ f"Content-Length: {len(bad)}\r\n\r\n".encode()
323
+ + bad
324
+ + f"Content-Length: {len(ping)}\r\n\r\n".encode()
325
+ + ping
326
+ )
327
+ content_length = _run_wire(framed)
328
+ self.assertEqual(
329
+ content_length.returncode,
330
+ 0,
331
+ content_length.stderr.decode(errors="replace"),
332
+ )
333
+ raw = content_length.stdout
334
+ bodies = []
335
+ while raw:
336
+ header, raw = raw.split(b"\r\n\r\n", 1)
337
+ length = int(header.split(b":", 1)[1].strip())
338
+ bodies.append(json.loads(raw[:length]))
339
+ raw = raw[length:]
340
+ self.assertEqual(bodies[0]["error"]["code"], -32700)
341
+ self.assertEqual(bodies[1]["id"], 2)
342
+
343
+
344
+ class EvidenceCaptureTests(unittest.TestCase):
345
+ """Real-browser captures; require Playwright + chromium."""
346
+
347
+ def test_screenshot_capture_writes_artifact_never_manifest(self) -> None:
348
+ with tempfile.TemporaryDirectory() as tmp:
349
+ root = Path(tmp)
350
+ html = root / "page.html"
351
+ html.write_text(FIXTURE_HTML, encoding="utf-8")
352
+ evidence = root / "evidence"
353
+ evidence.mkdir()
354
+ artifact_rel = "evidence/L6.3-error.png"
355
+ artifact_abs = root / artifact_rel
356
+ url = html.resolve().as_uri()
357
+
358
+ requests = [
359
+ {
360
+ "jsonrpc": "2.0",
361
+ "id": 1,
362
+ "method": "initialize",
363
+ "params": {"protocolVersion": "2025-06-18"},
364
+ },
365
+ {
366
+ "jsonrpc": "2.0",
367
+ "id": 2,
368
+ "method": "tools/call",
369
+ "params": {
370
+ "name": "execute_capture_plan",
371
+ "arguments": {
372
+ "url": url,
373
+ "type": "screenshot",
374
+ "state": "error",
375
+ "actions": [
376
+ {"do": "click", "selector": "#submit"},
377
+ ],
378
+ "artifact_path": artifact_rel,
379
+ },
380
+ },
381
+ },
382
+ ]
383
+ completed = _run_stdio(requests, timeout=45, cwd=root)
384
+ self.assertEqual(completed.returncode, 0, completed.stderr)
385
+ responses = _responses(completed)
386
+ self.assertEqual([r["id"] for r in responses], [1, 2])
387
+ self.assertFalse(responses[1]["result"].get("isError", False), responses[1])
388
+ payload = _structured(responses[1])
389
+ self.assertEqual(payload["result"], "captured")
390
+ self.assertEqual(payload["observed_state"], "error")
391
+ self.assertEqual(payload["error"], "")
392
+ self.assertTrue(artifact_abs.is_file(), f"missing {artifact_abs}")
393
+ self.assertGreater(artifact_abs.stat().st_size, 100)
394
+ # Provider must never write manifest.
395
+ self.assertFalse((evidence / "manifest.jsonl").exists())
396
+ self.assertEqual(payload["artifact"], artifact_rel)
397
+ # P0-1: absolute written_path so RUN_ROOT/cwd misconfig is visible
398
+ self.assertIn("written_path", payload)
399
+ self.assertEqual(
400
+ Path(payload["written_path"]).resolve(),
401
+ artifact_abs.resolve(),
402
+ )
403
+
404
+ def test_capture_without_explicit_page_state_reports_unknown(self) -> None:
405
+ """Requested state is intent, not an observed fact."""
406
+ with tempfile.TemporaryDirectory() as tmp:
407
+ root = Path(tmp)
408
+ html = root / "page.html"
409
+ html.write_text(
410
+ "<html><body><h1>No state marker</h1></body></html>",
411
+ encoding="utf-8",
412
+ )
413
+ artifact_rel = "evidence/L6.1-ok.png"
414
+ requests = [
415
+ {
416
+ "jsonrpc": "2.0",
417
+ "id": 1,
418
+ "method": "initialize",
419
+ "params": {"protocolVersion": "2025-06-18"},
420
+ },
421
+ {
422
+ "jsonrpc": "2.0",
423
+ "id": 2,
424
+ "method": "tools/call",
425
+ "params": {
426
+ "name": "execute_capture_plan",
427
+ "arguments": {
428
+ "url": html.resolve().as_uri(),
429
+ "type": "screenshot",
430
+ "state": "ok",
431
+ "actions": [],
432
+ "artifact_path": artifact_rel,
433
+ },
434
+ },
435
+ },
436
+ ]
437
+ completed = _run_stdio(requests, timeout=45, cwd=root)
438
+ self.assertEqual(completed.returncode, 0, completed.stderr)
439
+ payload = _structured(_responses(completed)[1])
440
+ self.assertEqual(payload["result"], "captured")
441
+ self.assertEqual(payload["observed_state"], "unknown")
442
+
443
+ def test_a11y_tree_and_interaction_trace_write_artifacts(self) -> None:
444
+ with tempfile.TemporaryDirectory() as tmp:
445
+ root = Path(tmp)
446
+ html = root / "page.html"
447
+ html.write_text(FIXTURE_HTML, encoding="utf-8")
448
+ a11y = root / "evidence" / "L6.4-a11y.json"
449
+ trace = root / "evidence" / "L6.5-trace.zip"
450
+ calls = [
451
+ ("a11y tree", [], a11y),
452
+ (
453
+ "interaction trace",
454
+ [{"do": "click", "selector": "#submit"}],
455
+ trace,
456
+ ),
457
+ ]
458
+ for request_id, (capture_type, actions, artifact) in enumerate(
459
+ calls, start=2
460
+ ):
461
+ with self.subTest(capture_type=capture_type):
462
+ requests = [
463
+ {
464
+ "jsonrpc": "2.0",
465
+ "id": 1,
466
+ "method": "initialize",
467
+ "params": {"protocolVersion": "2025-06-18"},
468
+ },
469
+ {
470
+ "jsonrpc": "2.0",
471
+ "id": request_id,
472
+ "method": "tools/call",
473
+ "params": {
474
+ "name": "execute_capture_plan",
475
+ "arguments": {
476
+ "url": html.resolve().as_uri(),
477
+ "type": capture_type,
478
+ "state": "error",
479
+ "actions": actions,
480
+ "artifact_path": artifact.relative_to(root).as_posix(),
481
+ },
482
+ },
483
+ },
484
+ ]
485
+ completed = _run_stdio(requests, timeout=45, cwd=root)
486
+ self.assertEqual(completed.returncode, 0, completed.stderr)
487
+ payload = _structured(_responses(completed)[1])
488
+ self.assertEqual(payload["result"], "captured", payload)
489
+ self.assertTrue(artifact.is_file(), artifact)
490
+ if capture_type == "a11y tree":
491
+ parsed = json.loads(artifact.read_text(encoding="utf-8"))
492
+ self.assertIn("Export jobs", json.dumps(parsed, ensure_ascii=False))
493
+ self.assertIn("Retry", json.dumps(parsed, ensure_ascii=False))
494
+ else:
495
+ with zipfile.ZipFile(artifact) as archive:
496
+ names = archive.namelist()
497
+ self.assertTrue(any(name.endswith("trace.trace") for name in names))
498
+
499
+ def test_select_option_action_drives_native_select(self) -> None:
500
+ """select_option drives a native <select> by value or label (issue 02)."""
501
+ select_html = """<!DOCTYPE html>
502
+ <html>
503
+ <head><meta charset="utf-8"><title>select-fixture</title></head>
504
+ <body data-state="idle">
505
+ <label for="pick">行业</label>
506
+ <select id="pick">
507
+ <option value="">请选择</option>
508
+ <option value="a">软件 / 互联网</option>
509
+ <option value="b">制造业</option>
510
+ </select>
511
+ <script>
512
+ document.getElementById("pick").addEventListener("change", (e) => {
513
+ document.body.dataset.state = e.target.value || "idle";
514
+ });
515
+ </script>
516
+ </body>
517
+ </html>
518
+ """
519
+ cases = [
520
+ ("by value", {"do": "select_option", "selector": "#pick", "value": "b"}, "b"),
521
+ ("by label", {"do": "select_option", "selector": "#pick", "label": "软件 / 互联网"}, "a"),
522
+ ]
523
+ with tempfile.TemporaryDirectory() as tmp:
524
+ root = Path(tmp)
525
+ html = root / "page.html"
526
+ html.write_text(select_html, encoding="utf-8")
527
+ (root / "evidence").mkdir()
528
+ for label, action, expected_state in cases:
529
+ with self.subTest(label=label):
530
+ artifact_rel = f"evidence/select-{label.replace(' ', '')}.png"
531
+ requests = [
532
+ {
533
+ "jsonrpc": "2.0",
534
+ "id": 1,
535
+ "method": "initialize",
536
+ "params": {"protocolVersion": "2025-06-18"},
537
+ },
538
+ {
539
+ "jsonrpc": "2.0",
540
+ "id": 2,
541
+ "method": "tools/call",
542
+ "params": {
543
+ "name": "execute_capture_plan",
544
+ "arguments": {
545
+ "url": html.resolve().as_uri(),
546
+ "type": "screenshot",
547
+ "state": expected_state,
548
+ "actions": [action],
549
+ "artifact_path": artifact_rel,
550
+ },
551
+ },
552
+ },
553
+ ]
554
+ completed = _run_stdio(requests, timeout=45, cwd=root)
555
+ self.assertEqual(completed.returncode, 0, completed.stderr)
556
+ payload = _structured(_responses(completed)[1])
557
+ self.assertEqual(payload["result"], "captured", payload)
558
+ # select_option fired change -> body[data-state] reflects it
559
+ self.assertEqual(payload["observed_state"], expected_state, payload)
560
+ self.assertTrue((root / artifact_rel).is_file())
561
+
562
+ def test_failure_paths_never_echo_requested_state_as_observed(self) -> None:
563
+ base_arguments = {
564
+ "url": "http://127.0.0.1:1/unreachable",
565
+ "type": "screenshot",
566
+ "state": "requested-error",
567
+ "actions": [],
568
+ "artifact_path": "evidence/failure.png",
569
+ }
570
+ cases = [
571
+ ("navigation failure", base_arguments, False),
572
+ (
573
+ "action failure",
574
+ {
575
+ **base_arguments,
576
+ "url": "about:blank",
577
+ "actions": [{"do": "click", "selector": "#missing"}],
578
+ },
579
+ False,
580
+ ),
581
+ ("playwright unavailable", {**base_arguments, "url": "about:blank"}, True),
582
+ ]
583
+ for label, arguments, no_site in cases:
584
+ with self.subTest(label=label), tempfile.TemporaryDirectory() as tmp:
585
+ requests = [
586
+ {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}},
587
+ {
588
+ "jsonrpc": "2.0",
589
+ "id": 2,
590
+ "method": "tools/call",
591
+ "params": {"name": "execute_capture_plan", "arguments": arguments},
592
+ },
593
+ ]
594
+ completed = _run_stdio(
595
+ requests,
596
+ timeout=45,
597
+ cwd=Path(tmp),
598
+ no_site=no_site,
599
+ )
600
+ self.assertEqual(completed.returncode, 0, completed.stderr)
601
+ payload = _structured(_responses(completed)[1])
602
+ self.assertEqual(payload["result"], "failed")
603
+ self.assertEqual(payload["observed_state"], "unknown")
604
+
605
+ def test_provider_overwrites_when_opted_in(self) -> None:
606
+ """G6 write boundary: overwrite=true explicitly opts in to replace."""
607
+ with tempfile.TemporaryDirectory() as tmp:
608
+ root = Path(tmp)
609
+ (root / "evidence").mkdir()
610
+ html = root / "page.html"
611
+ html.write_text(FIXTURE_HTML, encoding="utf-8")
612
+ artifact_rel = "evidence/L6.3-error.png"
613
+ artifact = root / artifact_rel
614
+ artifact.write_bytes(b"pre-existing-by-hand")
615
+
616
+ requests = [
617
+ {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}},
618
+ {
619
+ "jsonrpc": "2.0",
620
+ "id": 2,
621
+ "method": "tools/call",
622
+ "params": {
623
+ "name": "execute_capture_plan",
624
+ "arguments": {
625
+ "url": html.resolve().as_uri(),
626
+ "type": "screenshot",
627
+ "state": "error",
628
+ "actions": [],
629
+ "artifact_path": artifact_rel,
630
+ "overwrite": True,
631
+ },
632
+ },
633
+ },
634
+ ]
635
+ completed = _run_stdio(requests, timeout=45, cwd=root)
636
+ self.assertEqual(completed.returncode, 0, completed.stderr)
637
+ payload = _structured(_responses(completed)[1])
638
+ self.assertEqual(payload["result"], "captured", payload)
639
+ # Sentinel replaced with a real screenshot (>100 bytes).
640
+ self.assertGreater(artifact.stat().st_size, 100)
641
+ self.assertNotEqual(artifact.read_bytes(), b"pre-existing-by-hand")
642
+
643
+
644
+ if __name__ == "__main__":
645
+ unittest.main()
File without changes