pyrtan / sourcev0.14.0 · main

pyrtan / tests / test_adapters.py

24,835 bytes · last changed 2026-10-26 in Watches and growing from a gap: trees that run when something speaks, and the branch a gap asks for drafted by rules; release 0.9.0

1"""The other forms of I/O: file, shell, serial, queue, browser; their
2allow-lists, their fixtures, and the egress policy over them."""
3
4from __future__ import annotations
5
6import http.server
7import json
8import os
9import socket
10import struct
11import sys
12import threading
13import time
14from pathlib import Path
15
16import pytest
17
18from pyrtan import Registry, RecordedTransport, Runner
19from pyrtan.adapters import (
20 LocalAdapters,
21 _mqtt_packet,
22 _mqtt_read,
23 _mqtt_str,
24 adapter_key,
25 command_allowed,
26 device_allowed,
27 path_allowed,
28 topic_allowed,
29)
30from pyrtan.cli import main
31from pyrtan.transport import EgressRefused, RecordingTransport, TransportError
32from pyrtan.tree import TreeError, check, load_tree_text
33
34
35def registry(tmp_path: Path, **trees: str) -> Registry:
36 d = tmp_path / "trees"
37 d.mkdir(exist_ok=True)
38 for name, text in trees.items():
39 (d / f"{name}.toml").write_text(text)
40 reg = Registry.from_paths([d])
41 assert not reg.errors, reg.errors
42 return reg
43
44
45# ---------------------------------------------------------------- allow-lists
46
47
48def test_allow_lists():
49 assert path_allowed(["/var/log/*"], "/var/log/syslog") and path_allowed(["/var/log"], "/var/log/nginx/access.log")
50 assert not path_allowed(["/var/log"], "/var/logs") and not path_allowed(["/var/log/*.log"], "/etc/passwd") and not path_allowed([], "/x")
51 assert path_allowed(["*"], "/anything") and path_allowed(["~/notes"], os.path.expanduser("~/notes/today.md"))
52 assert command_allowed(["df", "uptime"], "df") and command_allowed(["df"], "/usr/bin/df") and command_allowed(["/usr/bin/df"], "/usr/bin/df")
53 assert not command_allowed(["df"], "dfx") and not command_allowed(["/usr/bin/df"], "/bin/df") and command_allowed(["*"], "rm")
54 assert device_allowed(["/dev/ttyUSB*"], "/dev/ttyUSB0") and not device_allowed(["/dev/ttyUSB*"], "/dev/ttyACM0")
55 assert topic_allowed(["home/+/temperature"], "home/kitchen/temperature") and not topic_allowed(["home/+/temperature"], "home/kitchen/humidity")
56 assert topic_allowed(["home/#"], "home/a/b/c") and topic_allowed(["home/#"], "home") and not topic_allowed(["home/#"], "work/a")
57 assert topic_allowed(["home/*"], "home/a") and not topic_allowed(["home/*"], "home/a/b") and topic_allowed(["#"], "x/y") and not topic_allowed(["home/a"], "home/a/b")
58
59
60def test_adapter_keys_are_stable():
61 assert adapter_key("file", {"path": "/var/log/syslog", "tail": 3}) == "file /var/log/syslog"
62 assert adapter_key("file", {"path": "/tmp/x", "append": "hi"}) == "file append /tmp/x"
63 assert adapter_key("shell", {"command": "df", "args": ["-P", "/", 2]}) == "shell df -P / 2"
64 assert adapter_key("serial", {"device": "/dev/ttyUSB0", "write": "AT\r\n"}) == 'serial /dev/ttyUSB0 "AT\\r\\n"'
65 assert adapter_key("queue", {"url": "mqtt://b", "topic": "t", "publish": "on"}) == "queue publish mqtt://b t"
66 assert adapter_key("browser", {"url": "https://x/"}) == "browser https://x/"
67
68
69# ---------------------------------------------------------------- file
70
71
72FILE_TREE = """
73name = "notes"
74description = "reads and appends a notes file"
75paths = ["{root}"]
76start = "read"
77
78[inputs.path]
79type = "str"
80
81[nodes.read]
82kind = "observe"
83file = {{ path = "{{{{ inputs.path }}}}", tail = 2 }}
84into = "f"
85extract = [{{ into = "last", regex = "(?m)^(.*)$", group = 1, all = true }}]
86next = "exists"
87
88[nodes.exists]
89kind = "decide"
90on = "f.exists"
91branches = [{{ when = {{ eq = true }}, then = "note" }}, {{ else = "none" }}]
92
93[nodes.note]
94kind = "act"
95file = {{ path = "{{{{ inputs.path }}}}", append = "visited at {{{{ now.iso }}}}\\n" }}
96into = "w"
97next = "say"
98
99[nodes.say]
100kind = "done"
101say = "{{{{ state.f.lines | length }}}} line(s), {{{{ state.f.size }}}} B; wrote {{{{ state.w.written }}}} B"
102
103[nodes.none]
104kind = "fail"
105reason = "no-file"
106say = "{{{{ inputs.path }}}} is not there"
107"""
108
109
110def test_file_read_tail_and_append(tmp_path):
111 reg = registry(tmp_path, notes=FILE_TREE.format(root=str(tmp_path / "data")))
112 (tmp_path / "data").mkdir()
113 f = tmp_path / "data" / "notes.txt"
114 f.write_text("one\ntwo\nthree\n")
115 out = Runner(reg).run_tree("notes", {"path": str(f)})
116 assert out.status == "done", out.say
117 assert out.say.startswith("2 line(s), 14 B; wrote ")
118 assert f.read_text().startswith("one\ntwo\nthree\nvisited at 20")
119 assert out.trace.steps[0].summary == f"file {f} -> 14 B; last=['two', 'three']"
120 assert out.trace.steps[2].summary.startswith(f"file {f} -> wrote ")
121 out = Runner(reg).run_tree("notes", {"path": str(tmp_path / "data" / "missing.txt")})
122 assert out.status == "failed" and out.reason == "no-file" and "missing" in out.trace.steps[0].summary
123 # Outside the tree's paths: refused at run time, by the runtime, before the adapter.
124 out = Runner(reg).run_tree("notes", {"path": "/etc/hostname"})
125 assert out.status == "error" and out.reason == "path-not-allowed" and "outside this tree's paths" in out.say
126 # And ".." does not escape.
127 out = Runner(reg).run_tree("notes", {"path": str(tmp_path / "data" / ".." / "secret")})
128 assert out.reason == "path-not-allowed"
129 # A whole file, and JSON parsed.
130 reg2 = registry(tmp_path, j='''
131name = "j"
132paths = ["*"]
133start = "r"
134[nodes.r]
135kind = "observe"
136file = { path = "%s" }
137into = "f"
138next = "d"
139[nodes.d]
140kind = "done"
141say = "{{ state.f.json.a }}"
142''' % (tmp_path / "x.json"))
143 (tmp_path / "x.json").write_text('{"a": 42}')
144 assert Runner(reg2).run_tree("j").say == "42"
145
146
147def test_file_checker():
148 base = '''
149name = "f"
150paths = ["/var/log"]
151start = "r"
152[nodes.r]
153kind = "observe"
154file = { path = "/var/log/syslog" }
155next = "d"
156[nodes.d]
157kind = "done"
158'''
159 tree = load_tree_text(base)
160 assert tree.sources() == ["file"] and tree.capability()["paths"] == ["/var/log"] and tree.capability()["sources"] == ["file"]
161 with pytest.raises(TreeError, match="make this an act"):
162 load_tree_text(base.replace('path = "/var/log/syslog"', 'path = "/var/log/syslog", append = "x"'))
163 with pytest.raises(TreeError, match="not under this tree's paths"):
164 load_tree_text(base.replace("/var/log/syslog", "/etc/passwd"))
165 with pytest.raises(TreeError, match="must list the paths"):
166 load_tree_text(base.replace('paths = ["/var/log"]', ""))
167 with pytest.raises(TreeError, match="exactly one of"):
168 load_tree_text(base.replace('file = { path = "/var/log/syslog" }', 'file = { path = "/var/log/syslog" }\nshell = { command = "df" }'))
169 notes = [str(p) for p in check(load_tree_text(base.replace('paths = ["/var/log"]', 'paths = ["/var/log"]\ncommands = ["df"]')))]
170 assert any("commands are listed but no node uses them" in n for n in notes)
171 notes = [str(p) for p in check(load_tree_text(base.replace("/var/log/syslog", "/var/log/{{ inputs.name }}").replace('start = "r"', 'start = "r"\n[inputs.name]\ntype = "str"')))]
172 assert any("decided at run time" in n for n in notes)
173
174
175# ---------------------------------------------------------------- shell
176
177
178SHELL_TREE = """
179name = "say"
180description = "runs a program"
181commands = ["printf", "{py}", "false", "sleep"]
182start = "run"
183
184[inputs.text]
185type = "str"
186default = "hi"
187
188[nodes.run]
189kind = "observe"
190shell = {{ command = "printf", args = ["%s|%s", "{{{{ inputs.text }}}}", "$HOME;ls"] }}
191into = "p"
192retries = 0
193next = "ok"
194
195[nodes.ok]
196kind = "decide"
197on = "p.ok"
198branches = [{{ when = {{ eq = true }}, then = "say" }}, {{ else = "bad" }}]
199
200[nodes.say]
201kind = "done"
202say = "{{{{ state.p.stdout }}}}"
203
204[nodes.bad]
205kind = "fail"
206reason = "exit"
207say = "exit {{{{ state.p.status }}}}: {{{{ state.p.stderr | strip }}}}"
208"""
209
210
211def test_shell_runs_one_program_without_a_shell(tmp_path):
212 reg = registry(tmp_path, say=SHELL_TREE.format(py=sys.executable))
213 out = Runner(reg).run_tree("say", {"text": "a b"})
214 assert out.status == "done" and out.say == "a b|$HOME;ls" # the arguments reached printf as given: no expansion, no second command
215 assert out.trace.steps[0].summary == "shell printf %s|%s a b $HOME;ls -> exit 0"
216 # Exit status is observed, not an error; stderr too; stdout as JSON when it is.
217 reg2 = registry(tmp_path, say=SHELL_TREE.format(py=sys.executable).replace('shell = { command = "printf", args = ["%s|%s", "{{ inputs.text }}", "$HOME;ls"] }', 'shell = { command = "%s", args = ["-c", "import sys; print(\'{\\"n\\": 3}\'); sys.stderr.write(\'careful\'); sys.exit(2)"] }' % sys.executable).replace('say = "{{ state.p.stdout }}"', 'say = "{{ state.p.json.n }}"'))
218 out = Runner(reg2).run_tree("say")
219 assert out.status == "failed" and out.say == "exit 2: careful"
220 # A program not on the box, or too slow, is a transport error (an observe retries; here retries = 0).
221 reg3 = registry(tmp_path, say=SHELL_TREE.format(py=sys.executable).replace('command = "printf"', 'command = "{{ inputs.text }}"'))
222 out = Runner(reg3).run_tree("say", {"text": "no-such-program-xyz"})
223 assert out.status == "error" and out.reason == "command-not-allowed"
224 out = Runner(reg3).run_tree("say", {"text": "sleep"}) # sleep "%s|%s" ... exits non-zero: fine, that is observed
225 assert out.status == "failed"
226 reg4 = registry(tmp_path, say=SHELL_TREE.format(py=sys.executable).replace('shell = { command = "printf", args = ["%s|%s", "{{ inputs.text }}", "$HOME;ls"] }', 'shell = { command = "sleep", args = ["5"] }\ntimeout = 0.3'))
227 t0 = time.time()
228 out = Runner(reg4).run_tree("say")
229 assert out.status == "error" and out.reason == "transport" and "no answer within" in out.say and time.time() - t0 < 3
230 with pytest.raises(TreeError, match="not in this tree's commands"):
231 load_tree_text(SHELL_TREE.format(py=sys.executable).replace('command = "printf"', 'command = "rm"'))
232 with pytest.raises(TreeError, match="must list the commands"):
233 load_tree_text(SHELL_TREE.format(py=sys.executable).replace('commands = ["printf", "%s", "false", "sleep"]' % sys.executable, ""))
234
235
236# ---------------------------------------------------------------- serial
237
238
239SERIAL_TREE = """
240name = "modem"
241description = "asks a modem whether it is there"
242devices = ["/dev/pts/*", "/dev/ttyUSB*"]
243start = "at"
244
245[inputs.device]
246type = "str"
247
248[nodes.at]
249kind = "observe"
250serial = { device = "{{ inputs.device }}", baud = 9600, write = "AT\\r\\n", read_until = "\\n" }
251into = "s"
252timeout = 2
253retries = 0
254extract = [{ into = "answer", regex = "(OK|ERROR)" }]
255next = "ok"
256
257[nodes.ok]
258kind = "decide"
259on = "answer"
260branches = [{ when = { eq = "OK" }, then = "there" }, { else = "odd" }]
261
262[nodes.there]
263kind = "done"
264say = "{{ inputs.device }} answers ({{ state.s.bytes }} B, sent {{ state.s.sent }})"
265
266[nodes.odd]
267kind = "fail"
268reason = "odd-answer"
269say = "{{ state.s.body | strip }}"
270"""
271
272
273@pytest.mark.skipif(not hasattr(os, "openpty"), reason="no pty on this platform")
274def test_serial_exchange_over_a_pty(tmp_path):
275 reg = registry(tmp_path, modem=SERIAL_TREE)
276 master, slave = os.openpty()
277 device = os.ttyname(slave)
278 got: list[bytes] = []
279
280 def modem():
281 buf = b""
282 while b"\n" not in buf:
283 buf += os.read(master, 64)
284 got.append(buf)
285 os.write(master, b"OK\r\n")
286
287 t = threading.Thread(target=modem, daemon=True)
288 t.start()
289 try:
290 out = Runner(reg).run_tree("modem", {"device": device})
291 t.join(2)
292 assert out.status == "done", out.say
293 assert out.say == f"{device} answers (4 B, sent 4)" and got == [b"AT\r\n"]
294 assert out.trace.steps[0].summary == f"serial {device} -> 4 B; answer=OK"
295 # Nothing answers: a transport error, not a hang.
296 t0 = time.time()
297 out = Runner(reg).run_tree("modem", {"device": device})
298 assert out.status == "error" and out.reason == "transport" and "no answer within" in out.say and time.time() - t0 < 5
299 finally:
300 os.close(master)
301 os.close(slave)
302 assert Runner(reg).run_tree("modem", {"device": "/dev/ttyACM0"}).reason == "device-not-allowed"
303 out = Runner(reg).run_tree("modem", {"device": "/dev/ttyUSB99"})
304 assert out.status == "error" and out.reason == "transport"
305 with pytest.raises(TreeError, match="not in this tree's devices"):
306 load_tree_text(SERIAL_TREE.replace('device = "{{ inputs.device }}"', 'device = "/dev/ttyACM0"'))
307
308
309# ---------------------------------------------------------------- queue (MQTT)
310
311
312class Broker:
313 """Just enough of an MQTT 3.1.1 broker: one client at a time, a canned
314 message for whoever subscribes, a record of what is published."""
315
316 def __init__(self, message: bytes | None = b'{"temperature": 23.5}', refuse: int = 0):
317 self.message = message
318 self.refuse = refuse
319 self.published: list[tuple[str, bytes]] = []
320 self.subscribed: list[str] = []
321 self.subscribers: list[tuple[socket.socket, str]] = []
322 self.sock = socket.socket()
323 self.sock.bind(("127.0.0.1", 0))
324 self.sock.listen(5)
325 self.port = self.sock.getsockname()[1]
326 self.url = f"mqtt://127.0.0.1:{self.port}"
327 threading.Thread(target=self._serve, daemon=True).start()
328
329 def _serve(self):
330 while True:
331 try:
332 conn, _ = self.sock.accept()
333 except OSError:
334 return
335 threading.Thread(target=self._client, args=(conn,), daemon=True).start()
336
337 def _client(self, conn: socket.socket):
338 deadline = time.monotonic() + 10
339 try:
340 while True:
341 pkt = _mqtt_read(conn, deadline)
342 if pkt is None:
343 return
344 ptype, flags, body = pkt
345 if ptype == 1:
346 conn.sendall(_mqtt_packet(0x20, bytes([0, self.refuse])))
347 if self.refuse:
348 return
349 elif ptype == 8:
350 pid = body[:2]
351 tlen = struct.unpack(">H", body[2:4])[0]
352 topic = body[4:4 + tlen].decode()
353 self.subscribed.append(topic)
354 self.subscribers.append((conn, topic))
355 conn.sendall(_mqtt_packet(0x90, pid + b"\x00"))
356 if self.message is not None:
357 conn.sendall(_mqtt_packet(0x30, _mqtt_str(topic.replace("+", "kitchen")) + self.message))
358 elif ptype == 12: # PINGREQ
359 conn.sendall(_mqtt_packet(0xD0, b""))
360 elif ptype == 3:
361 tlen = struct.unpack(">H", body[:2])[0]
362 self.published.append((body[2:2 + tlen].decode(), body[2 + tlen:]))
363 elif ptype == 14:
364 return
365 finally:
366 conn.close()
367
368 def publish_to_subscribers(self, topic: str, payload: bytes) -> None:
369 for conn, _ in list(self.subscribers):
370 try:
371 conn.sendall(_mqtt_packet(0x30, _mqtt_str(topic) + payload))
372 except OSError:
373 pass
374
375 def close(self):
376 self.sock.close()
377
378
379QUEUE_TREE = """
380name = "sensor"
381description = "the last temperature a room reported"
382hosts = ["127.0.0.1"]
383topics = ["home/+/temperature", "home/+/light"]
384start = "wait"
385
386[inputs.room]
387type = "str"
388default = "kitchen"
389
390[inputs.broker]
391type = "str"
392
393[nodes.wait]
394kind = "observe"
395queue = { url = "{{ inputs.broker }}", topic = "home/{{ inputs.room }}/temperature" }
396into = "q"
397timeout = 2
398retries = 0
399next = "got"
400
401[nodes.got]
402kind = "decide"
403on = "q.received"
404branches = [{ when = { eq = true }, then = "warm" }, { else = "quiet" }]
405
406[nodes.warm]
407kind = "decide"
408on = "q.json.temperature"
409branches = [{ when = { gt = 25 }, then = "cool-it" }, { else = "fine" }]
410
411[nodes.cool-it]
412kind = "act"
413queue = { url = "{{ inputs.broker }}", topic = "home/{{ inputs.room }}/light", publish = { on = false } }
414into = "sent"
415next = "fine"
416
417[nodes.fine]
418kind = "done"
419say = "{{ inputs.room }}: {{ state.q.json.temperature }} from {{ state.q.message.topic }}"
420
421[nodes.quiet]
422kind = "fail"
423reason = "no-reading"
424say = "nothing on home/{{ inputs.room }}/temperature within 2 s"
425"""
426
427
428def test_queue_subscribe_wait_and_publish(tmp_path):
429 reg = registry(tmp_path, sensor=QUEUE_TREE)
430 b = Broker()
431 try:
432 out = Runner(reg).run_tree("sensor", {"broker": b.url})
433 assert out.status == "done", out.say
434 assert out.say == "kitchen: 23.5 from home/kitchen/temperature" and b.subscribed == ["home/kitchen/temperature"] and b.published == []
435 assert out.trace.steps[0].summary == f"queue home/kitchen/temperature at {b.url} -> 1 message"
436 hot = Broker(message=b'{"temperature": 31}')
437 out = Runner(reg).run_tree("sensor", {"broker": hot.url})
438 for _ in range(50): # the broker reads the publish on its own thread
439 if hot.published:
440 break
441 time.sleep(0.02)
442 assert out.status == "done" and hot.published == [("home/kitchen/light", b'{"on": false}')]
443 assert out.trace.steps[3].summary == f"queue home/kitchen/light at {hot.url} -> published 13 B"
444 hot.close()
445 quiet = Broker(message=None)
446 t0 = time.time()
447 out = Runner(reg).run_tree("sensor", {"broker": quiet.url})
448 assert out.status == "failed" and out.reason == "no-reading" and time.time() - t0 < 5
449 quiet.close()
450 refusing = Broker(refuse=5)
451 out = Runner(reg).run_tree("sensor", {"broker": refusing.url})
452 assert out.status == "error" and "refused (code 5)" in out.say
453 refusing.close()
454 # Allow-lists: the broker's host in hosts, the topic in topics.
455 assert Runner(reg).run_tree("sensor", {"broker": "mqtt://broker.example"}).reason == "host-not-allowed"
456 assert Runner(reg).run_tree("sensor", {"broker": b.url, "room": "a/b"}).reason == "topic-not-allowed"
457 assert Runner(reg).run_tree("sensor", {"broker": "http://127.0.0.1/"}).reason == "host-not-allowed"
458 finally:
459 b.close()
460 with pytest.raises(TreeError, match="make this an act"):
461 load_tree_text(QUEUE_TREE.replace('topic = "home/{{ inputs.room }}/temperature" }', 'topic = "home/{{ inputs.room }}/temperature", publish = "x" }'))
462 with pytest.raises(TreeError, match="must list the topics"):
463 load_tree_text(QUEUE_TREE.replace('topics = ["home/+/temperature", "home/+/light"]', ""))
464 with pytest.raises(TreeError, match="not under this tree's topics"):
465 load_tree_text(QUEUE_TREE.replace('topic = "home/{{ inputs.room }}/light"', 'topic = "work/light"'))
466
467
468# ---------------------------------------------------------------- egress, fixtures, the command
469
470
471def test_egress_public_keeps_the_box_closed(tmp_path):
472 hosted = LocalAdapters(egress="public")
473 for source, spec in (("file", {"path": "/etc/hostname"}), ("shell", {"command": "uptime"}), ("serial", {"device": "/dev/ttyUSB0"})):
474 with pytest.raises(EgressRefused, match="its own box"):
475 hosted.perform(source, spec, 1.0)
476 with pytest.raises(EgressRefused):
477 hosted.perform("queue", {"url": "mqtt://127.0.0.1", "topic": "t"}, 1.0)
478 with pytest.raises(EgressRefused):
479 hosted.perform("browser", {"url": "http://10.0.0.1/"}, 1.0)
480 # Through a tree: the refusal is an error the trace shows, and an observe does not retry it.
481 reg = registry(tmp_path, say=SHELL_TREE.format(py=sys.executable))
482 out = Runner(reg, adapters=hosted).run_tree("say")
483 assert out.status == "error" and out.reason == "transport" and "its own box" in out.say
484
485
486def test_fixtures_record_and_replay_every_source(tmp_path, capsys):
487 reg = registry(tmp_path, say=SHELL_TREE.format(py=sys.executable), notes=FILE_TREE.format(root=str(tmp_path)))
488 (tmp_path / "n.txt").write_text("a\nb\n")
489 rec = RecordingTransport(None, LocalAdapters()) # type: ignore[arg-type]
490 runner = Runner(reg, rec)
491 assert runner.run_tree("say", {"text": "x"}).status == "done"
492 assert runner.run_tree("notes", {"path": str(tmp_path / "n.txt")}).status == "done"
493 assert set(rec.recorded) == {"shell printf %s|%s x $HOME;ls", f"file {tmp_path / 'n.txt'}", f"file append {tmp_path / 'n.txt'}"}
494 rec.save(tmp_path / "fx.json")
495 replay = RecordedTransport.from_file(tmp_path / "fx.json")
496 out = Runner(reg, replay).run_tree("say", {"text": "x"})
497 assert out.status == "done" and out.say == "x|$HOME;ls" and replay.calls == [("shell", "shell printf %s|%s x $HOME;ls")]
498 out = Runner(reg, replay).run_tree("say", {"text": "unrecorded"})
499 assert out.status == "error" and "no recorded result for shell printf %s|%s unrecorded" in out.say
500 # The command records and replays the same way.
501 code = main(["run", "--tree", "say", "--input", "text=cli", "--trees", str(tmp_path / "trees"), "--record", str(tmp_path / "cli.json"), "--json", "--no-trace"])
502 assert code == 0 and json.loads(capsys.readouterr().out)["say"] == "cli|$HOME;ls"
503 assert "shell printf %s|%s cli $HOME;ls" in json.loads((tmp_path / "cli.json").read_text())
504 code = main(["run", "--tree", "say", "--input", "text=cli", "--trees", str(tmp_path / "trees"), "--replay", str(tmp_path / "cli.json"), "--json", "--no-trace"])
505 assert code == 0 and json.loads(capsys.readouterr().out)["say"] == "cli|$HOME;ls"
506 # A service with --egress public refuses the box; a recorded transport answers for the sources too.
507 from pyrtan.service import Service
508
509 svc = Service(reg, egress="public")
510 assert svc.run({"tree": "say", "inputs": {}})["reason"] == "transport"
511 svc = Service(reg, transport=replay)
512 assert svc.run({"tree": "say", "inputs": {"text": "x"}})["status"] == "done"
513
514
515# ---------------------------------------------------------------- browser
516
517
518PAGE = b"""<!doctype html><html><head><title>App</title></head><body>
519<div id="root">loading</div><a id="away" href="%s">away</a>
520<script>setTimeout(() => { document.getElementById("root").innerHTML = "<h1>Rendered</h1><button id=more>more</button>";
521document.getElementById("more").onclick = () => { document.getElementById("root").insertAdjacentHTML("beforeend", "<p class=extra>extra</p>"); }; }, 50);</script>
522</body></html>"""
523
524BROWSER_TREE = """
525name = "app"
526description = "a page that needs a script to render"
527hosts = ["127.0.0.1"]
528start = "open"
529
530[inputs.url]
531type = "str"
532
533[nodes.open]
534kind = "observe"
535browser = { url = "{{ inputs.url }}", wait_for = "h1", actions = [{ click = "#more" }, { wait_for = ".extra" }] }
536into = "page"
537timeout = 15
538retries = 0
539extract = [{ into = "heading", css = "h1" }, { into = "extra", css = "p.extra" }]
540next = "say"
541
542[nodes.say]
543kind = "done"
544say = "{{ state.page.title }}: {{ state.heading }} / {{ state.extra }} ({{ state.page.status }})"
545"""
546
547
548@pytest.fixture
549def pages():
550 class H(http.server.BaseHTTPRequestHandler):
551 def do_GET(self):
552 self.send_response(200)
553 self.send_header("Content-Type", "text/html")
554 self.end_headers()
555 self.wfile.write(PAGE % f"http://localhost:{self.server.server_port}/elsewhere".encode())
556
557 def log_message(self, *a):
558 pass
559
560 srv = http.server.ThreadingHTTPServer(("127.0.0.1", 0), H)
561 threading.Thread(target=srv.serve_forever, daemon=True).start()
562 yield f"http://127.0.0.1:{srv.server_port}"
563 srv.shutdown()
564
565
566def test_browser_renders_acts_and_stays_on_its_hosts(tmp_path, pages):
567 pytest.importorskip("playwright")
568 adapters = LocalAdapters()
569 try:
570 adapters.perform("browser", {"url": pages + "/"}, 15.0)
571 except TransportError as e:
572 pytest.skip(str(e))
573 reg = registry(tmp_path, app=BROWSER_TREE)
574 try:
575 out = Runner(reg, adapters=adapters).run_tree("app", {"url": pages + "/app"})
576 assert out.status == "done", out.say
577 assert out.say == "App: Rendered / extra (200)"
578 assert out.trace.steps[0].summary == f"browser {pages}/app -> 200; heading=Rendered, extra=extra"
579 # A click that would take the page to another host is refused; so is a URL outside hosts, before any browser starts.
580 reg2 = registry(tmp_path, app=BROWSER_TREE.replace("{ click = \"#more\" }", "{ click = \"#away\" }"))
581 out = Runner(reg2, adapters=adapters).run_tree("app", {"url": pages + "/app"})
582 assert out.status == "error" and out.reason == "transport" and "outside this tree's hosts" in out.say
583 out = Runner(reg, adapters=adapters).run_tree("app", {"url": "http://localhost:1/"})
584 assert out.reason == "host-not-allowed"
585 finally:
586 adapters.close()
587 with pytest.raises(TreeError, match="exactly one of click"):
588 load_tree_text(BROWSER_TREE.replace('{ click = "#more" }', '{ tap = "#more" }'))
589 with pytest.raises(TreeError, match="not in this tree's hosts"):
590 load_tree_text(BROWSER_TREE.replace('url = "{{ inputs.url }}"', 'url = "https://example.com/"'))