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 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).
@@ -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).