cc-visual-walkthrough 0.2.0__py3-none-any.whl
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.
- cc_visual_walkthrough/__init__.py +22 -0
- cc_visual_walkthrough/actions.py +241 -0
- cc_visual_walkthrough/browser.py +38 -0
- cc_visual_walkthrough/cli.py +388 -0
- cc_visual_walkthrough/config.py +234 -0
- cc_visual_walkthrough/doctor.py +183 -0
- cc_visual_walkthrough/helpers.py +208 -0
- cc_visual_walkthrough/reporters.py +359 -0
- cc_visual_walkthrough/runner.py +454 -0
- cc_visual_walkthrough/serve.py +434 -0
- cc_visual_walkthrough/specs.py +181 -0
- cc_visual_walkthrough-0.2.0.dist-info/METADATA +195 -0
- cc_visual_walkthrough-0.2.0.dist-info/RECORD +16 -0
- cc_visual_walkthrough-0.2.0.dist-info/WHEEL +4 -0
- cc_visual_walkthrough-0.2.0.dist-info/entry_points.txt +2 -0
- cc_visual_walkthrough-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""cc-visual-walkthrough: spec-driven Playwright walkthroughs for Claude Code.
|
|
2
|
+
|
|
3
|
+
Records and captures browser tours of a live web app into shareable HTML +
|
|
4
|
+
Markdown reports (screenshots, per-group video, non-fatal regression
|
|
5
|
+
assertions). The supported workflow is through the Claude Code plugin
|
|
6
|
+
(slug: ccwalk); the `ccwalk` CLI can be driven by hand, but that path is
|
|
7
|
+
unsupported -- you are on your own.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .specs import Assertion, AssertionResult, Step, StepResult, load_spec, validate_steps
|
|
11
|
+
|
|
12
|
+
__version__ = "0.2.0"
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"Assertion",
|
|
16
|
+
"AssertionResult",
|
|
17
|
+
"Step",
|
|
18
|
+
"StepResult",
|
|
19
|
+
"load_spec",
|
|
20
|
+
"validate_steps",
|
|
21
|
+
"__version__",
|
|
22
|
+
]
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Action registry: the verbs a spec's action dicts can use.
|
|
2
|
+
|
|
3
|
+
Every action takes the live ``Runner`` (``rt``), the current ``Step``, and
|
|
4
|
+
its own kwargs, and returns an info dict merged into the step's log
|
|
5
|
+
(``screenshot`` / ``progress_screenshots`` keys are collected into the
|
|
6
|
+
report). The registry is a plain dict, so app-specific actions can be
|
|
7
|
+
added from a custom module -- see ``load_custom_actions``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import importlib.util
|
|
13
|
+
import json
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Callable
|
|
17
|
+
|
|
18
|
+
from .specs import Step
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _url_for(rt, path: str) -> str:
|
|
22
|
+
if path.startswith("http://") or path.startswith("https://"):
|
|
23
|
+
return path
|
|
24
|
+
if not path.startswith("/"):
|
|
25
|
+
path = "/" + path
|
|
26
|
+
return rt.base_url + path
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def act_goto(rt, step: Step, url: str, apply_auth: bool = True, **_) -> dict:
|
|
30
|
+
"""Navigate. On the first goto of a fresh browser context this also
|
|
31
|
+
establishes auth per the config (form login / localStorage injection);
|
|
32
|
+
cookie and header auth are applied at context creation instead."""
|
|
33
|
+
if apply_auth:
|
|
34
|
+
rt.ensure_pre_goto_auth()
|
|
35
|
+
rt.page.goto(_url_for(rt, url), wait_until="domcontentloaded")
|
|
36
|
+
if apply_auth:
|
|
37
|
+
rt.ensure_post_goto_auth()
|
|
38
|
+
return {}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def act_reload(rt, step: Step, **_) -> dict:
|
|
42
|
+
rt.page.reload(wait_until="domcontentloaded")
|
|
43
|
+
return {}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def act_click(rt, step: Step, selector: str, timeout_ms: int = 10000, **_) -> dict:
|
|
47
|
+
rt.page.click(selector, timeout=timeout_ms)
|
|
48
|
+
return {}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def act_fill(rt, step: Step, selector: str, value: str, **_) -> dict:
|
|
52
|
+
rt.page.fill(selector, value)
|
|
53
|
+
return {}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def act_press(rt, step: Step, selector: str, key: str, **_) -> dict:
|
|
57
|
+
rt.page.press(selector, key)
|
|
58
|
+
return {}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def act_hover(rt, step: Step, selector: str, **_) -> dict:
|
|
62
|
+
rt.page.hover(selector)
|
|
63
|
+
return {}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def act_select_option(
|
|
67
|
+
rt, step: Step, selector: str, value: str | None = None, label: str | None = None, **_
|
|
68
|
+
) -> dict:
|
|
69
|
+
if label is not None:
|
|
70
|
+
rt.page.select_option(selector, label=label)
|
|
71
|
+
else:
|
|
72
|
+
rt.page.select_option(selector, value=value)
|
|
73
|
+
return {}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def act_upload_file(rt, step: Step, selector: str, path: str, **_) -> dict:
|
|
77
|
+
rt.page.set_input_files(selector, path)
|
|
78
|
+
return {}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def act_wait_for(
|
|
82
|
+
rt, step: Step, selector: str, state: str = "visible", timeout_ms: int = 15000, **_
|
|
83
|
+
) -> dict:
|
|
84
|
+
rt.page.wait_for_selector(selector, state=state, timeout=timeout_ms)
|
|
85
|
+
return {}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def act_wait_ms(rt, step: Step, ms: int, **_) -> dict:
|
|
89
|
+
rt.page.wait_for_timeout(ms)
|
|
90
|
+
return {}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def act_set_viewport(rt, step: Step, width: int, height: int, **_) -> dict:
|
|
94
|
+
rt.page.set_viewport_size({"width": width, "height": height})
|
|
95
|
+
return {}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def act_screenshot(rt, step: Step, suffix: str = "", **_) -> dict:
|
|
99
|
+
path = rt.screenshot(step, suffix=suffix)
|
|
100
|
+
return {"screenshot": path}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def act_scroll_into_view(rt, step: Step, selector: str, **_) -> dict:
|
|
104
|
+
rt.page.locator(selector).first.scroll_into_view_if_needed()
|
|
105
|
+
return {}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def act_login_form(rt, step: Step, **_) -> dict:
|
|
109
|
+
"""Perform the configured form login explicitly (useful as a group's
|
|
110
|
+
opening step when the login flow itself should appear in the tour)."""
|
|
111
|
+
rt.perform_form_login()
|
|
112
|
+
return {"logged_in": True}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def act_override_session(rt, step: Step, logical_name: str = "default", **_) -> dict:
|
|
116
|
+
sid = rt.override_session_id(logical_name)
|
|
117
|
+
return {"session_id": sid}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def act_wait_for_condition(
|
|
121
|
+
rt,
|
|
122
|
+
step: Step,
|
|
123
|
+
js: str,
|
|
124
|
+
timeout_ms: int = 90000,
|
|
125
|
+
capture_progress: bool = False,
|
|
126
|
+
poll_interval_ms: int = 2500,
|
|
127
|
+
progress_selector: str = "",
|
|
128
|
+
settle_ms: int = 400,
|
|
129
|
+
**_,
|
|
130
|
+
) -> dict:
|
|
131
|
+
"""Poll ``js`` (a no-arg JS function body evaluated via
|
|
132
|
+
``() => (expr)``; return truthy when the wait should END) until it
|
|
133
|
+
holds or ``timeout_ms`` elapses. While waiting, if
|
|
134
|
+
``progress_selector`` is visible and ``capture_progress`` is set, a
|
|
135
|
+
progress screenshot is taken every ``poll_interval_ms`` -- this is how
|
|
136
|
+
long async operations (spinners, progress labels, streaming
|
|
137
|
+
responses) get their wait-state captured in the report.
|
|
138
|
+
|
|
139
|
+
The internal poll granularity is finer (300ms) than
|
|
140
|
+
``poll_interval_ms`` (which only paces progress screenshots), so a
|
|
141
|
+
near-instant completion is still detected promptly -- a lesson
|
|
142
|
+
inherited from the predecessor system, where gating on a
|
|
143
|
+
spinner-visible->hidden transition spun for the full timeout on
|
|
144
|
+
fast replies (see DESIGN.md).
|
|
145
|
+
"""
|
|
146
|
+
shots: list[str] = []
|
|
147
|
+
deadline = time.monotonic() + (timeout_ms / 1000.0)
|
|
148
|
+
saw_progress = False
|
|
149
|
+
met = False
|
|
150
|
+
last_shot_at = 0.0
|
|
151
|
+
check_interval_ms = min(300, poll_interval_ms) if poll_interval_ms else 300
|
|
152
|
+
expr = f"() => ({js})"
|
|
153
|
+
while time.monotonic() < deadline:
|
|
154
|
+
if progress_selector and rt.page.is_visible(progress_selector):
|
|
155
|
+
saw_progress = True
|
|
156
|
+
now = time.monotonic()
|
|
157
|
+
if capture_progress and (now - last_shot_at) * 1000 >= poll_interval_ms:
|
|
158
|
+
shots.append(rt.screenshot(step, suffix=f"wait_{len(shots)}"))
|
|
159
|
+
last_shot_at = now
|
|
160
|
+
if rt.page.evaluate(expr):
|
|
161
|
+
met = True
|
|
162
|
+
break
|
|
163
|
+
rt.page.wait_for_timeout(check_interval_ms)
|
|
164
|
+
if not met and rt.page.evaluate(expr):
|
|
165
|
+
met = True # condition became true exactly at the deadline
|
|
166
|
+
if not met:
|
|
167
|
+
# A timed-out evidence wait must be VISIBLE: the runner turns this
|
|
168
|
+
# into a failed pseudo-assertion (step status warn), never a
|
|
169
|
+
# silent PASS.
|
|
170
|
+
return {
|
|
171
|
+
"progress_screenshots": shots,
|
|
172
|
+
"saw_progress": saw_progress,
|
|
173
|
+
"timed_out": True,
|
|
174
|
+
"timeout_detail": f"wait_for_condition {js!r} not met within {timeout_ms}ms",
|
|
175
|
+
}
|
|
176
|
+
if settle_ms:
|
|
177
|
+
rt.page.wait_for_timeout(settle_ms)
|
|
178
|
+
return {"progress_screenshots": shots, "saw_progress": saw_progress}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def act_mock_route(rt, step: Step, url_pattern: str, json_body: Any, status: int = 200, **_) -> dict:
|
|
182
|
+
"""Intercept a network request with a canned JSON response (e.g. to
|
|
183
|
+
stop a wizard auto-skipping itself on an already-configured backend).
|
|
184
|
+
Registered on the page, so it survives a subsequent goto/reload."""
|
|
185
|
+
body = json.dumps(json_body)
|
|
186
|
+
|
|
187
|
+
def handler(route):
|
|
188
|
+
route.fulfill(status=status, content_type="application/json", body=body)
|
|
189
|
+
|
|
190
|
+
rt.page.route(url_pattern, handler)
|
|
191
|
+
return {}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
ACTIONS: dict[str, Callable[..., dict]] = {
|
|
195
|
+
"goto": act_goto,
|
|
196
|
+
"reload": act_reload,
|
|
197
|
+
"click": act_click,
|
|
198
|
+
"fill": act_fill,
|
|
199
|
+
"press": act_press,
|
|
200
|
+
"hover": act_hover,
|
|
201
|
+
"select_option": act_select_option,
|
|
202
|
+
"upload_file": act_upload_file,
|
|
203
|
+
"wait_for": act_wait_for,
|
|
204
|
+
"wait_ms": act_wait_ms,
|
|
205
|
+
"set_viewport": act_set_viewport,
|
|
206
|
+
"screenshot": act_screenshot,
|
|
207
|
+
"scroll_into_view": act_scroll_into_view,
|
|
208
|
+
"login_form": act_login_form,
|
|
209
|
+
"override_session": act_override_session,
|
|
210
|
+
"wait_for_condition": act_wait_for_condition,
|
|
211
|
+
"mock_route": act_mock_route,
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def run_action(rt, step: Step, action_dict: dict[str, Any]) -> dict:
|
|
216
|
+
kwargs = {k: v for k, v in action_dict.items() if k != "action"}
|
|
217
|
+
name = action_dict["action"]
|
|
218
|
+
fn = ACTIONS.get(name)
|
|
219
|
+
if fn is None:
|
|
220
|
+
raise ValueError(f"Unknown action {name!r} (known: {sorted(ACTIONS)})")
|
|
221
|
+
return fn(rt, step, **kwargs)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def load_custom_actions(module_path: str | Path) -> list[str]:
|
|
225
|
+
"""Import a custom-actions module by file path and let it register
|
|
226
|
+
into ACTIONS. The module must expose ``register(actions: dict)``.
|
|
227
|
+
Returns the action names it added."""
|
|
228
|
+
path = Path(module_path)
|
|
229
|
+
if not path.exists():
|
|
230
|
+
raise ValueError(f"custom_actions module not found: {module_path}")
|
|
231
|
+
spec = importlib.util.spec_from_file_location(f"ccwalk_custom_{path.stem}", path)
|
|
232
|
+
if spec is None or spec.loader is None:
|
|
233
|
+
raise ValueError(f"Cannot import custom_actions module: {module_path}")
|
|
234
|
+
mod = importlib.util.module_from_spec(spec)
|
|
235
|
+
spec.loader.exec_module(mod)
|
|
236
|
+
register = getattr(mod, "register", None)
|
|
237
|
+
if not callable(register):
|
|
238
|
+
raise ValueError(f"{module_path} must define register(actions: dict)")
|
|
239
|
+
before = set(ACTIONS)
|
|
240
|
+
register(ACTIONS)
|
|
241
|
+
return sorted(set(ACTIONS) - before)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Browser launch with a system-chromium fallback.
|
|
2
|
+
|
|
3
|
+
Some boxes cannot run `playwright install-deps` (no root), but already
|
|
4
|
+
have a shared-libs-satisfied system chromium. Try the Playwright-managed
|
|
5
|
+
browser first, then each known system path."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
_FALLBACK_CHROMIUM_PATHS = [
|
|
12
|
+
"/usr/bin/chromium-browser",
|
|
13
|
+
"/usr/bin/chromium",
|
|
14
|
+
"/snap/bin/chromium",
|
|
15
|
+
"/usr/bin/google-chrome",
|
|
16
|
+
"/usr/bin/google-chrome-stable",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def launch_browser(playwright, headless: bool = True):
|
|
21
|
+
last_error: Exception | None = None
|
|
22
|
+
try:
|
|
23
|
+
return playwright.chromium.launch(headless=headless)
|
|
24
|
+
except Exception as e: # noqa: BLE001 -- any launch failure triggers fallback
|
|
25
|
+
last_error = e
|
|
26
|
+
for candidate in _FALLBACK_CHROMIUM_PATHS:
|
|
27
|
+
if not Path(candidate).exists():
|
|
28
|
+
continue
|
|
29
|
+
try:
|
|
30
|
+
return playwright.chromium.launch(headless=headless, executable_path=candidate)
|
|
31
|
+
except Exception as e: # noqa: BLE001
|
|
32
|
+
last_error = e
|
|
33
|
+
raise RuntimeError(
|
|
34
|
+
"Could not launch any chromium (playwright-managed or system fallback). "
|
|
35
|
+
f"Last error: {last_error}. Run `ccwalk install-browsers` to install "
|
|
36
|
+
"Playwright's chromium (if that fails on missing shared libraries, "
|
|
37
|
+
"install libnspr4/libnss3 system packages)."
|
|
38
|
+
)
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"""ccwalk CLI: init / run / doctor / report.
|
|
2
|
+
|
|
3
|
+
The supported workflow is through the Claude Code plugin (/ccwalk:setup
|
|
4
|
+
writes the config and spec, /ccwalk:run runs and triages). Driving this
|
|
5
|
+
CLI by hand works but is unsupported.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import shutil
|
|
12
|
+
import sys
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from . import __version__
|
|
17
|
+
from .actions import load_custom_actions
|
|
18
|
+
from .config import CONFIG_FILENAME, Config, load_config
|
|
19
|
+
from .helpers import AppProcess, app_ready, git_head, parse_viewports
|
|
20
|
+
from .reporters import results_from_run_meta, transcode_videos_mp4, write_report
|
|
21
|
+
from .runner import run_capture_only, run_walkthrough
|
|
22
|
+
from .specs import load_spec
|
|
23
|
+
|
|
24
|
+
_CONFIG_TEMPLATE = """\
|
|
25
|
+
# ccwalk.yaml -- cc-visual-walkthrough configuration.
|
|
26
|
+
# Generated by `ccwalk init`; the /ccwalk:setup Claude Code skill fills
|
|
27
|
+
# this in properly for your app. Full schema: see the config module docs.
|
|
28
|
+
version: 1
|
|
29
|
+
app:
|
|
30
|
+
name: "My App"
|
|
31
|
+
base_url: "http://localhost:8000"
|
|
32
|
+
# start_command: "npm run dev"
|
|
33
|
+
ready_check: { path: "/", timeout_s: 60 }
|
|
34
|
+
auth:
|
|
35
|
+
method: none # none | form | localstorage | cookie | header
|
|
36
|
+
capture:
|
|
37
|
+
video: true
|
|
38
|
+
default_viewport: { width: 1440, height: 900 }
|
|
39
|
+
report:
|
|
40
|
+
out_dir: "reports/walkthrough"
|
|
41
|
+
keep_runs: 10
|
|
42
|
+
specs_dir: "walkthrough/specs"
|
|
43
|
+
default_spec: "example_tour"
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
_EXAMPLE_SPEC = '''\
|
|
47
|
+
"""Example walkthrough spec -- replace with your app's tour.
|
|
48
|
+
|
|
49
|
+
Rules the validator enforces:
|
|
50
|
+
- steps of a group must be consecutive in STEPS
|
|
51
|
+
- a group's first step must begin with a goto (fresh contexts start blank)
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
from cc_visual_walkthrough import Assertion, Step
|
|
55
|
+
|
|
56
|
+
STEPS = [
|
|
57
|
+
Step(
|
|
58
|
+
name="homepage",
|
|
59
|
+
description="Load the homepage and check the main heading renders",
|
|
60
|
+
group="smoke",
|
|
61
|
+
actions=[
|
|
62
|
+
{"action": "goto", "url": "/"},
|
|
63
|
+
{"action": "wait_for", "selector": "h1"},
|
|
64
|
+
],
|
|
65
|
+
assertions=[Assertion(selector="h1", description="main heading visible")],
|
|
66
|
+
),
|
|
67
|
+
]
|
|
68
|
+
'''
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _resolve_spec(cfg: Config, spec_arg: str | None) -> str:
|
|
72
|
+
"""Resolution order: an explicit path (slash or .py) passes through;
|
|
73
|
+
otherwise try {specs_dir}/{name}.py on disk FIRST -- so a spec named
|
|
74
|
+
'tour.v2' finds tour.v2.py instead of misrouting into a dotted-module
|
|
75
|
+
import -- and only fall back to treating a dotted name as a module."""
|
|
76
|
+
spec = spec_arg or cfg.default_spec
|
|
77
|
+
if "/" in spec or "\\" in spec or spec.endswith(".py"):
|
|
78
|
+
return spec
|
|
79
|
+
candidate = Path(cfg.specs_dir) / f"{spec}.py"
|
|
80
|
+
if candidate.exists() or "." not in spec:
|
|
81
|
+
return str(candidate)
|
|
82
|
+
return spec # dotted module path
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _prune_runs(out_root: Path, keep: int) -> None:
|
|
86
|
+
if keep <= 0 or not out_root.is_dir():
|
|
87
|
+
return
|
|
88
|
+
runs = sorted([p for p in out_root.iterdir() if p.is_dir()], key=lambda p: p.name)
|
|
89
|
+
for old in runs[:-keep]:
|
|
90
|
+
shutil.rmtree(old, ignore_errors=True)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
94
|
+
root = Path.cwd()
|
|
95
|
+
cfg_path = root / CONFIG_FILENAME
|
|
96
|
+
if cfg_path.exists() and not args.force:
|
|
97
|
+
print(f"{CONFIG_FILENAME} already exists (use --force to overwrite)")
|
|
98
|
+
return 1
|
|
99
|
+
cfg_path.write_text(_CONFIG_TEMPLATE)
|
|
100
|
+
specs_dir = root / "walkthrough" / "specs"
|
|
101
|
+
specs_dir.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
example = specs_dir / "example_tour.py"
|
|
103
|
+
if not example.exists() or args.force:
|
|
104
|
+
example.write_text(_EXAMPLE_SPEC)
|
|
105
|
+
state_dir = root / ".ccwalk"
|
|
106
|
+
state_dir.mkdir(exist_ok=True)
|
|
107
|
+
(state_dir / ".gitignore").write_text("*\n")
|
|
108
|
+
print(f"Wrote {CONFIG_FILENAME}, {example.relative_to(root)}, and .ccwalk/")
|
|
109
|
+
print("Next: run /ccwalk:setup in Claude Code to tailor the tour to this app.")
|
|
110
|
+
return 0
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def cmd_run(args: argparse.Namespace) -> int:
|
|
114
|
+
cfg = load_config(args.config)
|
|
115
|
+
if args.base_url:
|
|
116
|
+
cfg.app.base_url = args.base_url.rstrip("/")
|
|
117
|
+
if cfg.custom_actions:
|
|
118
|
+
added = load_custom_actions(cfg.custom_actions)
|
|
119
|
+
if added:
|
|
120
|
+
print(f"Custom actions loaded: {', '.join(added)}")
|
|
121
|
+
|
|
122
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
|
123
|
+
out_root = Path(cfg.report.out_dir)
|
|
124
|
+
out_dir = Path(args.out_dir) if args.out_dir else out_root / timestamp
|
|
125
|
+
|
|
126
|
+
app_proc = None
|
|
127
|
+
if args.start_app:
|
|
128
|
+
if not cfg.app.start_command:
|
|
129
|
+
print("--start-app given but app.start_command is not configured", file=sys.stderr)
|
|
130
|
+
return 2
|
|
131
|
+
app_proc = AppProcess(
|
|
132
|
+
cfg.app.start_command,
|
|
133
|
+
cfg.app.base_url,
|
|
134
|
+
cfg.app.ready_check.path,
|
|
135
|
+
cfg.app.ready_check.timeout_s,
|
|
136
|
+
)
|
|
137
|
+
app_proc.__enter__()
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
health = app_ready(cfg.app.base_url, cfg.app.ready_check.path)
|
|
141
|
+
if not health.get("reachable") and not args.force:
|
|
142
|
+
print(
|
|
143
|
+
f"App not reachable at {cfg.app.base_url}{cfg.app.ready_check.path} "
|
|
144
|
+
f"({health.get('error')}). Start it first, use --start-app, or --force "
|
|
145
|
+
"to run anyway.",
|
|
146
|
+
file=sys.stderr,
|
|
147
|
+
)
|
|
148
|
+
return 2
|
|
149
|
+
# Preflight passed -- only now claim a run directory, so a dead app
|
|
150
|
+
# doesn't litter reports/ with empty timestamp dirs.
|
|
151
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
|
|
153
|
+
run_meta = {
|
|
154
|
+
"timestamp": timestamp,
|
|
155
|
+
"git_head": git_head(),
|
|
156
|
+
"base_url": cfg.app.base_url,
|
|
157
|
+
"app_health": health,
|
|
158
|
+
"app_name": cfg.app.name,
|
|
159
|
+
"tool_version": __version__,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if args.capture_only:
|
|
163
|
+
run_meta["spec"] = f"--capture-only {args.capture_only}"
|
|
164
|
+
viewports = parse_viewports(args.viewports)
|
|
165
|
+
results = run_capture_only(
|
|
166
|
+
args.capture_only, viewports, cfg, out_dir, headed=args.headed
|
|
167
|
+
)
|
|
168
|
+
else:
|
|
169
|
+
spec_ref = _resolve_spec(cfg, args.spec)
|
|
170
|
+
run_meta["spec"] = spec_ref
|
|
171
|
+
from .actions import ACTIONS
|
|
172
|
+
|
|
173
|
+
steps = load_spec(spec_ref, known_actions=set(ACTIONS))
|
|
174
|
+
results = run_walkthrough(
|
|
175
|
+
steps, cfg, out_dir,
|
|
176
|
+
headed=args.headed,
|
|
177
|
+
record_video=cfg.capture.video and not args.no_video,
|
|
178
|
+
)
|
|
179
|
+
finally:
|
|
180
|
+
if app_proc:
|
|
181
|
+
app_proc.__exit__(None, None, None)
|
|
182
|
+
|
|
183
|
+
if args.mp4:
|
|
184
|
+
for w in transcode_videos_mp4(out_dir, results):
|
|
185
|
+
print(f"warning: {w}", file=sys.stderr)
|
|
186
|
+
|
|
187
|
+
embed = args.embed or cfg.report.embed
|
|
188
|
+
if embed:
|
|
189
|
+
total_media = sum(
|
|
190
|
+
(out_dir / p).stat().st_size
|
|
191
|
+
for r in results
|
|
192
|
+
for p in [*r.screenshots, *( [r.video_path] if r.video_path else [] )]
|
|
193
|
+
if (out_dir / p).exists()
|
|
194
|
+
)
|
|
195
|
+
if total_media > 20 * 1024 * 1024:
|
|
196
|
+
print(
|
|
197
|
+
f"warning: --embed with {total_media / 1e6:.0f}MB of media makes a very "
|
|
198
|
+
"large HTML file; files over 25MB stay linked",
|
|
199
|
+
file=sys.stderr,
|
|
200
|
+
)
|
|
201
|
+
report_path = write_report(run_meta, results, out_dir, embed=embed)
|
|
202
|
+
if not args.out_dir:
|
|
203
|
+
_prune_runs(out_root, cfg.report.keep_runs)
|
|
204
|
+
|
|
205
|
+
n_pass = sum(1 for r in results if r.status == "pass")
|
|
206
|
+
n_warn = sum(1 for r in results if r.status == "warn")
|
|
207
|
+
n_fail = sum(1 for r in results if r.status == "fail")
|
|
208
|
+
n_blocked = sum(1 for r in results if r.status == "blocked")
|
|
209
|
+
print(f"\nReport: {Path(report_path).resolve()}")
|
|
210
|
+
summary = f"Steps: {len(results)} PASS={n_pass} WARN={n_warn} FAIL={n_fail}"
|
|
211
|
+
if n_blocked:
|
|
212
|
+
summary += f" BLOCKED={n_blocked}"
|
|
213
|
+
print(summary)
|
|
214
|
+
for r in results:
|
|
215
|
+
line = f" [{r.status.upper():7s}] {r.step.name} ({r.duration_ms:.0f}ms)"
|
|
216
|
+
if r.error:
|
|
217
|
+
line += f" -- {r.error}"
|
|
218
|
+
print(line)
|
|
219
|
+
return 0 if n_fail == 0 else 1
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def cmd_doctor(args: argparse.Namespace) -> int:
|
|
223
|
+
from .doctor import run_doctor
|
|
224
|
+
|
|
225
|
+
cfg = load_config(args.config)
|
|
226
|
+
if args.base_url:
|
|
227
|
+
cfg.app.base_url = args.base_url.rstrip("/")
|
|
228
|
+
return run_doctor(
|
|
229
|
+
cfg, args.url, args.find, headed=args.headed, no_auth=args.no_auth
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def cmd_install_browsers(args: argparse.Namespace) -> int:
|
|
234
|
+
"""Install Playwright's chromium via THIS interpreter -- works the
|
|
235
|
+
same for pip, uv tool, and pipx installs, where a bare `playwright`
|
|
236
|
+
command may not be on PATH."""
|
|
237
|
+
import subprocess
|
|
238
|
+
|
|
239
|
+
extra = args.args or ["chromium"]
|
|
240
|
+
proc = subprocess.run([sys.executable, "-m", "playwright", "install", *extra])
|
|
241
|
+
if proc.returncode == 0:
|
|
242
|
+
# playwright is silent when browsers are already present; say so
|
|
243
|
+
print(f"ccwalk: {' '.join(extra)} ready (installed or already present)")
|
|
244
|
+
else:
|
|
245
|
+
print("ccwalk: browser install FAILED - see output above", file=sys.stderr)
|
|
246
|
+
return proc.returncode
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def cmd_serve(args: argparse.Namespace) -> int:
|
|
250
|
+
from . import serve as serve_mod
|
|
251
|
+
|
|
252
|
+
cfg = load_config(args.config)
|
|
253
|
+
root = cfg.source_path.parent if cfg.source_path else Path.cwd()
|
|
254
|
+
out_root = Path(cfg.report.out_dir)
|
|
255
|
+
if not out_root.is_absolute():
|
|
256
|
+
out_root = root / out_root
|
|
257
|
+
if args.action == "stop":
|
|
258
|
+
return serve_mod.stop(root)
|
|
259
|
+
if args.action == "status":
|
|
260
|
+
return serve_mod.status(root, out_root)
|
|
261
|
+
return serve_mod.start(
|
|
262
|
+
root, out_root, cfg.app.name,
|
|
263
|
+
port=args.port, bind=args.bind, daemon=args.daemon,
|
|
264
|
+
config_path=str(cfg.source_path) if cfg.source_path else None,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def cmd_report(args: argparse.Namespace) -> int:
|
|
269
|
+
import json
|
|
270
|
+
|
|
271
|
+
run_dir = Path(args.run_dir)
|
|
272
|
+
meta_path = run_dir / "run_meta.json"
|
|
273
|
+
if not meta_path.exists():
|
|
274
|
+
print(f"No run_meta.json in {run_dir}", file=sys.stderr)
|
|
275
|
+
return 2
|
|
276
|
+
run_meta = json.loads(meta_path.read_text())
|
|
277
|
+
results = results_from_run_meta(run_meta)
|
|
278
|
+
if args.mp4:
|
|
279
|
+
for w in transcode_videos_mp4(run_dir, results):
|
|
280
|
+
print(f"warning: {w}", file=sys.stderr)
|
|
281
|
+
path = write_report(
|
|
282
|
+
run_meta, results, run_dir, embed=args.embed,
|
|
283
|
+
in_place=getattr(args, "in_place", False),
|
|
284
|
+
)
|
|
285
|
+
print(f"Re-rendered: {path}" + (" (embedded media)" if args.embed else ""))
|
|
286
|
+
return 0
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def build_arg_parser() -> argparse.ArgumentParser:
|
|
290
|
+
p = argparse.ArgumentParser(
|
|
291
|
+
prog="ccwalk",
|
|
292
|
+
description="cc-visual-walkthrough: records and captures spec-driven browser tours "
|
|
293
|
+
"into shareable HTML reports. Supported via the Claude Code plugin (/ccwalk:setup).",
|
|
294
|
+
)
|
|
295
|
+
p.add_argument("--version", action="version", version=f"ccwalk {__version__}")
|
|
296
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
297
|
+
|
|
298
|
+
p_init = sub.add_parser("init", help="Scaffold ccwalk.yaml + an example spec")
|
|
299
|
+
p_init.add_argument("--force", action="store_true")
|
|
300
|
+
p_init.set_defaults(func=cmd_init)
|
|
301
|
+
|
|
302
|
+
p_run = sub.add_parser("run", help="Run a walkthrough (or --capture-only) and write the report")
|
|
303
|
+
p_run.add_argument("--spec", default=None, help="Spec name in specs_dir, module path, or .py file")
|
|
304
|
+
p_run.add_argument("--config", default=None, help=f"Path to {CONFIG_FILENAME}")
|
|
305
|
+
p_run.add_argument("--base-url", default=None)
|
|
306
|
+
p_run.add_argument("--out-dir", default=None)
|
|
307
|
+
p_run.add_argument("--headed", action="store_true", help="Show the browser window")
|
|
308
|
+
p_run.add_argument("--no-video", action="store_true", help="Skip video recording")
|
|
309
|
+
p_run.add_argument("--embed", action="store_true", help="Base64-embed media into one HTML file")
|
|
310
|
+
p_run.add_argument("--mp4", action="store_true", help="Transcode videos to H.264 (needs ffmpeg)")
|
|
311
|
+
p_run.add_argument("--start-app", action="store_true", help="Launch app.start_command for the run")
|
|
312
|
+
p_run.add_argument("--force", action="store_true", help="Run even if the ready check fails")
|
|
313
|
+
p_run.add_argument("--capture-only", default=None, metavar="URL_OR_PATH",
|
|
314
|
+
help="Design mode: screenshot one page across --viewports")
|
|
315
|
+
p_run.add_argument("--viewports", default="390,768,1440")
|
|
316
|
+
p_run.set_defaults(func=cmd_run)
|
|
317
|
+
|
|
318
|
+
p_doc = sub.add_parser("doctor", help="Print stable-selector candidates for a page")
|
|
319
|
+
p_doc.add_argument("url", help="Path or URL to inspect")
|
|
320
|
+
p_doc.add_argument("--find", default=None, help="Text to locate on the page")
|
|
321
|
+
p_doc.add_argument("--config", default=None)
|
|
322
|
+
p_doc.add_argument("--base-url", default=None)
|
|
323
|
+
p_doc.add_argument("--headed", action="store_true")
|
|
324
|
+
p_doc.add_argument(
|
|
325
|
+
"--no-auth", dest="no_auth", action="store_true",
|
|
326
|
+
help="inspect without applying configured auth (the configured "
|
|
327
|
+
"login page is always exempt automatically)",
|
|
328
|
+
)
|
|
329
|
+
p_doc.set_defaults(func=cmd_doctor)
|
|
330
|
+
|
|
331
|
+
p_rep = sub.add_parser("report", help="Re-render report.html/.md from a run's run_meta.json")
|
|
332
|
+
p_rep.add_argument("run_dir")
|
|
333
|
+
p_rep.add_argument(
|
|
334
|
+
"--embed", action="store_true",
|
|
335
|
+
help="also write a self-contained report_embedded.html next to report.html",
|
|
336
|
+
)
|
|
337
|
+
p_rep.add_argument(
|
|
338
|
+
"--in-place", dest="in_place", action="store_true",
|
|
339
|
+
help="with --embed: overwrite report.html instead of writing a sibling",
|
|
340
|
+
)
|
|
341
|
+
p_rep.add_argument("--mp4", action="store_true")
|
|
342
|
+
p_rep.set_defaults(func=cmd_report)
|
|
343
|
+
|
|
344
|
+
p_srv = sub.add_parser(
|
|
345
|
+
"serve",
|
|
346
|
+
help="Serve the report directory with a runs index at / "
|
|
347
|
+
"(localhost, Cache-Control: no-store)",
|
|
348
|
+
description="Serve reports over HTTP with a generated runs index at /. "
|
|
349
|
+
"Binds 127.0.0.1 by default; exposing wider (--bind 0.0.0.0) is an "
|
|
350
|
+
"explicit choice - reports contain your app's screenshots. Safari "
|
|
351
|
+
"cannot play the .webm videos from this server (no Range support); "
|
|
352
|
+
"use --mp4 runs plus a range-capable server for Safari viewers.",
|
|
353
|
+
)
|
|
354
|
+
p_srv.add_argument(
|
|
355
|
+
"action", nargs="?", default="start", choices=["start", "stop", "status"],
|
|
356
|
+
help="start (default; restarts any running server), stop, or status",
|
|
357
|
+
)
|
|
358
|
+
p_srv.add_argument("--port", type=int, default=8378,
|
|
359
|
+
help="port to bind (default 8378; 0 = pick a free one)")
|
|
360
|
+
p_srv.add_argument("--bind", default="127.0.0.1")
|
|
361
|
+
p_srv.add_argument("--daemon", action="store_true",
|
|
362
|
+
help="run in the background (manage with serve stop/status)")
|
|
363
|
+
p_srv.add_argument("--config", default=None)
|
|
364
|
+
p_srv.set_defaults(func=cmd_serve)
|
|
365
|
+
|
|
366
|
+
p_ib = sub.add_parser(
|
|
367
|
+
"install-browsers",
|
|
368
|
+
help="Install Playwright's chromium for this interpreter (pip/uv tool/pipx safe)",
|
|
369
|
+
)
|
|
370
|
+
p_ib.add_argument("args", nargs="*", help="Extra args for `playwright install` (default: chromium)")
|
|
371
|
+
p_ib.set_defaults(func=cmd_install_browsers)
|
|
372
|
+
return p
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def main(argv: list[str] | None = None) -> int:
|
|
376
|
+
args = build_arg_parser().parse_args(argv)
|
|
377
|
+
try:
|
|
378
|
+
return args.func(args)
|
|
379
|
+
except (ValueError, RuntimeError) as e:
|
|
380
|
+
# Config errors, spec errors, and browser-launch failures arrive
|
|
381
|
+
# here as clean one-liners (with their own remediation hints)
|
|
382
|
+
# instead of raw tracebacks.
|
|
383
|
+
print(f"ccwalk: {e}", file=sys.stderr)
|
|
384
|
+
return 2
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
if __name__ == "__main__":
|
|
388
|
+
sys.exit(main())
|