shotbox 0.3.0__tar.gz
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.
- shotbox-0.3.0/LICENSE +21 -0
- shotbox-0.3.0/PKG-INFO +325 -0
- shotbox-0.3.0/README.md +302 -0
- shotbox-0.3.0/pyproject.toml +43 -0
- shotbox-0.3.0/setup.cfg +4 -0
- shotbox-0.3.0/shotbox/__init__.py +10 -0
- shotbox-0.3.0/shotbox/__main__.py +390 -0
- shotbox-0.3.0/shotbox/screen.py +206 -0
- shotbox-0.3.0/shotbox/session.py +385 -0
- shotbox-0.3.0/shotbox/term.py +239 -0
- shotbox-0.3.0/shotbox/wl.py +103 -0
- shotbox-0.3.0/shotbox/wlinput.py +288 -0
- shotbox-0.3.0/shotbox/x.py +167 -0
- shotbox-0.3.0/shotbox/xtest.py +215 -0
- shotbox-0.3.0/shotbox.egg-info/PKG-INFO +325 -0
- shotbox-0.3.0/shotbox.egg-info/SOURCES.txt +17 -0
- shotbox-0.3.0/shotbox.egg-info/dependency_links.txt +1 -0
- shotbox-0.3.0/shotbox.egg-info/entry_points.txt +2 -0
- shotbox-0.3.0/shotbox.egg-info/top_level.txt +1 -0
shotbox-0.3.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Misha Nasledov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
shotbox-0.3.0/PKG-INFO
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: shotbox
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Reproducible screenshots of real programs in a sealed X11 or Wayland session, taken once they're ready
|
|
5
|
+
Author-email: Misha Nasledov <misha@nasledov.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/mishan/shotbox
|
|
8
|
+
Project-URL: Changelog, https://github.com/mishan/shotbox/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: Issues, https://github.com/mishan/shotbox/issues
|
|
10
|
+
Keywords: screenshots,xvfb,wayland,sway,visual-regression,testing,gtk
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Environment :: X11 Applications
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Software Development :: Testing
|
|
18
|
+
Classifier: Topic :: Multimedia :: Graphics :: Capture :: Screen Capture
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# shotbox
|
|
25
|
+
|
|
26
|
+
Reproducible screenshots of real programs, taken where they can't touch your
|
|
27
|
+
desktop.
|
|
28
|
+
|
|
29
|
+
`shotbox shoot` starts a program on a private X display, with its own D-Bus
|
|
30
|
+
and a scratch home, waits until it's actually ready, takes the picture, and
|
|
31
|
+
cleans up. Run it twice and you get the same bytes. Nothing it runs can
|
|
32
|
+
reach your session: no stray windows on your screen, no settings changed, no
|
|
33
|
+
profiles or caches written in your home.
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
shotbox shoot editor.png --window 'Text Editor' -- gnome-text-editor notes.md
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Why
|
|
40
|
+
|
|
41
|
+
Screenshots for a README, a theme preview, or a check that a UI still looks
|
|
42
|
+
right all want the same things, and every project was growing its own copy:
|
|
43
|
+
|
|
44
|
+
- **Sealed.** The environment is rebuilt from an allowlist, not scrubbed from
|
|
45
|
+
a denylist, so nothing leaks in by accident. `DISPLAY`, `WAYLAND_DISPLAY`
|
|
46
|
+
and your session bus are gone; `HOME` and every XDG directory are a scratch
|
|
47
|
+
dir. The bus has nothing activatable on it, so no gvfs, dconf or portals
|
|
48
|
+
start behind your back. Settings are in-memory, so apps come up with their
|
|
49
|
+
own defaults, not yours.
|
|
50
|
+
- **Ready, not slept.** Waits are for things: a window mapped, a port open, a
|
|
51
|
+
file written, text on a terminal's screen, or the picture holding still.
|
|
52
|
+
No `sleep 6` and hope.
|
|
53
|
+
- **Deterministic.** X11 on Xvfb, GTK's software renderer, `LANG=C.UTF-8`,
|
|
54
|
+
`TZ=UTC`, a cursor that doesn't blink (GTK's text caret included, and GTK's
|
|
55
|
+
animations off), and PNGs with no timestamps in them.
|
|
56
|
+
- **Cleaned up.** Every program runs in its own process group, and the group
|
|
57
|
+
is killed on the way out: signalling a wrapper script alone leaves its
|
|
58
|
+
children running.
|
|
59
|
+
|
|
60
|
+
## Install
|
|
61
|
+
|
|
62
|
+
Needs `Xvfb`, `xauth`, `dbus-daemon`, ImageMagick and `xwininfo`; for
|
|
63
|
+
`shotbox term`, PyGObject with VTE (`gir1.2-vte-2.91`). On Debian:
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
sudo apt install xvfb xauth dbus-daemon imagemagick x11-utils gir1.2-vte-2.91
|
|
67
|
+
pipx install --system-site-packages shotbox
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`--system-site-packages` lets it see the system's PyGObject, which
|
|
71
|
+
`shotbox term` uses; `pip install shotbox` works too. Or run it from a
|
|
72
|
+
checkout: `ln -s "$PWD/bin/shotbox" ~/.local/bin/shotbox`.
|
|
73
|
+
|
|
74
|
+
No Python packages: it's the standard library and those tools. Wayland
|
|
75
|
+
sessions need more; see [Wayland](#wayland).
|
|
76
|
+
|
|
77
|
+
## Commands
|
|
78
|
+
|
|
79
|
+
**`shotbox shoot OUT.png [options] -- COMMAND...`** starts the command in a
|
|
80
|
+
sealed session, waits, takes the picture and stops everything.
|
|
81
|
+
|
|
82
|
+
- `--window RE` takes that window (its title, a regex matched in full)
|
|
83
|
+
rather than the whole display, and waits for it to appear. `app=RE`
|
|
84
|
+
matches its app id instead: a Wayland window's `app_id`, or an X
|
|
85
|
+
window's `WM_CLASS`, instance or class. Every `--window` and every
|
|
86
|
+
`window` wait takes either; a title that really does start with `app=`
|
|
87
|
+
can be written `[a]pp=...`.
|
|
88
|
+
- `--wait window:RE|port:N|file:PATH|ready|stable[:SECS]` waits for more
|
|
89
|
+
(repeatable, in order). `ready` is `$SHOTBOX_SCRATCH/ready` existing;
|
|
90
|
+
`shotbox term` writes it. `stable` is the `--window` (or the whole display)
|
|
91
|
+
looking the same for SECS (0.5): for a program with no other sign it's
|
|
92
|
+
done drawing.
|
|
93
|
+
- `--park` moves the pointer out of the way first (see `shotbox park`).
|
|
94
|
+
- `--crop WxH+X+Y`, `--settle SECS` (0.3), `--timeout SECS` (30),
|
|
95
|
+
`--log FILE` for the program's output. If a wait fails or the program
|
|
96
|
+
dies, it says which, shows the program's last output, and leaves a
|
|
97
|
+
picture of the screen as it was beside OUT, as `OUT-failed.png`
|
|
98
|
+
(`--failed FILE` for elsewhere).
|
|
99
|
+
|
|
100
|
+
**`shotbox run [options] -- COMMAND...`** runs a command in a sealed session
|
|
101
|
+
and exits with its status. For scripts that take several pictures: inside,
|
|
102
|
+
`shotbox wait window|port|file|ready|stable ARG` and `shotbox capture OUT.png
|
|
103
|
+
[--window RE] [--crop G] [--park]` work on the session's display. `shotbox wait
|
|
104
|
+
stable [SECS] [--window RE]` is the one to use after a click, in place of a
|
|
105
|
+
sleep. A wait that gives up takes a picture of the screen with `--failed
|
|
106
|
+
FILE`, or wherever `$SHOTBOX_FAILED` says (`--env SHOTBOX_FAILED=...`).
|
|
107
|
+
|
|
108
|
+
Inside, `shotbox key CHORD...` presses keys (`ctrl+comma`, `Return`,
|
|
109
|
+
`alt+shift+Tab`), `shotbox type TEXT` types ASCII text, and `shotbox click
|
|
110
|
+
X Y`, `shotbox move X Y` and `shotbox drag X1 Y1 X2 Y2` work the pointer,
|
|
111
|
+
at a point on the screen or, with `--window RE`, inside a window (`click
|
|
112
|
+
--button 3`, `--double`). `shotbox park` moves the pointer to the screen's
|
|
113
|
+
bottom-right corner, off whatever it was hovering, and waits (up to
|
|
114
|
+
`--settle`, 2s) for the repaint; a screen that keeps changing is taken as it
|
|
115
|
+
is. They speak XTEST to the display directly, so there's still nothing to
|
|
116
|
+
install.
|
|
117
|
+
|
|
118
|
+
Both take the session options: `--screen WxH` (1280x800); `--env NAME=VALUE`
|
|
119
|
+
and `--pass NAME` to set or let through variables; `--seed DIR` to start the
|
|
120
|
+
home as a copy of a directory (a config, a profile, sample files);
|
|
121
|
+
`--desktop` for real GSettings and the system's D-Bus services, which a
|
|
122
|
+
desktop shell needs; `--system-bus` for a stand-in system bus; `--keep` to
|
|
123
|
+
keep the scratch dir and say where it is; `--wayland` and `--xwayland`
|
|
124
|
+
for a [Wayland](#wayland) session.
|
|
125
|
+
|
|
126
|
+
**`shotbox term [options] -- COMMAND...`** is a terminal to take pictures of:
|
|
127
|
+
VTE, the engine behind GNOME Terminal, Ptyxis and Tilix, in a plain window
|
|
128
|
+
named `shotbox-term`.
|
|
129
|
+
|
|
130
|
+
- `--scheme FILE` colors it: a Tilix scheme as is, or
|
|
131
|
+
`{"foreground", "background", "cursor", "palette": [16 colors]}`.
|
|
132
|
+
- `--size COLSxROWS`, `--font "Monospace 11"`, `--title NAME`.
|
|
133
|
+
- Steps, in order: `--when REGEX` waits for the screen's text to match,
|
|
134
|
+
`--type TEXT` types (`\r` is Enter, `\e` Escape), `--sleep SECS`. After
|
|
135
|
+
the last one it writes the ready file. The terminal stays open, its
|
|
136
|
+
screen as the program left it, after the program exits: no `sleep 60` to
|
|
137
|
+
keep it up.
|
|
138
|
+
- `--crop COLSxROWS` takes only the top-left cells: a wide terminal, so
|
|
139
|
+
nothing wraps, and a picture of just the part that matters. It writes
|
|
140
|
+
the size in pixels to `$SHOTBOX_SCRATCH/crop`, which `shoot` crops to
|
|
141
|
+
when it has no `--crop` of its own.
|
|
142
|
+
- `--shoot OUT.png` starts the session and takes the picture too, taking
|
|
143
|
+
`shoot`'s session options, `--park` and `--failed`.
|
|
144
|
+
- `--log FILE` keeps every byte the program wrote, escape codes and all
|
|
145
|
+
(via `script`, whose first line is its own header). Colors are hard to
|
|
146
|
+
judge in a picture and easy to grep in the log.
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
shotbox term --shoot irssi.png --scheme dark.json --size 88x24 \
|
|
150
|
+
--when 'End of /NAMES' --type 'hello\r' -- irssi -c 127.0.0.1
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
which is short for `shotbox shoot irssi.png --window shotbox-term --wait
|
|
154
|
+
ready -- shotbox term ...`.
|
|
155
|
+
|
|
156
|
+
**`shotbox compare A.png B.png [--diff D.png] [--fuzz 1%] [--max N]`**
|
|
157
|
+
counts the pixels that differ and fails if more than `--max` (0) do. With a
|
|
158
|
+
committed picture, that's a visual regression test.
|
|
159
|
+
|
|
160
|
+
**`shotbox compare REFDIR NEWDIR [NAME...]`** does that for each picture in
|
|
161
|
+
REFDIR (or each NAME), against the one of the same name in NEWDIR, a line
|
|
162
|
+
each, and fails if any differs, is missing, or changed size. `--diff DIR`
|
|
163
|
+
writes `NAME-diff.png` there for each that fails. `--fuzz` and `--max`
|
|
164
|
+
take `NAME=VALUE` for one picture, beside a plain VALUE for the rest:
|
|
165
|
+
|
|
166
|
+
```
|
|
167
|
+
shotbox compare data/screenshots fresh --fuzz 0.5% --fuzz video=5% --diff fresh
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Wayland
|
|
171
|
+
|
|
172
|
+
`--wayland` makes a session a Wayland one: the program runs under a
|
|
173
|
+
headless sway, drawing in software, instead of on Xvfb. `--xwayland` adds
|
|
174
|
+
an Xwayland to it, and `DISPLAY`, so an X client runs the way it does on a
|
|
175
|
+
Wayland desktop, GTK programs made to with `--env GDK_BACKEND=x11`.
|
|
176
|
+
|
|
177
|
+
```
|
|
178
|
+
shotbox shoot editor.png --wayland --window app=org.gnome.TextEditor -- gnome-text-editor
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Everything else is the same: windows by title or `app=`, pictures, waits,
|
|
182
|
+
failure pictures, keys, text, the pointer, `park`, `term` and the Python
|
|
183
|
+
API (`shotbox.Session(wayland=True)`, or `here()` inside). Windows come
|
|
184
|
+
from sway's IPC socket and pictures from `grim`; keys and the pointer go
|
|
185
|
+
over sway's virtual keyboard and pointer, from a small client of
|
|
186
|
+
shotbox's own, as XTEST does on X11.
|
|
187
|
+
|
|
188
|
+
The pictures aren't X11's: GTK draws its own decorations on Wayland,
|
|
189
|
+
and under Xwayland, with no XSETTINGS, its text at 96 dpi. A committed
|
|
190
|
+
picture belongs to one backend.
|
|
191
|
+
|
|
192
|
+
Needs `sway` and `grim`, and `Xwayland` for `--xwayland`:
|
|
193
|
+
`sudo apt install sway grim xwayland`. Why sway, and how it fits:
|
|
194
|
+
[docs/wayland.md](docs/wayland.md).
|
|
195
|
+
|
|
196
|
+
## Python
|
|
197
|
+
|
|
198
|
+
A script that takes many pictures can drive the display itself rather than
|
|
199
|
+
start a `shotbox` process for every click. Inside `shotbox run`,
|
|
200
|
+
`shotbox.here()` is the session's display; outside, `shotbox.Session` starts
|
|
201
|
+
one. Both are a `Screen`:
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
import shotbox
|
|
205
|
+
|
|
206
|
+
with shotbox.Session(size=(1600, 1000), failed="out/chat-failed.png") as s:
|
|
207
|
+
s.spawn(["gtkhx"], log="gtkhx.log")
|
|
208
|
+
s.wait_window("GtkHx.*")
|
|
209
|
+
s.click(560, 660, window="GtkHx.*")
|
|
210
|
+
s.type("/clear\n")
|
|
211
|
+
s.wait_stable(window="GtkHx.*")
|
|
212
|
+
s.capture("out/chat.png", window="GtkHx.*", park=True)
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
- Waits: `wait_window(re)` (a title, or `app=RE`; returns
|
|
216
|
+
`(id, name, w, h, x, y)`),
|
|
217
|
+
`wait_port(n)`, `wait_file(path)`, `wait_ready()`,
|
|
218
|
+
`wait_stable(secs=0.5, window=None)`, and `until(what, test)` for a test
|
|
219
|
+
of your own; each takes `timeout` (30).
|
|
220
|
+
- Input: `key(*chords)`, `type(text)`, `click(x, y, window=None,
|
|
221
|
+
button=1, double=False)`, `move`, `drag`, `park()`. One X connection,
|
|
222
|
+
kept.
|
|
223
|
+
- Pictures: `capture(out, window=None, crop=None, park=False)`, and
|
|
224
|
+
`window(re)` for where one is.
|
|
225
|
+
- Anything that fails raises `SessionError`, saying what; with `failed` set
|
|
226
|
+
(or `$SHOTBOX_FAILED` inside `shotbox run`) it leaves a picture of the
|
|
227
|
+
screen there first, and says where.
|
|
228
|
+
- A `Session` also has `spawn(cmd, log=None)`, `run(cmd)`, `env` and
|
|
229
|
+
`scratch`.
|
|
230
|
+
|
|
231
|
+
Installed, `import shotbox` just works. With a checkout, put its root on
|
|
232
|
+
`PYTHONPATH` (`--pass PYTHONPATH` to let it
|
|
233
|
+
into a session).
|
|
234
|
+
|
|
235
|
+
## Node
|
|
236
|
+
|
|
237
|
+
`node/` is a small package for browser checks with Playwright, from the
|
|
238
|
+
pieces several projects had copied between them:
|
|
239
|
+
|
|
240
|
+
```js
|
|
241
|
+
import { serve, checks, pageErrors, launch, until, gif, sealed,
|
|
242
|
+
dress, film, frames, steady } from 'shotbox';
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
- `serve(dir, port = 0)`: a static server on localhost that serves ES
|
|
246
|
+
modules with the right type; port 0 picks a free one.
|
|
247
|
+
`shotbox-serve [DIR] [PORT] [PAGE]` runs it by hand (port 8080) and
|
|
248
|
+
prints the address of PAGE, for a site whose page isn't at its top:
|
|
249
|
+
`"demo": "shotbox-serve . 8080 demo/"` in a project's scripts.
|
|
250
|
+
- `checks()`: `check(cond, what)` and `skip(what)` print `ok`, `FAIL` or
|
|
251
|
+
`skip` lines; `done()` prints the tally and returns the exit status.
|
|
252
|
+
- `pageErrors(page)`: every uncaught exception and `console.error`.
|
|
253
|
+
- `launch(cmd, args)`: a program in its own process group, with `kill()`
|
|
254
|
+
that reaches its children and `why()` that says how it ended and what it
|
|
255
|
+
printed. `until(test, ms)` polls.
|
|
256
|
+
- `gif(film, out, { width, fps, from, colors, dither })`: a Playwright
|
|
257
|
+
recording, or a directory from `frames()`, as a GIF, via ffmpeg, with its own palette. `from` cuts that
|
|
258
|
+
many seconds off the start, `colors` caps the palette, and `dither` is
|
|
259
|
+
ffmpeg's (`none` for flat pages with something moving on them).
|
|
260
|
+
- `sealed({ pass, env })`: an environment for a headless browser that
|
|
261
|
+
nothing of yours gets into, as a session's but with no display.
|
|
262
|
+
Otherwise Chromium's fonts come through your `~/.config/fontconfig` and
|
|
263
|
+
`~/.local/share/fonts`, and your pictures aren't anybody else's. Give
|
|
264
|
+
its `env` to `chromium.launch({ env })` and `close()` it afterwards.
|
|
265
|
+
`pass` (a list of names) and `env` (an object) do what `--pass` and
|
|
266
|
+
`--env` do.
|
|
267
|
+
- `dress(page, { hold })`: a pointer and captions for a recording, since
|
|
268
|
+
a browser records neither its cursor nor a key pressed. A dot follows
|
|
269
|
+
the mouse and shrinks while a button is down; `caption(text)` shows a
|
|
270
|
+
caption at the bottom for `hold` ms (1300). Both come back after a
|
|
271
|
+
reload; `remove()` takes them out for a still.
|
|
272
|
+
- `film(page)`: where the loop starts in a recording. Call it right after
|
|
273
|
+
`newPage()`, `start()` when the loop begins, and `end()` closes the
|
|
274
|
+
context and resolves to the video, for `gif(video, out, { from:
|
|
275
|
+
reel.from })`. Playwright writes a frame only when the page repaints.
|
|
276
|
+
|
|
277
|
+
- `frames(page, { dir, fps, epoch, seed })`: a recording made a frame
|
|
278
|
+
at a time that comes out the same each run, where `recordVideo` races
|
|
279
|
+
the page and never does. The page's time stands still and moves a
|
|
280
|
+
frame's worth between screenshots: timers, `Date`,
|
|
281
|
+
`requestAnimationFrame`, CSS transitions and animations, and a seeded
|
|
282
|
+
`Math.random`. Launch Chromium with `args: steady`, and call it before
|
|
283
|
+
the page's first navigation. `run(ms)` lets page time pass unrecorded,
|
|
284
|
+
`start()` starts keeping frames, `hold(ms)` stands in for
|
|
285
|
+
`waitForTimeout` and `move(x, y, ms)` for `mouse.move({ steps })`;
|
|
286
|
+
other input lands between frames. `end()` says where they are, for
|
|
287
|
+
`gif(dir, out, { fps })`. Runs match frame for frame, give or take a
|
|
288
|
+
few pixels one level apart; what is real-time and not a timer (a
|
|
289
|
+
fetch, a worker, an iframe loading) is not stood still, though a
|
|
290
|
+
frame that navigates is let load and paint before time moves on.
|
|
291
|
+
|
|
292
|
+
```js
|
|
293
|
+
const context = await browser.newContext({ recordVideo: { dir } });
|
|
294
|
+
const page = await context.newPage();
|
|
295
|
+
const reel = film(page);
|
|
296
|
+
await page.goto(url);
|
|
297
|
+
const dressing = await dress(page);
|
|
298
|
+
reel.start();
|
|
299
|
+
await page.mouse.move(400, 300, { steps: 24 });
|
|
300
|
+
await dressing.caption('Alt Enter');
|
|
301
|
+
await page.keyboard.press('Alt+Enter');
|
|
302
|
+
await gif(await reel.end(), 'demo.gif', { from: reel.from });
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
It has TypeScript declarations, written by hand beside each module and
|
|
306
|
+
checked by `npm run types`. `dress`, `film` and `pageErrors` take
|
|
307
|
+
Playwright's `Page`, so they want Playwright's own types installed.
|
|
308
|
+
|
|
309
|
+
`npm install --save-dev shotbox`. Its `package.json` is at the top of the
|
|
310
|
+
repository, since npm installs from the top of a git repository and
|
|
311
|
+
nowhere else, so `"shotbox": "github:mishan/shotbox"` in `devDependencies`
|
|
312
|
+
works too, or `"file:../shotbox"` for a checkout beside yours.
|
|
313
|
+
|
|
314
|
+
## Tests
|
|
315
|
+
|
|
316
|
+
`test/run.sh` checks the session, the terminal, the pictures, the Python
|
|
317
|
+
API (`test/api.py`, which needs GTK 3's PyGObject) and the Node helpers, and
|
|
318
|
+
takes a few seconds. The helpers that need a browser use Playwright's
|
|
319
|
+
Chromium (`npm install && npx playwright install chromium`) and skip
|
|
320
|
+
without it, and the Wayland sessions need sway, grim and Xwayland and skip
|
|
321
|
+
without them.
|
|
322
|
+
|
|
323
|
+
## License
|
|
324
|
+
|
|
325
|
+
MIT. See [LICENSE](LICENSE).
|
shotbox-0.3.0/README.md
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
# shotbox
|
|
2
|
+
|
|
3
|
+
Reproducible screenshots of real programs, taken where they can't touch your
|
|
4
|
+
desktop.
|
|
5
|
+
|
|
6
|
+
`shotbox shoot` starts a program on a private X display, with its own D-Bus
|
|
7
|
+
and a scratch home, waits until it's actually ready, takes the picture, and
|
|
8
|
+
cleans up. Run it twice and you get the same bytes. Nothing it runs can
|
|
9
|
+
reach your session: no stray windows on your screen, no settings changed, no
|
|
10
|
+
profiles or caches written in your home.
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
shotbox shoot editor.png --window 'Text Editor' -- gnome-text-editor notes.md
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Why
|
|
17
|
+
|
|
18
|
+
Screenshots for a README, a theme preview, or a check that a UI still looks
|
|
19
|
+
right all want the same things, and every project was growing its own copy:
|
|
20
|
+
|
|
21
|
+
- **Sealed.** The environment is rebuilt from an allowlist, not scrubbed from
|
|
22
|
+
a denylist, so nothing leaks in by accident. `DISPLAY`, `WAYLAND_DISPLAY`
|
|
23
|
+
and your session bus are gone; `HOME` and every XDG directory are a scratch
|
|
24
|
+
dir. The bus has nothing activatable on it, so no gvfs, dconf or portals
|
|
25
|
+
start behind your back. Settings are in-memory, so apps come up with their
|
|
26
|
+
own defaults, not yours.
|
|
27
|
+
- **Ready, not slept.** Waits are for things: a window mapped, a port open, a
|
|
28
|
+
file written, text on a terminal's screen, or the picture holding still.
|
|
29
|
+
No `sleep 6` and hope.
|
|
30
|
+
- **Deterministic.** X11 on Xvfb, GTK's software renderer, `LANG=C.UTF-8`,
|
|
31
|
+
`TZ=UTC`, a cursor that doesn't blink (GTK's text caret included, and GTK's
|
|
32
|
+
animations off), and PNGs with no timestamps in them.
|
|
33
|
+
- **Cleaned up.** Every program runs in its own process group, and the group
|
|
34
|
+
is killed on the way out: signalling a wrapper script alone leaves its
|
|
35
|
+
children running.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
Needs `Xvfb`, `xauth`, `dbus-daemon`, ImageMagick and `xwininfo`; for
|
|
40
|
+
`shotbox term`, PyGObject with VTE (`gir1.2-vte-2.91`). On Debian:
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
sudo apt install xvfb xauth dbus-daemon imagemagick x11-utils gir1.2-vte-2.91
|
|
44
|
+
pipx install --system-site-packages shotbox
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`--system-site-packages` lets it see the system's PyGObject, which
|
|
48
|
+
`shotbox term` uses; `pip install shotbox` works too. Or run it from a
|
|
49
|
+
checkout: `ln -s "$PWD/bin/shotbox" ~/.local/bin/shotbox`.
|
|
50
|
+
|
|
51
|
+
No Python packages: it's the standard library and those tools. Wayland
|
|
52
|
+
sessions need more; see [Wayland](#wayland).
|
|
53
|
+
|
|
54
|
+
## Commands
|
|
55
|
+
|
|
56
|
+
**`shotbox shoot OUT.png [options] -- COMMAND...`** starts the command in a
|
|
57
|
+
sealed session, waits, takes the picture and stops everything.
|
|
58
|
+
|
|
59
|
+
- `--window RE` takes that window (its title, a regex matched in full)
|
|
60
|
+
rather than the whole display, and waits for it to appear. `app=RE`
|
|
61
|
+
matches its app id instead: a Wayland window's `app_id`, or an X
|
|
62
|
+
window's `WM_CLASS`, instance or class. Every `--window` and every
|
|
63
|
+
`window` wait takes either; a title that really does start with `app=`
|
|
64
|
+
can be written `[a]pp=...`.
|
|
65
|
+
- `--wait window:RE|port:N|file:PATH|ready|stable[:SECS]` waits for more
|
|
66
|
+
(repeatable, in order). `ready` is `$SHOTBOX_SCRATCH/ready` existing;
|
|
67
|
+
`shotbox term` writes it. `stable` is the `--window` (or the whole display)
|
|
68
|
+
looking the same for SECS (0.5): for a program with no other sign it's
|
|
69
|
+
done drawing.
|
|
70
|
+
- `--park` moves the pointer out of the way first (see `shotbox park`).
|
|
71
|
+
- `--crop WxH+X+Y`, `--settle SECS` (0.3), `--timeout SECS` (30),
|
|
72
|
+
`--log FILE` for the program's output. If a wait fails or the program
|
|
73
|
+
dies, it says which, shows the program's last output, and leaves a
|
|
74
|
+
picture of the screen as it was beside OUT, as `OUT-failed.png`
|
|
75
|
+
(`--failed FILE` for elsewhere).
|
|
76
|
+
|
|
77
|
+
**`shotbox run [options] -- COMMAND...`** runs a command in a sealed session
|
|
78
|
+
and exits with its status. For scripts that take several pictures: inside,
|
|
79
|
+
`shotbox wait window|port|file|ready|stable ARG` and `shotbox capture OUT.png
|
|
80
|
+
[--window RE] [--crop G] [--park]` work on the session's display. `shotbox wait
|
|
81
|
+
stable [SECS] [--window RE]` is the one to use after a click, in place of a
|
|
82
|
+
sleep. A wait that gives up takes a picture of the screen with `--failed
|
|
83
|
+
FILE`, or wherever `$SHOTBOX_FAILED` says (`--env SHOTBOX_FAILED=...`).
|
|
84
|
+
|
|
85
|
+
Inside, `shotbox key CHORD...` presses keys (`ctrl+comma`, `Return`,
|
|
86
|
+
`alt+shift+Tab`), `shotbox type TEXT` types ASCII text, and `shotbox click
|
|
87
|
+
X Y`, `shotbox move X Y` and `shotbox drag X1 Y1 X2 Y2` work the pointer,
|
|
88
|
+
at a point on the screen or, with `--window RE`, inside a window (`click
|
|
89
|
+
--button 3`, `--double`). `shotbox park` moves the pointer to the screen's
|
|
90
|
+
bottom-right corner, off whatever it was hovering, and waits (up to
|
|
91
|
+
`--settle`, 2s) for the repaint; a screen that keeps changing is taken as it
|
|
92
|
+
is. They speak XTEST to the display directly, so there's still nothing to
|
|
93
|
+
install.
|
|
94
|
+
|
|
95
|
+
Both take the session options: `--screen WxH` (1280x800); `--env NAME=VALUE`
|
|
96
|
+
and `--pass NAME` to set or let through variables; `--seed DIR` to start the
|
|
97
|
+
home as a copy of a directory (a config, a profile, sample files);
|
|
98
|
+
`--desktop` for real GSettings and the system's D-Bus services, which a
|
|
99
|
+
desktop shell needs; `--system-bus` for a stand-in system bus; `--keep` to
|
|
100
|
+
keep the scratch dir and say where it is; `--wayland` and `--xwayland`
|
|
101
|
+
for a [Wayland](#wayland) session.
|
|
102
|
+
|
|
103
|
+
**`shotbox term [options] -- COMMAND...`** is a terminal to take pictures of:
|
|
104
|
+
VTE, the engine behind GNOME Terminal, Ptyxis and Tilix, in a plain window
|
|
105
|
+
named `shotbox-term`.
|
|
106
|
+
|
|
107
|
+
- `--scheme FILE` colors it: a Tilix scheme as is, or
|
|
108
|
+
`{"foreground", "background", "cursor", "palette": [16 colors]}`.
|
|
109
|
+
- `--size COLSxROWS`, `--font "Monospace 11"`, `--title NAME`.
|
|
110
|
+
- Steps, in order: `--when REGEX` waits for the screen's text to match,
|
|
111
|
+
`--type TEXT` types (`\r` is Enter, `\e` Escape), `--sleep SECS`. After
|
|
112
|
+
the last one it writes the ready file. The terminal stays open, its
|
|
113
|
+
screen as the program left it, after the program exits: no `sleep 60` to
|
|
114
|
+
keep it up.
|
|
115
|
+
- `--crop COLSxROWS` takes only the top-left cells: a wide terminal, so
|
|
116
|
+
nothing wraps, and a picture of just the part that matters. It writes
|
|
117
|
+
the size in pixels to `$SHOTBOX_SCRATCH/crop`, which `shoot` crops to
|
|
118
|
+
when it has no `--crop` of its own.
|
|
119
|
+
- `--shoot OUT.png` starts the session and takes the picture too, taking
|
|
120
|
+
`shoot`'s session options, `--park` and `--failed`.
|
|
121
|
+
- `--log FILE` keeps every byte the program wrote, escape codes and all
|
|
122
|
+
(via `script`, whose first line is its own header). Colors are hard to
|
|
123
|
+
judge in a picture and easy to grep in the log.
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
shotbox term --shoot irssi.png --scheme dark.json --size 88x24 \
|
|
127
|
+
--when 'End of /NAMES' --type 'hello\r' -- irssi -c 127.0.0.1
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
which is short for `shotbox shoot irssi.png --window shotbox-term --wait
|
|
131
|
+
ready -- shotbox term ...`.
|
|
132
|
+
|
|
133
|
+
**`shotbox compare A.png B.png [--diff D.png] [--fuzz 1%] [--max N]`**
|
|
134
|
+
counts the pixels that differ and fails if more than `--max` (0) do. With a
|
|
135
|
+
committed picture, that's a visual regression test.
|
|
136
|
+
|
|
137
|
+
**`shotbox compare REFDIR NEWDIR [NAME...]`** does that for each picture in
|
|
138
|
+
REFDIR (or each NAME), against the one of the same name in NEWDIR, a line
|
|
139
|
+
each, and fails if any differs, is missing, or changed size. `--diff DIR`
|
|
140
|
+
writes `NAME-diff.png` there for each that fails. `--fuzz` and `--max`
|
|
141
|
+
take `NAME=VALUE` for one picture, beside a plain VALUE for the rest:
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
shotbox compare data/screenshots fresh --fuzz 0.5% --fuzz video=5% --diff fresh
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Wayland
|
|
148
|
+
|
|
149
|
+
`--wayland` makes a session a Wayland one: the program runs under a
|
|
150
|
+
headless sway, drawing in software, instead of on Xvfb. `--xwayland` adds
|
|
151
|
+
an Xwayland to it, and `DISPLAY`, so an X client runs the way it does on a
|
|
152
|
+
Wayland desktop, GTK programs made to with `--env GDK_BACKEND=x11`.
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
shotbox shoot editor.png --wayland --window app=org.gnome.TextEditor -- gnome-text-editor
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Everything else is the same: windows by title or `app=`, pictures, waits,
|
|
159
|
+
failure pictures, keys, text, the pointer, `park`, `term` and the Python
|
|
160
|
+
API (`shotbox.Session(wayland=True)`, or `here()` inside). Windows come
|
|
161
|
+
from sway's IPC socket and pictures from `grim`; keys and the pointer go
|
|
162
|
+
over sway's virtual keyboard and pointer, from a small client of
|
|
163
|
+
shotbox's own, as XTEST does on X11.
|
|
164
|
+
|
|
165
|
+
The pictures aren't X11's: GTK draws its own decorations on Wayland,
|
|
166
|
+
and under Xwayland, with no XSETTINGS, its text at 96 dpi. A committed
|
|
167
|
+
picture belongs to one backend.
|
|
168
|
+
|
|
169
|
+
Needs `sway` and `grim`, and `Xwayland` for `--xwayland`:
|
|
170
|
+
`sudo apt install sway grim xwayland`. Why sway, and how it fits:
|
|
171
|
+
[docs/wayland.md](docs/wayland.md).
|
|
172
|
+
|
|
173
|
+
## Python
|
|
174
|
+
|
|
175
|
+
A script that takes many pictures can drive the display itself rather than
|
|
176
|
+
start a `shotbox` process for every click. Inside `shotbox run`,
|
|
177
|
+
`shotbox.here()` is the session's display; outside, `shotbox.Session` starts
|
|
178
|
+
one. Both are a `Screen`:
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
import shotbox
|
|
182
|
+
|
|
183
|
+
with shotbox.Session(size=(1600, 1000), failed="out/chat-failed.png") as s:
|
|
184
|
+
s.spawn(["gtkhx"], log="gtkhx.log")
|
|
185
|
+
s.wait_window("GtkHx.*")
|
|
186
|
+
s.click(560, 660, window="GtkHx.*")
|
|
187
|
+
s.type("/clear\n")
|
|
188
|
+
s.wait_stable(window="GtkHx.*")
|
|
189
|
+
s.capture("out/chat.png", window="GtkHx.*", park=True)
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
- Waits: `wait_window(re)` (a title, or `app=RE`; returns
|
|
193
|
+
`(id, name, w, h, x, y)`),
|
|
194
|
+
`wait_port(n)`, `wait_file(path)`, `wait_ready()`,
|
|
195
|
+
`wait_stable(secs=0.5, window=None)`, and `until(what, test)` for a test
|
|
196
|
+
of your own; each takes `timeout` (30).
|
|
197
|
+
- Input: `key(*chords)`, `type(text)`, `click(x, y, window=None,
|
|
198
|
+
button=1, double=False)`, `move`, `drag`, `park()`. One X connection,
|
|
199
|
+
kept.
|
|
200
|
+
- Pictures: `capture(out, window=None, crop=None, park=False)`, and
|
|
201
|
+
`window(re)` for where one is.
|
|
202
|
+
- Anything that fails raises `SessionError`, saying what; with `failed` set
|
|
203
|
+
(or `$SHOTBOX_FAILED` inside `shotbox run`) it leaves a picture of the
|
|
204
|
+
screen there first, and says where.
|
|
205
|
+
- A `Session` also has `spawn(cmd, log=None)`, `run(cmd)`, `env` and
|
|
206
|
+
`scratch`.
|
|
207
|
+
|
|
208
|
+
Installed, `import shotbox` just works. With a checkout, put its root on
|
|
209
|
+
`PYTHONPATH` (`--pass PYTHONPATH` to let it
|
|
210
|
+
into a session).
|
|
211
|
+
|
|
212
|
+
## Node
|
|
213
|
+
|
|
214
|
+
`node/` is a small package for browser checks with Playwright, from the
|
|
215
|
+
pieces several projects had copied between them:
|
|
216
|
+
|
|
217
|
+
```js
|
|
218
|
+
import { serve, checks, pageErrors, launch, until, gif, sealed,
|
|
219
|
+
dress, film, frames, steady } from 'shotbox';
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
- `serve(dir, port = 0)`: a static server on localhost that serves ES
|
|
223
|
+
modules with the right type; port 0 picks a free one.
|
|
224
|
+
`shotbox-serve [DIR] [PORT] [PAGE]` runs it by hand (port 8080) and
|
|
225
|
+
prints the address of PAGE, for a site whose page isn't at its top:
|
|
226
|
+
`"demo": "shotbox-serve . 8080 demo/"` in a project's scripts.
|
|
227
|
+
- `checks()`: `check(cond, what)` and `skip(what)` print `ok`, `FAIL` or
|
|
228
|
+
`skip` lines; `done()` prints the tally and returns the exit status.
|
|
229
|
+
- `pageErrors(page)`: every uncaught exception and `console.error`.
|
|
230
|
+
- `launch(cmd, args)`: a program in its own process group, with `kill()`
|
|
231
|
+
that reaches its children and `why()` that says how it ended and what it
|
|
232
|
+
printed. `until(test, ms)` polls.
|
|
233
|
+
- `gif(film, out, { width, fps, from, colors, dither })`: a Playwright
|
|
234
|
+
recording, or a directory from `frames()`, as a GIF, via ffmpeg, with its own palette. `from` cuts that
|
|
235
|
+
many seconds off the start, `colors` caps the palette, and `dither` is
|
|
236
|
+
ffmpeg's (`none` for flat pages with something moving on them).
|
|
237
|
+
- `sealed({ pass, env })`: an environment for a headless browser that
|
|
238
|
+
nothing of yours gets into, as a session's but with no display.
|
|
239
|
+
Otherwise Chromium's fonts come through your `~/.config/fontconfig` and
|
|
240
|
+
`~/.local/share/fonts`, and your pictures aren't anybody else's. Give
|
|
241
|
+
its `env` to `chromium.launch({ env })` and `close()` it afterwards.
|
|
242
|
+
`pass` (a list of names) and `env` (an object) do what `--pass` and
|
|
243
|
+
`--env` do.
|
|
244
|
+
- `dress(page, { hold })`: a pointer and captions for a recording, since
|
|
245
|
+
a browser records neither its cursor nor a key pressed. A dot follows
|
|
246
|
+
the mouse and shrinks while a button is down; `caption(text)` shows a
|
|
247
|
+
caption at the bottom for `hold` ms (1300). Both come back after a
|
|
248
|
+
reload; `remove()` takes them out for a still.
|
|
249
|
+
- `film(page)`: where the loop starts in a recording. Call it right after
|
|
250
|
+
`newPage()`, `start()` when the loop begins, and `end()` closes the
|
|
251
|
+
context and resolves to the video, for `gif(video, out, { from:
|
|
252
|
+
reel.from })`. Playwright writes a frame only when the page repaints.
|
|
253
|
+
|
|
254
|
+
- `frames(page, { dir, fps, epoch, seed })`: a recording made a frame
|
|
255
|
+
at a time that comes out the same each run, where `recordVideo` races
|
|
256
|
+
the page and never does. The page's time stands still and moves a
|
|
257
|
+
frame's worth between screenshots: timers, `Date`,
|
|
258
|
+
`requestAnimationFrame`, CSS transitions and animations, and a seeded
|
|
259
|
+
`Math.random`. Launch Chromium with `args: steady`, and call it before
|
|
260
|
+
the page's first navigation. `run(ms)` lets page time pass unrecorded,
|
|
261
|
+
`start()` starts keeping frames, `hold(ms)` stands in for
|
|
262
|
+
`waitForTimeout` and `move(x, y, ms)` for `mouse.move({ steps })`;
|
|
263
|
+
other input lands between frames. `end()` says where they are, for
|
|
264
|
+
`gif(dir, out, { fps })`. Runs match frame for frame, give or take a
|
|
265
|
+
few pixels one level apart; what is real-time and not a timer (a
|
|
266
|
+
fetch, a worker, an iframe loading) is not stood still, though a
|
|
267
|
+
frame that navigates is let load and paint before time moves on.
|
|
268
|
+
|
|
269
|
+
```js
|
|
270
|
+
const context = await browser.newContext({ recordVideo: { dir } });
|
|
271
|
+
const page = await context.newPage();
|
|
272
|
+
const reel = film(page);
|
|
273
|
+
await page.goto(url);
|
|
274
|
+
const dressing = await dress(page);
|
|
275
|
+
reel.start();
|
|
276
|
+
await page.mouse.move(400, 300, { steps: 24 });
|
|
277
|
+
await dressing.caption('Alt Enter');
|
|
278
|
+
await page.keyboard.press('Alt+Enter');
|
|
279
|
+
await gif(await reel.end(), 'demo.gif', { from: reel.from });
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
It has TypeScript declarations, written by hand beside each module and
|
|
283
|
+
checked by `npm run types`. `dress`, `film` and `pageErrors` take
|
|
284
|
+
Playwright's `Page`, so they want Playwright's own types installed.
|
|
285
|
+
|
|
286
|
+
`npm install --save-dev shotbox`. Its `package.json` is at the top of the
|
|
287
|
+
repository, since npm installs from the top of a git repository and
|
|
288
|
+
nowhere else, so `"shotbox": "github:mishan/shotbox"` in `devDependencies`
|
|
289
|
+
works too, or `"file:../shotbox"` for a checkout beside yours.
|
|
290
|
+
|
|
291
|
+
## Tests
|
|
292
|
+
|
|
293
|
+
`test/run.sh` checks the session, the terminal, the pictures, the Python
|
|
294
|
+
API (`test/api.py`, which needs GTK 3's PyGObject) and the Node helpers, and
|
|
295
|
+
takes a few seconds. The helpers that need a browser use Playwright's
|
|
296
|
+
Chromium (`npm install && npx playwright install chromium`) and skip
|
|
297
|
+
without it, and the Wayland sessions need sway, grim and Xwayland and skip
|
|
298
|
+
without them.
|
|
299
|
+
|
|
300
|
+
## License
|
|
301
|
+
|
|
302
|
+
MIT. See [LICENSE](LICENSE).
|