VoxIP Kiosk OS 1.0.0: Raspberry Pi kiosk with web management
CI / test-bookworm (push) Failing after 11s
CI / test-latest (push) Successful in 1m27s

Chromium kiosk without a desktop for the Raspberry Pi 3, a Flask web
interface on port 8080 (kiosk, system and network management), a
restricted root helper, network changes with automatic rollback, a
healthcheck timer, install/update/uninstall scripts and a pi-gen image
build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rothmanandClaude Opus 5.5 committed 2026-11-04 21:38:49 +01:00
commit a33c4822bf
96 files changed
+7535

No files matched your search

+171
View File
@@ -0,0 +1,171 @@
import json
import pytest
from voxip_kiosk.agent import Agent, HUNG_RELOAD_AFTER, HUNG_RESTART_AFTER
from voxip_kiosk.cdp import CDPError
from voxip_kiosk.config import save_config
class Clock:
def __init__(self):
self.t = 1000.0
def __call__(self):
return self.t
class BrowserStub:
def __init__(self, url):
self.url = url
self.navigations = []
self.reloads = 0
self.closed = False
self.responsive_answer = True
self.down = False
def main_page(self):
if self.down:
raise CDPError("down")
return {"url": self.url, "webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/page/1"}
def new_page(self, url):
self.url = url
def navigate(self, url):
self.navigations.append(url)
self.url = url
def reload(self, ignore_cache=False):
self.reloads += 1
def responsive(self, ws=None, timeout=5):
return self.responsive_answer
def close_browser(self):
self.closed = True
class Probe:
def __init__(self):
self.ok = True
self.reason = ""
self.calls = 0
def __call__(self, url):
self.calls += 1
return self.ok, self.reason
@pytest.fixture
def env(paths):
paths.admin_file.write_text("{}") # setup finished
clock = Clock()
probe = Probe()
start = f"file://{paths.www_dir}/start.html?next=x"
browser = BrowserStub(start)
agent = Agent(paths, cdp=browser, probe_fn=probe, clock=clock, wall=clock, hostname=lambda: "kiosk",
addresses=lambda: {"eth0": ["192.168.1.20/24"], "wlan0": ["fe80::1/64"]})
return agent, browser, probe, clock, paths
def test_navigates_from_start_page_when_reachable(env):
agent, browser, probe, clock, paths = env
agent.tick()
assert browser.navigations == ["https://schermen-ijsselflat.voxip.nl"]
state = json.loads(paths.agent_state_file.read_text())
assert state["page"] == "start" and state["target_reachable"] is True
def test_offline_page_when_unreachable_then_recovers(env):
agent, browser, probe, clock, _ = env
probe.ok, probe.reason = False, "Naam niet gevonden (DNS)"
agent.tick()
assert "offline.html" in browser.url and "reason=Naam+niet+gevonden" in browser.url
navs = len(browser.navigations)
clock.t += 5
agent.tick() # still offline: no re-navigation (probe cached / page already offline)
assert len(browser.navigations) == navs
probe.ok = True
clock.t += 11
agent.tick()
assert browser.url == "https://schermen-ijsselflat.voxip.nl"
def test_chromium_error_page_is_replaced(env):
agent, browser, probe, clock, _ = env
browser.url = "chrome-error://chromewebdata/"
probe.ok = False
agent.tick()
assert "offline.html" in browser.url
def test_running_page_is_left_alone_when_network_drops(env):
"""Pages with their own offline cache (e.g. InfoFlow displays) must keep running."""
agent, browser, probe, clock, _ = env
browser.url = "https://schermen-ijsselflat.voxip.nl"
agent.tick()
probe.ok = False
for _ in range(20):
clock.t += 61
agent.tick()
assert browser.navigations == []
assert browser.reloads == 0
def test_url_change_navigates_without_restart(env):
agent, browser, probe, clock, paths = env
browser.url = "https://schermen-ijsselflat.voxip.nl"
agent.tick()
save_config(paths.config_file, {"url": "https://nieuw.example.nl/scherm"})
agent.tick()
assert browser.navigations == ["https://nieuw.example.nl/scherm"]
assert not browser.closed
def test_refresh_interval_skips_when_unreachable(env):
agent, browser, probe, clock, paths = env
save_config(paths.config_file, {"refresh_interval": 60})
browser.url = "https://schermen-ijsselflat.voxip.nl"
agent.tick()
clock.t += 61
agent.tick()
assert browser.reloads == 1
probe.ok = False
clock.t += 61
agent.tick()
assert browser.reloads == 1
assert "overgeslagen" in agent.state["last_error"]["message"]
def test_hung_renderer_reload_then_browser_restart(env):
agent, browser, probe, clock, _ = env
browser.url = "https://schermen-ijsselflat.voxip.nl"
browser.responsive_answer = False
for _ in range(HUNG_RELOAD_AFTER):
agent.tick()
assert browser.reloads == 1 and not browser.closed
for _ in range(HUNG_RESTART_AFTER - HUNG_RELOAD_AFTER):
agent.tick()
assert browser.closed
def test_setup_page_until_password_exists(env):
agent, browser, probe, clock, paths = env
paths.admin_file.unlink()
paths.setup_code_file.write_text("ABCD-EFGH\n")
agent.tick()
assert "setup.html" in browser.url
assert "code=ABCD-EFGH" in browser.url and "ips=192.168.1.20" in browser.url and "port=8080" in browser.url
agent.tick()
assert len(browser.navigations) == 1 # no flicker: setup page is not reloaded every tick
paths.admin_file.write_text("{}")
agent.tick()
assert browser.url == "https://schermen-ijsselflat.voxip.nl"
def test_devtools_down_is_reported(env):
agent, browser, probe, clock, paths = env
browser.down = True
agent.tick()
assert json.loads(paths.agent_state_file.read_text())["browser"] == "unreachable"