shotlist 0.1.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.
- capture/__init__.py +8 -0
- capture/backends/__init__.py +6 -0
- capture/backends/cli.py +96 -0
- capture/backends/native_terminal.py +234 -0
- capture/backends/web.py +38 -0
- capture/check.py +65 -0
- capture/cli.py +258 -0
- capture/config.py +201 -0
- capture/diff.py +123 -0
- capture/engine.py +192 -0
- capture/lifecycle.py +165 -0
- capture/output.py +135 -0
- capture/render.py +39 -0
- capture/report.py +173 -0
- shotlist-0.1.0.dist-info/METADATA +203 -0
- shotlist-0.1.0.dist-info/RECORD +19 -0
- shotlist-0.1.0.dist-info/WHEEL +4 -0
- shotlist-0.1.0.dist-info/entry_points.txt +2 -0
- shotlist-0.1.0.dist-info/licenses/LICENSE +21 -0
capture/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""capture — reproducible screenshot capture for docs.
|
|
2
|
+
|
|
3
|
+
Drive a web app or CLI from a declarative shot list (``.capture.yaml``) and
|
|
4
|
+
capture polished, repeatable feature screenshots for READMEs, posts, and
|
|
5
|
+
test-evidence documents.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Capture backends: one rendering engine (Playwright/Chromium), two inputs.
|
|
2
|
+
|
|
3
|
+
- :mod:`capture.backends.web` screenshots live web pages.
|
|
4
|
+
- :mod:`capture.backends.cli` renders command output as a styled terminal window
|
|
5
|
+
and screenshots that, so CLI docs match the web ones visually.
|
|
6
|
+
"""
|
capture/backends/cli.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Capture CLI output as a styled terminal-window screenshot.
|
|
2
|
+
|
|
3
|
+
The command runs under a pseudo-terminal so tools emit their normal colors, the
|
|
4
|
+
output is converted to HTML, rendered as a terminal card (:mod:`capture.render`),
|
|
5
|
+
and screenshotted with Playwright.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import contextlib
|
|
9
|
+
import fcntl
|
|
10
|
+
import os
|
|
11
|
+
import pty
|
|
12
|
+
import select
|
|
13
|
+
import signal
|
|
14
|
+
import struct
|
|
15
|
+
import subprocess
|
|
16
|
+
import termios
|
|
17
|
+
import time
|
|
18
|
+
|
|
19
|
+
from playwright.sync_api import Page
|
|
20
|
+
|
|
21
|
+
from capture.config import CliShot
|
|
22
|
+
from capture.render import ansi_to_html, terminal_html
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _set_winsize(fd: int, cols: int, rows: int = 50) -> None:
|
|
26
|
+
winsize = struct.pack("HHHH", rows, cols, 0, 0)
|
|
27
|
+
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run_command(
|
|
31
|
+
command: str,
|
|
32
|
+
cwd: str | None,
|
|
33
|
+
cols: int,
|
|
34
|
+
timeout: float = 60.0,
|
|
35
|
+
) -> str:
|
|
36
|
+
"""Run ``command`` under a PTY and return its raw (ANSI) output.
|
|
37
|
+
|
|
38
|
+
A PTY makes tools emit colors as if attached to a real terminal. The command
|
|
39
|
+
is killed if it does not finish within ``timeout`` so capture never hangs.
|
|
40
|
+
"""
|
|
41
|
+
master, slave = pty.openpty()
|
|
42
|
+
_set_winsize(slave, cols)
|
|
43
|
+
env = {
|
|
44
|
+
**os.environ,
|
|
45
|
+
"TERM": "xterm-256color",
|
|
46
|
+
"COLUMNS": str(cols),
|
|
47
|
+
"FORCE_COLOR": "1",
|
|
48
|
+
"CLICOLOR_FORCE": "1",
|
|
49
|
+
}
|
|
50
|
+
proc = subprocess.Popen(
|
|
51
|
+
command,
|
|
52
|
+
shell=True,
|
|
53
|
+
cwd=cwd,
|
|
54
|
+
stdin=slave,
|
|
55
|
+
stdout=slave,
|
|
56
|
+
stderr=slave,
|
|
57
|
+
env=env,
|
|
58
|
+
start_new_session=True,
|
|
59
|
+
close_fds=True,
|
|
60
|
+
)
|
|
61
|
+
os.close(slave)
|
|
62
|
+
|
|
63
|
+
chunks: list[bytes] = []
|
|
64
|
+
deadline = time.monotonic() + timeout
|
|
65
|
+
try:
|
|
66
|
+
while time.monotonic() < deadline:
|
|
67
|
+
ready, _, _ = select.select([master], [], [], 0.2)
|
|
68
|
+
if not ready:
|
|
69
|
+
continue
|
|
70
|
+
try:
|
|
71
|
+
data = os.read(master, 4096)
|
|
72
|
+
except OSError:
|
|
73
|
+
break # slave closed — child finished
|
|
74
|
+
if not data:
|
|
75
|
+
break
|
|
76
|
+
chunks.append(data)
|
|
77
|
+
finally:
|
|
78
|
+
if proc.poll() is None:
|
|
79
|
+
with contextlib.suppress(ProcessLookupError):
|
|
80
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
81
|
+
proc.wait()
|
|
82
|
+
os.close(master)
|
|
83
|
+
|
|
84
|
+
return b"".join(chunks).decode(errors="replace")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def capture_cli(page: Page, shot: CliShot, cwd: str | None = None) -> bytes:
|
|
88
|
+
"""Run the shot's command and return a PNG of its rendered terminal output.
|
|
89
|
+
|
|
90
|
+
``cwd`` overrides ``shot.cwd`` when given (the engine passes the working
|
|
91
|
+
directory resolved relative to the repo root).
|
|
92
|
+
"""
|
|
93
|
+
working_dir = cwd if cwd is not None else shot.cwd
|
|
94
|
+
raw = run_command(shot.command, working_dir, shot.cols)
|
|
95
|
+
page.set_content(terminal_html(ansi_to_html(raw), shot.cols))
|
|
96
|
+
return page.locator(".frame").screenshot()
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Capture a *real* macOS Terminal window — an authentic screenshot.
|
|
2
|
+
|
|
3
|
+
Unlike the rendered CLI backend (which recreates output as styled HTML), this
|
|
4
|
+
drives the actual Terminal.app via AppleScript: open a window, size it, run the
|
|
5
|
+
command after a ``clear``, wait for it to finish, then ``screencapture`` the live
|
|
6
|
+
window and close it — all atomically so nothing can slide in front mid-capture.
|
|
7
|
+
|
|
8
|
+
Requires macOS and Screen-Recording permission for the controlling terminal
|
|
9
|
+
(System Settings → Privacy & Security → Screen Recording).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import contextlib
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import tempfile
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
# AppleScript args: cwd, command, cols, rows, out_path
|
|
19
|
+
_APPLESCRIPT = r'''on run argv
|
|
20
|
+
set theCwd to item 1 of argv
|
|
21
|
+
set theCmd to item 2 of argv
|
|
22
|
+
set theCols to (item 3 of argv) as integer
|
|
23
|
+
set theRows to (item 4 of argv) as integer
|
|
24
|
+
set outPath to item 5 of argv
|
|
25
|
+
tell application "Terminal"
|
|
26
|
+
activate
|
|
27
|
+
set t to do script ""
|
|
28
|
+
delay 0.4
|
|
29
|
+
set w to front window
|
|
30
|
+
set number of columns of w to theCols
|
|
31
|
+
set number of rows of w to theRows
|
|
32
|
+
delay 0.2
|
|
33
|
+
do script ("cd " & quoted form of theCwd & " && clear && " & theCmd) in t
|
|
34
|
+
delay 0.4
|
|
35
|
+
set tries to 0
|
|
36
|
+
repeat while (busy of t) and (tries < 600)
|
|
37
|
+
delay 0.2
|
|
38
|
+
set tries to tries + 1
|
|
39
|
+
end repeat
|
|
40
|
+
delay 0.5
|
|
41
|
+
-- Capture by WINDOW ID, not screen region: a region grab photographs whatever
|
|
42
|
+
-- pixels are stacked on top (e.g. the user's own frontmost terminal), leaking
|
|
43
|
+
-- unrelated window content into the shot. -l targets this window regardless of
|
|
44
|
+
-- stacking and doesn't require it to be frontmost.
|
|
45
|
+
set wid to id of w
|
|
46
|
+
do shell script "screencapture -x -o -l " & wid & " " & quoted form of outPath
|
|
47
|
+
close w saving no
|
|
48
|
+
end tell
|
|
49
|
+
end run
|
|
50
|
+
'''
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class NativeCaptureError(RuntimeError):
|
|
54
|
+
"""Raised when a real Terminal capture cannot be performed."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def capture_terminal(
|
|
58
|
+
command: str,
|
|
59
|
+
cwd: str,
|
|
60
|
+
cols: int,
|
|
61
|
+
rows: int,
|
|
62
|
+
timeout: float = 120.0,
|
|
63
|
+
) -> bytes:
|
|
64
|
+
"""Run ``command`` in a real Terminal window and return PNG bytes of it."""
|
|
65
|
+
if sys.platform != "darwin":
|
|
66
|
+
raise NativeCaptureError(
|
|
67
|
+
"native terminal capture requires macOS; set 'style: rendered' on this platform"
|
|
68
|
+
)
|
|
69
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
70
|
+
script_path = Path(tmp) / "capture.applescript"
|
|
71
|
+
out_path = Path(tmp) / "shot.png"
|
|
72
|
+
script_path.write_text(_APPLESCRIPT)
|
|
73
|
+
try:
|
|
74
|
+
subprocess.run(
|
|
75
|
+
[
|
|
76
|
+
"osascript",
|
|
77
|
+
str(script_path),
|
|
78
|
+
cwd,
|
|
79
|
+
command,
|
|
80
|
+
str(cols),
|
|
81
|
+
str(rows),
|
|
82
|
+
str(out_path),
|
|
83
|
+
],
|
|
84
|
+
check=True,
|
|
85
|
+
capture_output=True,
|
|
86
|
+
text=True,
|
|
87
|
+
timeout=timeout,
|
|
88
|
+
)
|
|
89
|
+
except subprocess.CalledProcessError as exc:
|
|
90
|
+
raise NativeCaptureError(
|
|
91
|
+
"Terminal capture failed. Grant Screen Recording permission to your "
|
|
92
|
+
"terminal in System Settings → Privacy & Security → Screen Recording, "
|
|
93
|
+
f"then retry.\n{exc.stderr.strip()}"
|
|
94
|
+
) from exc
|
|
95
|
+
except subprocess.TimeoutExpired as exc:
|
|
96
|
+
raise NativeCaptureError(
|
|
97
|
+
f"Terminal capture timed out after {timeout:.0f}s running: {command}"
|
|
98
|
+
) from exc
|
|
99
|
+
if not out_path.exists():
|
|
100
|
+
raise NativeCaptureError("Terminal capture produced no image")
|
|
101
|
+
return out_path.read_bytes()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# --- Persistent sessions: one window, many commands, a screenshot after each ---
|
|
105
|
+
|
|
106
|
+
# args: cwd, cols, rows -> prints the Terminal window id
|
|
107
|
+
_CREATE_SCRIPT = r'''on run argv
|
|
108
|
+
set theCwd to item 1 of argv
|
|
109
|
+
set theCols to (item 2 of argv) as integer
|
|
110
|
+
set theRows to (item 3 of argv) as integer
|
|
111
|
+
tell application "Terminal"
|
|
112
|
+
activate
|
|
113
|
+
set t to do script ""
|
|
114
|
+
delay 0.4
|
|
115
|
+
set w to front window
|
|
116
|
+
set number of columns of w to theCols
|
|
117
|
+
set number of rows of w to theRows
|
|
118
|
+
delay 0.2
|
|
119
|
+
do script ("cd " & quoted form of theCwd & " && clear") in t
|
|
120
|
+
delay 0.3
|
|
121
|
+
return (id of w) as text
|
|
122
|
+
end tell
|
|
123
|
+
end run
|
|
124
|
+
'''
|
|
125
|
+
|
|
126
|
+
# args: window_id, command, clear("1"/"0"), wait_ms, out_path
|
|
127
|
+
_STEP_SCRIPT = r'''on run argv
|
|
128
|
+
set wid to (item 1 of argv) as integer
|
|
129
|
+
set theCmd to item 2 of argv
|
|
130
|
+
set doClear to (item 3 of argv) is "1"
|
|
131
|
+
set waitMs to (item 4 of argv) as integer
|
|
132
|
+
set outPath to item 5 of argv
|
|
133
|
+
tell application "Terminal"
|
|
134
|
+
set w to window id wid
|
|
135
|
+
set t to selected tab of w
|
|
136
|
+
if doClear then
|
|
137
|
+
do script "clear" in t
|
|
138
|
+
delay 0.3
|
|
139
|
+
end if
|
|
140
|
+
do script theCmd in t
|
|
141
|
+
delay 0.3
|
|
142
|
+
set tries to 0
|
|
143
|
+
repeat while (busy of t) and (tries < 1500)
|
|
144
|
+
delay 0.2
|
|
145
|
+
set tries to tries + 1
|
|
146
|
+
end repeat
|
|
147
|
+
if waitMs > 0 then
|
|
148
|
+
delay (waitMs / 1000)
|
|
149
|
+
end if
|
|
150
|
+
delay 0.3
|
|
151
|
+
-- Capture by WINDOW ID (see the single-shot script): region grabs leak whatever
|
|
152
|
+
-- window is stacked on top; -l hits this window even when it isn't frontmost.
|
|
153
|
+
do shell script "screencapture -x -o -l " & wid & " " & quoted form of outPath
|
|
154
|
+
end tell
|
|
155
|
+
end run
|
|
156
|
+
'''
|
|
157
|
+
|
|
158
|
+
# args: window_id
|
|
159
|
+
_CLOSE_SCRIPT = r'''on run argv
|
|
160
|
+
set wid to (item 1 of argv) as integer
|
|
161
|
+
tell application "Terminal" to close (window id wid) saving no
|
|
162
|
+
end run
|
|
163
|
+
'''
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _run_osascript(script_body: str, args: list[str], timeout: float) -> str:
|
|
167
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
168
|
+
path = Path(tmp) / "script.applescript"
|
|
169
|
+
path.write_text(script_body)
|
|
170
|
+
try:
|
|
171
|
+
proc = subprocess.run(
|
|
172
|
+
["osascript", str(path), *args],
|
|
173
|
+
check=True,
|
|
174
|
+
capture_output=True,
|
|
175
|
+
text=True,
|
|
176
|
+
timeout=timeout,
|
|
177
|
+
)
|
|
178
|
+
except subprocess.CalledProcessError as exc:
|
|
179
|
+
raise NativeCaptureError(
|
|
180
|
+
"Terminal automation failed. Grant Screen Recording permission to your "
|
|
181
|
+
"terminal in System Settings → Privacy & Security → Screen Recording.\n"
|
|
182
|
+
f"{exc.stderr.strip()}"
|
|
183
|
+
) from exc
|
|
184
|
+
except subprocess.TimeoutExpired as exc:
|
|
185
|
+
raise NativeCaptureError(f"Terminal automation timed out after {timeout:.0f}s") from exc
|
|
186
|
+
return proc.stdout.strip()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _create_session(cwd: str, cols: int, rows: int) -> str:
|
|
190
|
+
return _run_osascript(_CREATE_SCRIPT, [cwd, str(cols), str(rows)], timeout=60.0)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _run_step(wid: str, command: str, clear: bool, wait_ms: int, out_path: str) -> None:
|
|
194
|
+
_run_osascript(
|
|
195
|
+
_STEP_SCRIPT,
|
|
196
|
+
[wid, command, "1" if clear else "0", str(wait_ms), out_path],
|
|
197
|
+
timeout=600.0,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _close_session(wid: str) -> None:
|
|
202
|
+
_run_osascript(_CLOSE_SCRIPT, [wid], timeout=30.0)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def capture_terminal_session(
|
|
206
|
+
steps: list[tuple[str, bool, int]],
|
|
207
|
+
cwd: str,
|
|
208
|
+
cols: int,
|
|
209
|
+
rows: int,
|
|
210
|
+
) -> list[bytes]:
|
|
211
|
+
"""Run ``steps`` in one persistent Terminal window, capturing after each.
|
|
212
|
+
|
|
213
|
+
Each step is ``(command, clear_first, wait_ms)``. The shell state persists
|
|
214
|
+
across steps; the window is captured after every step and closed at the end
|
|
215
|
+
(even on error). Returns one PNG per step, in order.
|
|
216
|
+
"""
|
|
217
|
+
if sys.platform != "darwin":
|
|
218
|
+
raise NativeCaptureError(
|
|
219
|
+
"native terminal capture requires macOS; set 'style: rendered' on this platform"
|
|
220
|
+
)
|
|
221
|
+
images: list[bytes] = []
|
|
222
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
223
|
+
wid = _create_session(cwd, cols, rows)
|
|
224
|
+
try:
|
|
225
|
+
for index, (command, clear, wait_ms) in enumerate(steps):
|
|
226
|
+
out_path = Path(tmp) / f"{index:03d}.png"
|
|
227
|
+
_run_step(wid, command, clear, wait_ms, str(out_path))
|
|
228
|
+
if not out_path.exists():
|
|
229
|
+
raise NativeCaptureError(f"no image produced for step {index + 1}")
|
|
230
|
+
images.append(out_path.read_bytes())
|
|
231
|
+
finally:
|
|
232
|
+
with contextlib.suppress(NativeCaptureError):
|
|
233
|
+
_close_session(wid)
|
|
234
|
+
return images
|
capture/backends/web.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Capture screenshots of live web pages with Playwright."""
|
|
2
|
+
|
|
3
|
+
from playwright.sync_api import Page
|
|
4
|
+
|
|
5
|
+
from capture.config import Step, WebShot
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def apply_step(page: Page, step: Step) -> None:
|
|
9
|
+
"""Run one declarative interaction against the page."""
|
|
10
|
+
if step.goto is not None:
|
|
11
|
+
page.goto(step.goto)
|
|
12
|
+
elif step.click is not None:
|
|
13
|
+
page.click(step.click)
|
|
14
|
+
elif step.fill is not None:
|
|
15
|
+
selector, value = step.fill
|
|
16
|
+
page.fill(selector, value)
|
|
17
|
+
elif step.wait_for is not None:
|
|
18
|
+
page.wait_for_selector(step.wait_for)
|
|
19
|
+
elif step.wait_ms is not None:
|
|
20
|
+
page.wait_for_timeout(step.wait_ms)
|
|
21
|
+
elif step.press is not None:
|
|
22
|
+
page.keyboard.press(step.press)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def capture_web(page: Page, shot: WebShot) -> bytes:
|
|
26
|
+
"""Navigate, run any interaction steps, and return PNG bytes.
|
|
27
|
+
|
|
28
|
+
Uses Playwright's default ``load`` wait (not ``networkidle``) so apps holding
|
|
29
|
+
open connections — websockets, SSE — do not hang; use a ``wait_for`` step to
|
|
30
|
+
gate on dynamic content instead.
|
|
31
|
+
"""
|
|
32
|
+
page.set_viewport_size({"width": shot.viewport.width, "height": shot.viewport.height})
|
|
33
|
+
page.goto(shot.url)
|
|
34
|
+
for step in shot.steps:
|
|
35
|
+
apply_step(page, step)
|
|
36
|
+
if shot.selector is not None:
|
|
37
|
+
return page.locator(shot.selector).screenshot()
|
|
38
|
+
return page.screenshot(full_page=shot.full_page)
|
capture/check.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Compare a fresh capture against a committed baseline manifest (drift check).
|
|
2
|
+
|
|
3
|
+
The committed ``manifest.json`` records a ``sha256`` per shot, so a re-capture can
|
|
4
|
+
be checked against it without any image-diffing dependency. Structural changes
|
|
5
|
+
(a shot added or removed) always count as drift; content changes are only flagged
|
|
6
|
+
for *deterministic* shots — a real Terminal screenshot can't be compared, so it is
|
|
7
|
+
skipped rather than reported as a spurious change.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
from capture.report import Manifest
|
|
14
|
+
|
|
15
|
+
Status = Literal["unchanged", "changed", "added", "removed", "skipped"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class ShotDiff:
|
|
20
|
+
"""How one shot compares between the baseline and the fresh capture."""
|
|
21
|
+
|
|
22
|
+
name: str
|
|
23
|
+
status: Status
|
|
24
|
+
reason: str = ""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class CheckResult:
|
|
29
|
+
"""The outcome of comparing a capture against its baseline."""
|
|
30
|
+
|
|
31
|
+
diffs: list[ShotDiff]
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def drifted(self) -> bool:
|
|
35
|
+
"""True when any shot changed, was added, or was removed."""
|
|
36
|
+
return any(d.status in ("changed", "added", "removed") for d in self.diffs)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def compare_manifests(baseline: Manifest, current: Manifest) -> CheckResult:
|
|
40
|
+
"""Compare ``current`` against ``baseline`` per shot, keyed by name."""
|
|
41
|
+
base_by_name = {shot["name"]: shot for shot in baseline["shots"]}
|
|
42
|
+
current_names = {shot["name"] for shot in current["shots"]}
|
|
43
|
+
diffs: list[ShotDiff] = []
|
|
44
|
+
|
|
45
|
+
for shot in current["shots"]:
|
|
46
|
+
name = shot["name"]
|
|
47
|
+
if name not in base_by_name:
|
|
48
|
+
diffs.append(ShotDiff(name, "added"))
|
|
49
|
+
elif not shot["deterministic"]:
|
|
50
|
+
diffs.append(ShotDiff(name, "skipped", "not reproducible (native)"))
|
|
51
|
+
elif base_by_name[name]["sha256"] != shot["sha256"]:
|
|
52
|
+
diffs.append(ShotDiff(name, "changed"))
|
|
53
|
+
else:
|
|
54
|
+
diffs.append(ShotDiff(name, "unchanged"))
|
|
55
|
+
|
|
56
|
+
for shot in baseline["shots"]:
|
|
57
|
+
if shot["name"] not in current_names:
|
|
58
|
+
if shot["deterministic"]:
|
|
59
|
+
diffs.append(ShotDiff(shot["name"], "removed"))
|
|
60
|
+
else:
|
|
61
|
+
# `check` only recaptures deterministic shots, so a native shot
|
|
62
|
+
# missing from the current set was never re-shot — not removed.
|
|
63
|
+
diffs.append(ShotDiff(shot["name"], "skipped", "not reproducible (native)"))
|
|
64
|
+
|
|
65
|
+
return CheckResult(diffs=diffs)
|