plexi-sdk 0.4.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.
plexi_sdk/testing.py ADDED
@@ -0,0 +1,451 @@
1
+ """Headless snapshot testing for Plexi widgets.
2
+
3
+ Spawns the plexi binary with `--render`, feeds it DrawCommand JSON,
4
+ reads back PNG bytes. Provides pixel-level assertions and snapshot file helpers.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import queue
10
+ import struct
11
+ import subprocess
12
+ import sys
13
+ import threading
14
+ import time
15
+ import zlib
16
+ from pathlib import Path
17
+
18
+ DEFAULT_BG = "#1e1e2e"
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Binary location
22
+ # ---------------------------------------------------------------------------
23
+
24
+ def _find_renderer() -> str:
25
+ """Locate the plexi binary to invoke with `--render`.
26
+
27
+ Checks in order:
28
+ 1. PLEXI_RENDER_BIN env var (path to a plexi binary)
29
+ 2. <repo_root>/target/release/plexi
30
+ 3. <repo_root>/target/debug/plexi
31
+
32
+ Raises RuntimeError with build instructions if not found.
33
+ """
34
+ env_override = os.environ.get("PLEXI_RENDER_BIN")
35
+ if env_override:
36
+ if os.path.isfile(env_override) and os.access(env_override, os.X_OK):
37
+ return env_override
38
+ raise RuntimeError(
39
+ f"PLEXI_RENDER_BIN is set to {env_override!r} but the file is not "
40
+ "executable or does not exist."
41
+ )
42
+
43
+ # Resolve repo root relative to this file: sdk/python/plexi_sdk/testing.py
44
+ # → walk up three parents to reach the repo root.
45
+ repo_root = Path(__file__).resolve().parent.parent.parent.parent
46
+ release = repo_root / "target" / "release" / "plexi"
47
+ debug = repo_root / "target" / "debug" / "plexi"
48
+
49
+ for candidate in (release, debug):
50
+ if candidate.is_file() and os.access(candidate, os.X_OK):
51
+ return str(candidate)
52
+
53
+ raise RuntimeError(
54
+ "plexi binary not found. Build it with:\n"
55
+ " cargo build --release\n"
56
+ f"Expected at: {release}"
57
+ )
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Core render call
62
+ # ---------------------------------------------------------------------------
63
+
64
+ def render_draw_commands(
65
+ commands: list[dict],
66
+ width: int,
67
+ height: int,
68
+ background: str = DEFAULT_BG,
69
+ ) -> bytes:
70
+ """Feed draw commands to the headless renderer, return PNG bytes.
71
+
72
+ Args:
73
+ commands: list of PGAP draw command dicts (rect, text, line, …).
74
+ width: viewport width in pixels.
75
+ height: viewport height in pixels.
76
+ background: CSS hex colour string for the background fill.
77
+
78
+ Returns:
79
+ Raw PNG bytes.
80
+
81
+ Raises:
82
+ RuntimeError if the binary is not found or exits nonzero.
83
+ """
84
+ renderer = _find_renderer()
85
+ payload = json.dumps({
86
+ "viewport": {"width": width, "height": height, "background": background},
87
+ "draw_commands": commands,
88
+ })
89
+ try:
90
+ result = subprocess.run(
91
+ [renderer, "--render"],
92
+ input=payload.encode(),
93
+ capture_output=True,
94
+ check=False,
95
+ )
96
+ except OSError as e:
97
+ raise RuntimeError(f"Failed to spawn plexi --render ({renderer}): {e}") from e
98
+
99
+ if result.returncode != 0:
100
+ stderr = result.stderr.decode(errors="replace").strip()
101
+ raise RuntimeError(
102
+ f"plexi --render exited {result.returncode}: {stderr}"
103
+ )
104
+
105
+ return result.stdout
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # PNG decoder (stdlib only — no PIL/Pillow)
110
+ # ---------------------------------------------------------------------------
111
+
112
+ def decode_png(png_bytes: bytes) -> tuple[int, int, bytes]:
113
+ """Decode an RGBA 8-bit PNG to (width, height, raw_rgba_bytes).
114
+
115
+ Pure stdlib implementation using zlib + struct. Only handles RGBA 8-bit
116
+ PNGs — which is exactly what tiny-skia produces.
117
+
118
+ Raises:
119
+ ValueError on an unrecognised PNG or unsupported colour type/bit depth.
120
+ """
121
+ if not png_bytes[:8] == b"\x89PNG\r\n\x1a\n":
122
+ raise ValueError("Not a PNG file (bad signature)")
123
+
124
+ # Parse chunks.
125
+ pos = 8
126
+ width = height = 0
127
+ raw_idat = b""
128
+
129
+ while pos < len(png_bytes):
130
+ length = struct.unpack_from(">I", png_bytes, pos)[0]
131
+ chunk_type = png_bytes[pos + 4: pos + 8]
132
+ data = png_bytes[pos + 8: pos + 8 + length]
133
+ pos += 12 + length # length + type(4) + data + crc(4)
134
+
135
+ if chunk_type == b"IHDR":
136
+ width, height = struct.unpack_from(">II", data)
137
+ bit_depth = data[8]
138
+ colour_type = data[9]
139
+ if bit_depth != 8 or colour_type != 6:
140
+ raise ValueError(
141
+ f"Unsupported PNG colour type={colour_type} bit_depth={bit_depth}. "
142
+ "Only RGBA 8-bit (colour_type=6) is supported."
143
+ )
144
+ elif chunk_type == b"IDAT":
145
+ raw_idat += data
146
+ elif chunk_type == b"IEND":
147
+ break
148
+
149
+ # Decompress all IDAT chunks together.
150
+ decompressed = zlib.decompress(raw_idat)
151
+
152
+ # Reconstruct pixel data by reversing the PNG filter on each scanline.
153
+ bytes_per_pixel = 4 # RGBA
154
+ stride = width * bytes_per_pixel
155
+ raw_rgba = bytearray(stride * height)
156
+
157
+ for row in range(height):
158
+ filter_byte = decompressed[row * (stride + 1)]
159
+ line_start = row * (stride + 1) + 1
160
+ line = decompressed[line_start: line_start + stride]
161
+
162
+ if filter_byte == 0: # None
163
+ raw_rgba[row * stride: (row + 1) * stride] = line
164
+ elif filter_byte == 1: # Sub
165
+ out = bytearray(line)
166
+ for i in range(bytes_per_pixel, stride):
167
+ out[i] = (out[i] + out[i - bytes_per_pixel]) & 0xFF
168
+ raw_rgba[row * stride: (row + 1) * stride] = out
169
+ elif filter_byte == 2: # Up
170
+ out = bytearray(line)
171
+ if row > 0:
172
+ prev = raw_rgba[(row - 1) * stride: row * stride]
173
+ for i in range(stride):
174
+ out[i] = (out[i] + prev[i]) & 0xFF
175
+ raw_rgba[row * stride: (row + 1) * stride] = out
176
+ elif filter_byte == 3: # Average
177
+ out = bytearray(line)
178
+ prev = raw_rgba[(row - 1) * stride: row * stride] if row > 0 else bytes(stride)
179
+ for i in range(stride):
180
+ a = out[i - bytes_per_pixel] if i >= bytes_per_pixel else 0
181
+ b = prev[i]
182
+ out[i] = (out[i] + (a + b) // 2) & 0xFF
183
+ raw_rgba[row * stride: (row + 1) * stride] = out
184
+ elif filter_byte == 4: # Paeth
185
+ out = bytearray(line)
186
+ prev = raw_rgba[(row - 1) * stride: row * stride] if row > 0 else bytes(stride)
187
+ for i in range(stride):
188
+ a = out[i - bytes_per_pixel] if i >= bytes_per_pixel else 0
189
+ b = prev[i]
190
+ c = prev[i - bytes_per_pixel] if i >= bytes_per_pixel else 0
191
+ p = a + b - c
192
+ pa = abs(p - a)
193
+ pb = abs(p - b)
194
+ pc = abs(p - c)
195
+ pr = a if pa <= pb and pa <= pc else (b if pb <= pc else c)
196
+ out[i] = (out[i] + pr) & 0xFF
197
+ raw_rgba[row * stride: (row + 1) * stride] = out
198
+ else:
199
+ raise ValueError(f"Unknown PNG filter byte {filter_byte} at row {row}")
200
+
201
+ return width, height, bytes(raw_rgba)
202
+
203
+
204
+ # ---------------------------------------------------------------------------
205
+ # Pixel helpers
206
+ # ---------------------------------------------------------------------------
207
+
208
+ def pixel_at(png_bytes: bytes, x: int, y: int) -> tuple[int, int, int, int]:
209
+ """Return (r, g, b, a) of the pixel at (x, y)."""
210
+ width, height, raw = decode_png(png_bytes)
211
+ if x < 0 or x >= width or y < 0 or y >= height:
212
+ raise ValueError(f"Pixel ({x}, {y}) out of bounds for {width}×{height} image")
213
+ idx = (y * width + x) * 4
214
+ return raw[idx], raw[idx + 1], raw[idx + 2], raw[idx + 3]
215
+
216
+
217
+ def hex_to_rgba(hex_color: str) -> tuple[int, int, int, int]:
218
+ """Convert a CSS hex color string to (r, g, b, a).
219
+
220
+ Supports #RGB, #RRGGBB, and #RRGGBBAA.
221
+ Alpha defaults to 255 when not specified.
222
+ """
223
+ s = hex_color.lstrip("#")
224
+ if len(s) == 3:
225
+ r = int(s[0] * 2, 16)
226
+ g = int(s[1] * 2, 16)
227
+ b = int(s[2] * 2, 16)
228
+ return r, g, b, 255
229
+ elif len(s) == 6:
230
+ r = int(s[0:2], 16)
231
+ g = int(s[2:4], 16)
232
+ b = int(s[4:6], 16)
233
+ return r, g, b, 255
234
+ elif len(s) == 8:
235
+ r = int(s[0:2], 16)
236
+ g = int(s[2:4], 16)
237
+ b = int(s[4:6], 16)
238
+ a = int(s[6:8], 16)
239
+ return r, g, b, a
240
+ else:
241
+ raise ValueError(f"Unrecognised hex color: {hex_color!r}")
242
+
243
+
244
+ def assert_pixel(
245
+ png_bytes: bytes,
246
+ x: int,
247
+ y: int,
248
+ expected: str | tuple[int, int, int, int],
249
+ tolerance: int = 4,
250
+ ) -> None:
251
+ """Assert the pixel at (x, y) matches the expected color within per-channel tolerance.
252
+
253
+ Args:
254
+ png_bytes: raw PNG bytes from render_draw_commands.
255
+ x, y: pixel coordinates.
256
+ expected: CSS hex string (e.g. "#ff0000") or (r, g, b, a) tuple.
257
+ tolerance: per-channel absolute tolerance (default 4).
258
+
259
+ Raises:
260
+ AssertionError with a diagnostic message on mismatch.
261
+ """
262
+ if isinstance(expected, str):
263
+ exp = hex_to_rgba(expected)
264
+ else:
265
+ exp = expected
266
+
267
+ got = pixel_at(png_bytes, x, y)
268
+ channels = ("r", "g", "b", "a")
269
+ mismatches = []
270
+ for ch, g, e in zip(channels, got, exp):
271
+ if abs(g - e) > tolerance:
272
+ mismatches.append(f" {ch}: got {g}, expected {e} (diff {abs(g - e)} > tolerance {tolerance})")
273
+
274
+ if mismatches:
275
+ exp_hex = "#{:02x}{:02x}{:02x}{:02x}".format(*exp)
276
+ got_hex = "#{:02x}{:02x}{:02x}{:02x}".format(*got)
277
+ raise AssertionError(
278
+ f"Pixel ({x}, {y}) mismatch:\n"
279
+ f" expected {exp_hex}, got {got_hex}\n"
280
+ + "\n".join(mismatches)
281
+ )
282
+
283
+
284
+ # ---------------------------------------------------------------------------
285
+ # Snapshot file helper
286
+ # ---------------------------------------------------------------------------
287
+
288
+ def save_snapshot(png_bytes: bytes, path: str | Path) -> None:
289
+ """Write PNG bytes to path. Creates parent directories as needed."""
290
+ p = Path(path)
291
+ try:
292
+ p.parent.mkdir(parents=True, exist_ok=True)
293
+ p.write_bytes(png_bytes)
294
+ except OSError as e:
295
+ raise RuntimeError(f"Failed to save snapshot to {p}: {e}") from e
296
+
297
+
298
+ # ---------------------------------------------------------------------------
299
+ # AppHarness — headless Python app subprocess runner (issue #865)
300
+ # ---------------------------------------------------------------------------
301
+
302
+
303
+ class AppHarness:
304
+ """Headless runner for Plexi Python apps.
305
+
306
+ Spawns the app script as a subprocess, communicates over stdin/stdout
307
+ using the PGAP v3 protocol. No display or running Plexi host required.
308
+
309
+ Usage::
310
+
311
+ with AppHarness("my_app.py", width=400, height=300) as h:
312
+ cmds = h.run(1) # step one render frame
313
+ h.key("enter") # inject a key event
314
+ cmds = h.run(1) # render again to see effects
315
+ h.assert_pixel(10, 10, "#1e1e2e") # pixel assertion (needs plexi binary)
316
+ """
317
+
318
+ def __init__(self, app_path: "str | Path", width: int = 800, height: int = 600,
319
+ timeout: float = 5.0) -> None:
320
+ self._width = width
321
+ self._height = height
322
+ self._timeout = timeout
323
+ self._draw_commands: "list[dict]" = []
324
+ self._frame_id = 0
325
+ self._stdout_q: "queue.Queue[str | None]" = queue.Queue()
326
+
327
+ import os
328
+ import subprocess as _subprocess
329
+ env = dict(os.environ)
330
+ env["PYTHONPATH"] = os.pathsep.join(sys.path)
331
+
332
+ self._proc = _subprocess.Popen(
333
+ [sys.executable, str(app_path)],
334
+ stdin=_subprocess.PIPE,
335
+ stdout=_subprocess.PIPE,
336
+ stderr=_subprocess.PIPE,
337
+ text=True,
338
+ env=env,
339
+ )
340
+
341
+ self._reader = threading.Thread(target=self._read_stdout, daemon=True)
342
+ self._reader.start()
343
+
344
+ # Handshake
345
+ self._send({"type": "init", "protocol": "pgap/3", "app_id": "test-harness",
346
+ "workspace_root": "/tmp", "capabilities": [], "feature_flags": []})
347
+ ev = self._wait_for(lambda e: e.get("type") == "ready")
348
+ if ev is None:
349
+ stderr = self._proc.stderr.read() if self._proc.stderr else ""
350
+ raise RuntimeError(
351
+ f"AppHarness: app did not send 'ready' within {timeout}s. "
352
+ f"stderr: {stderr[:500]!r}"
353
+ )
354
+
355
+ def _read_stdout(self) -> None:
356
+ assert self._proc.stdout is not None
357
+ for line in self._proc.stdout:
358
+ line = line.strip()
359
+ if line:
360
+ self._stdout_q.put(line)
361
+ self._stdout_q.put(None) # EOF sentinel
362
+
363
+ def _next_event(self, timeout: float) -> "dict | None":
364
+ try:
365
+ line = self._stdout_q.get(timeout=timeout)
366
+ except queue.Empty:
367
+ return None
368
+ if line is None:
369
+ return None
370
+ try:
371
+ return json.loads(line)
372
+ except json.JSONDecodeError:
373
+ return None
374
+
375
+ def _wait_for(self, predicate, timeout=None) -> "dict | None":
376
+ deadline = time.monotonic() + (timeout if timeout is not None else self._timeout)
377
+ while time.monotonic() < deadline:
378
+ remaining = deadline - time.monotonic()
379
+ ev = self._next_event(timeout=max(0.05, remaining))
380
+ if ev is not None and predicate(ev):
381
+ return ev
382
+ return None
383
+
384
+ def _send(self, event: dict) -> None:
385
+ assert self._proc.stdin is not None
386
+ self._proc.stdin.write(json.dumps(event) + "\n")
387
+ self._proc.stdin.flush()
388
+
389
+ def run(self, n_frames: int = 1) -> "list[dict]":
390
+ """Step N render frames. Returns draw commands from the last frame."""
391
+ cmds: "list[dict]" = []
392
+ for _ in range(n_frames):
393
+ self._frame_id += 1
394
+ self._send({
395
+ "type": "render",
396
+ "frame_id": self._frame_id,
397
+ "rect": {"x": 0.0, "y": 0.0, "w": float(self._width), "h": float(self._height)},
398
+ })
399
+ cmds = []
400
+ deadline = time.monotonic() + self._timeout
401
+ while time.monotonic() < deadline:
402
+ remaining = deadline - time.monotonic()
403
+ ev = self._next_event(timeout=max(0.05, remaining))
404
+ if ev is None:
405
+ continue
406
+ if ev.get("type") == "frame_done":
407
+ break
408
+ cmds.append(ev)
409
+ self._draw_commands = cmds
410
+ return cmds
411
+
412
+ def key(self, key: str, modifiers: "dict | None" = None) -> None:
413
+ """Inject a synthetic key event."""
414
+ self._send({"type": "key", "key": key, "modifiers": modifiers or {}})
415
+
416
+ def text_submit(self, id: str, value: str) -> None:
417
+ """Inject a synthetic text_submitted event (user pressed Enter in a TextInput)."""
418
+ self._send({"type": "text_submitted", "id": id, "value": value})
419
+
420
+ def screenshot(self) -> bytes:
421
+ """Render current draw commands to PNG. Requires the plexi binary."""
422
+ return render_draw_commands(self._draw_commands, self._width, self._height)
423
+
424
+ def assert_pixel(self, x: int, y: int,
425
+ expected: "str | tuple[int, int, int, int]",
426
+ tolerance: int = 4) -> None:
427
+ """Assert pixel color at (x, y) against the last rendered frame."""
428
+ assert_pixel(self.screenshot(), x, y, expected, tolerance)
429
+
430
+ def save_snapshot(self, path: "str | Path") -> None:
431
+ """Save the last rendered frame as a PNG file."""
432
+ save_snapshot(self.screenshot(), path)
433
+
434
+ def close(self) -> None:
435
+ """Shut down the app subprocess cleanly."""
436
+ import subprocess as _subprocess
437
+ try:
438
+ if self._proc.stdin:
439
+ self._proc.stdin.close()
440
+ except OSError:
441
+ pass
442
+ try:
443
+ self._proc.wait(timeout=5.0)
444
+ except _subprocess.TimeoutExpired:
445
+ self._proc.kill()
446
+
447
+ def __enter__(self) -> "AppHarness":
448
+ return self
449
+
450
+ def __exit__(self, *_: object) -> None:
451
+ self.close()