urirun-service-android-node 0.1.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.
@@ -0,0 +1,135 @@
1
+ Metadata-Version: 2.4
2
+ Name: urirun-service-android-node
3
+ Version: 0.1.0
4
+ Summary: Android setup and webpage-node relay service for urirun on port 8195
5
+ Author: if-uri
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://connect.ifuri.com/services/android-node
8
+ Project-URL: Repository, https://github.com/if-uri/urirun-service-android-node
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: urirun[host]>=0.4.88
12
+ Requires-Dist: qrcode[pil]>=7
13
+ Provides-Extra: test
14
+ Requires-Dist: pytest>=8; extra == "test"
15
+
16
+ # urirun-service-android-node
17
+
18
+ Service on port `8195` for connecting Android devices and browser pages to a urirun host.
19
+
20
+ Two separate roles:
21
+
22
+ - **Android setup** — QR page, ADB instructions, Termux bootstrap, APK download.
23
+ - **Webpage node relay** — any browser page that opens the service URL registers as a `webpage` node and exposes `/health`, `/routes`, `/run` through the relay.
24
+
25
+ ## Start
26
+
27
+ ```bash
28
+ urirun-android-node serve --host 0.0.0.0 --port 8195
29
+ ```
30
+
31
+ Open the printed URL on a phone or another browser. To replace an old process on the same port:
32
+
33
+ ```bash
34
+ urirun-android-node restart --host 0.0.0.0 --port 8195 --force-replace
35
+ ```
36
+
37
+ ## APK download
38
+
39
+ The setup page (Path C) shows available APKs with a QR code for direct phone download.
40
+
41
+ ```
42
+ GET /apk/ — JSON list of available APKs
43
+ GET /apk/<file> — download APK
44
+ ```
45
+
46
+ Current APK:
47
+
48
+ | File | Arch | Android | Size |
49
+ |---|---|---|---|
50
+ | `urirunnode-0.3.0-arm64-v8a-debug.apk` | arm64-v8a (64-bit) | 12+ (API 31) | 18 MB |
51
+
52
+ The service checks these directories (first match wins per filename):
53
+
54
+ 1. `URIRUN_ANDROID_NODE_APK_DIRS` or `URIRUN_ANDROID_NODE_APK_DIR` (env override)
55
+ 2. `urirun-service-android-node/apk/` ← stable distribution location
56
+ 3. `urirun-android-node-app/bin/` ← Buildozer output
57
+ 4. `~/.urirun/android-node/apk/`
58
+
59
+ To build and publish a new APK:
60
+
61
+ ```bash
62
+ cd /home/tom/github/if-uri/urirun-android-node-app
63
+ URIRUN_ANDROID_BUILD_SKIP_IMAGE=1 make docker-apk
64
+ ```
65
+
66
+ `docker-apk` copies the result to `urirun-service-android-node/apk/` automatically.
67
+ The page reloads the list dynamically — no service restart needed.
68
+
69
+ ## Browser plugins
70
+
71
+ ```
72
+ GET /plugins — JSON list of available plugins
73
+ GET /plugins/chrome.zip — Chrome extension zip
74
+ GET /plugins/firefox.zip — Firefox extension zip
75
+ ```
76
+
77
+ Plugins are served when their source directories exist next to this repo:
78
+
79
+ ```bash
80
+ cd /home/tom/github/if-uri/chrome-plugin && make package
81
+ # Chrome: chrome://extensions → Developer mode → Load unpacked
82
+
83
+ cd /home/tom/github/if-uri/firefox-plugin && make package
84
+ # Firefox: about:debugging → Load Temporary Add-on → manifest.json
85
+ ```
86
+
87
+ ## Webpage node relay
88
+
89
+ Opening the setup page on a phone/browser auto-registers it as a `webpage` node.
90
+ The host can reach it immediately through the relay endpoint.
91
+
92
+ ```
93
+ GET /api/webpage-node/list
94
+ GET /api/webpage-node/relay/<id>/health
95
+ GET /api/webpage-node/relay/<id>/routes
96
+ POST /api/webpage-node/relay/<id>/run
97
+ POST /api/webpage-node/register
98
+ POST /api/webpage-node/forget
99
+ ```
100
+
101
+ Routes exposed per device (replace `{id}` with the assigned device id):
102
+
103
+ ```
104
+ webpage://{id}/page/query/info
105
+ webpage://{id}/page/query/text
106
+ webpage://{id}/page/query/devices
107
+ webpage://{id}/page/command/navigate
108
+ webpage://{id}/page/command/eval
109
+ webpage://{id}/camera/query/status
110
+ webpage://{id}/camera/command/start
111
+ webpage://{id}/sensor/query/capabilities
112
+ webpage://{id}/user/query/activity
113
+ webpage://{id}/iframe/command/open
114
+ ```
115
+
116
+ Legacy `/api/web-node/...` endpoints are accepted as aliases.
117
+
118
+ ## All endpoints
119
+
120
+ ```
121
+ GET / setup page (HTML)
122
+ GET /qr.png QR code for this page (or ?url=... for arbitrary URL)
123
+ GET /bootstrap.sh Termux bootstrap script
124
+ GET /apk/ APK list (JSON)
125
+ GET /apk/<file> APK download
126
+ GET /plugins browser plugin list (JSON)
127
+ GET /plugins/chrome.zip Chrome extension
128
+ GET /plugins/firefox.zip Firefox extension
129
+ GET /api/status ADB devices + service info
130
+ POST /api/connect register an Android node by IP:port
131
+ GET /api/webpage-node/list list connected webpage nodes
132
+ GET /api/webpage-node/relay/<id>/health
133
+ GET /api/webpage-node/relay/<id>/routes
134
+ POST /api/webpage-node/relay/<id>/run
135
+ ```
@@ -0,0 +1,120 @@
1
+ # urirun-service-android-node
2
+
3
+ Service on port `8195` for connecting Android devices and browser pages to a urirun host.
4
+
5
+ Two separate roles:
6
+
7
+ - **Android setup** — QR page, ADB instructions, Termux bootstrap, APK download.
8
+ - **Webpage node relay** — any browser page that opens the service URL registers as a `webpage` node and exposes `/health`, `/routes`, `/run` through the relay.
9
+
10
+ ## Start
11
+
12
+ ```bash
13
+ urirun-android-node serve --host 0.0.0.0 --port 8195
14
+ ```
15
+
16
+ Open the printed URL on a phone or another browser. To replace an old process on the same port:
17
+
18
+ ```bash
19
+ urirun-android-node restart --host 0.0.0.0 --port 8195 --force-replace
20
+ ```
21
+
22
+ ## APK download
23
+
24
+ The setup page (Path C) shows available APKs with a QR code for direct phone download.
25
+
26
+ ```
27
+ GET /apk/ — JSON list of available APKs
28
+ GET /apk/<file> — download APK
29
+ ```
30
+
31
+ Current APK:
32
+
33
+ | File | Arch | Android | Size |
34
+ |---|---|---|---|
35
+ | `urirunnode-0.3.0-arm64-v8a-debug.apk` | arm64-v8a (64-bit) | 12+ (API 31) | 18 MB |
36
+
37
+ The service checks these directories (first match wins per filename):
38
+
39
+ 1. `URIRUN_ANDROID_NODE_APK_DIRS` or `URIRUN_ANDROID_NODE_APK_DIR` (env override)
40
+ 2. `urirun-service-android-node/apk/` ← stable distribution location
41
+ 3. `urirun-android-node-app/bin/` ← Buildozer output
42
+ 4. `~/.urirun/android-node/apk/`
43
+
44
+ To build and publish a new APK:
45
+
46
+ ```bash
47
+ cd /home/tom/github/if-uri/urirun-android-node-app
48
+ URIRUN_ANDROID_BUILD_SKIP_IMAGE=1 make docker-apk
49
+ ```
50
+
51
+ `docker-apk` copies the result to `urirun-service-android-node/apk/` automatically.
52
+ The page reloads the list dynamically — no service restart needed.
53
+
54
+ ## Browser plugins
55
+
56
+ ```
57
+ GET /plugins — JSON list of available plugins
58
+ GET /plugins/chrome.zip — Chrome extension zip
59
+ GET /plugins/firefox.zip — Firefox extension zip
60
+ ```
61
+
62
+ Plugins are served when their source directories exist next to this repo:
63
+
64
+ ```bash
65
+ cd /home/tom/github/if-uri/chrome-plugin && make package
66
+ # Chrome: chrome://extensions → Developer mode → Load unpacked
67
+
68
+ cd /home/tom/github/if-uri/firefox-plugin && make package
69
+ # Firefox: about:debugging → Load Temporary Add-on → manifest.json
70
+ ```
71
+
72
+ ## Webpage node relay
73
+
74
+ Opening the setup page on a phone/browser auto-registers it as a `webpage` node.
75
+ The host can reach it immediately through the relay endpoint.
76
+
77
+ ```
78
+ GET /api/webpage-node/list
79
+ GET /api/webpage-node/relay/<id>/health
80
+ GET /api/webpage-node/relay/<id>/routes
81
+ POST /api/webpage-node/relay/<id>/run
82
+ POST /api/webpage-node/register
83
+ POST /api/webpage-node/forget
84
+ ```
85
+
86
+ Routes exposed per device (replace `{id}` with the assigned device id):
87
+
88
+ ```
89
+ webpage://{id}/page/query/info
90
+ webpage://{id}/page/query/text
91
+ webpage://{id}/page/query/devices
92
+ webpage://{id}/page/command/navigate
93
+ webpage://{id}/page/command/eval
94
+ webpage://{id}/camera/query/status
95
+ webpage://{id}/camera/command/start
96
+ webpage://{id}/sensor/query/capabilities
97
+ webpage://{id}/user/query/activity
98
+ webpage://{id}/iframe/command/open
99
+ ```
100
+
101
+ Legacy `/api/web-node/...` endpoints are accepted as aliases.
102
+
103
+ ## All endpoints
104
+
105
+ ```
106
+ GET / setup page (HTML)
107
+ GET /qr.png QR code for this page (or ?url=... for arbitrary URL)
108
+ GET /bootstrap.sh Termux bootstrap script
109
+ GET /apk/ APK list (JSON)
110
+ GET /apk/<file> APK download
111
+ GET /plugins browser plugin list (JSON)
112
+ GET /plugins/chrome.zip Chrome extension
113
+ GET /plugins/firefox.zip Firefox extension
114
+ GET /api/status ADB devices + service info
115
+ POST /api/connect register an Android node by IP:port
116
+ GET /api/webpage-node/list list connected webpage nodes
117
+ GET /api/webpage-node/relay/<id>/health
118
+ GET /api/webpage-node/relay/<id>/routes
119
+ POST /api/webpage-node/relay/<id>/run
120
+ ```
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "urirun-service-android-node"
7
+ version = "0.1.0"
8
+ description = "Android setup and webpage-node relay service for urirun on port 8195"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "Apache-2.0"
12
+ authors = [{ name = "if-uri" }]
13
+ dependencies = ["urirun[host]>=0.4.88", "qrcode[pil]>=7"]
14
+
15
+ [project.optional-dependencies]
16
+ test = ["pytest>=8"]
17
+
18
+ [project.urls]
19
+ Homepage = "https://connect.ifuri.com/services/android-node"
20
+ Repository = "https://github.com/if-uri/urirun-service-android-node"
21
+
22
+ [project.scripts]
23
+ urirun-service-android-node = "urirun_service_android_node.core:main"
24
+ urirun-android-node = "urirun_service_android_node.core:main"
25
+
26
+ [project.entry-points."urirun.services"]
27
+ android-node = "urirun_service_android_node.core:urirun_service"
28
+
29
+ [tool.setuptools.packages.find]
30
+ where = ["."]
31
+ include = ["urirun_service_android_node*"]
32
+
33
+ [tool.setuptools.package-data]
34
+ urirun_service_android_node = ["service.manifest.json", "templates/*.html"]
35
+
36
+ [tool.wellmanifest]
37
+ standard = "0.20.35"
38
+ revision = "cfaa0bf0ea6b0e7349fed0bb62b5ce15792d687d"
39
+ gate = "project/governance-check.sh"
40
+
41
+ [tool.pytest.ini_options]
42
+ addopts = "-p wellmanifest_governance"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,355 @@
1
+ # Author: Tom Sapletta · https://tom.sapletta.com
2
+ # Part of the ifURI solution.
3
+
4
+ from __future__ import annotations
5
+
6
+ import io
7
+ import json
8
+ import pathlib
9
+ import threading
10
+ from http.server import ThreadingHTTPServer
11
+ from urllib.request import urlopen
12
+ from urllib.error import URLError
13
+
14
+ import pytest
15
+
16
+ import urirun_service_android_node.core as core
17
+ from urirun_service_android_node import DEFAULT_PORT, SERVICE_ID, service_manifest
18
+
19
+
20
+ def test_service_manifest_declares_port() -> None:
21
+ m = service_manifest()
22
+ assert m["id"] == SERVICE_ID
23
+ assert m["id"] == "android-node"
24
+ assert m["defaultPort"] == 8195
25
+
26
+
27
+ def test_service_manifest_has_required_fields() -> None:
28
+ m = service_manifest()
29
+ assert m["kind"] == "service"
30
+ assert "/api/status" in m["http"]["api"]
31
+ assert "/api/host-services" in m["http"]["api"]
32
+ assert "/bootstrap.sh" in m["http"]["api"]
33
+ assert "/apk/" in m["http"]["api"]
34
+ assert "/plugins/chrome.zip" in m["http"]["api"]
35
+
36
+
37
+ def test_lan_ip_returns_string() -> None:
38
+ ip = core._lan_ip()
39
+ assert isinstance(ip, str)
40
+ assert "." in ip # IPv4 dotted notation
41
+
42
+
43
+ def test_adb_devices_no_adb(monkeypatch) -> None:
44
+ monkeypatch.setattr(core.shutil, "which", lambda _: None)
45
+ devices = core._adb_devices()
46
+ assert devices == []
47
+
48
+
49
+ def test_qr_bytes_writes_and_reads(monkeypatch, tmp_path) -> None:
50
+ written: list = []
51
+
52
+ def fake_write(url, path):
53
+ path.parent.mkdir(parents=True, exist_ok=True)
54
+ path.write_bytes(b"PNG_FAKE")
55
+ written.append(url)
56
+
57
+ monkeypatch.setattr(core, "_write_qr_png", fake_write)
58
+ monkeypatch.setattr(core, "_QR_CACHE", tmp_path / "qr.png")
59
+ result = core._qr_bytes("http://192.168.1.1:8195/")
60
+ assert result == b"PNG_FAKE"
61
+ assert written == ["http://192.168.1.1:8195/"]
62
+
63
+
64
+ def _start_test_server(tmp_path) -> tuple[ThreadingHTTPServer, int, str]:
65
+ """Start the service on a free port, return (server, port, base_url)."""
66
+ # Write a fake bootstrap script
67
+ bs = tmp_path / "bootstrap.sh"
68
+ bs.write_text("#!/bin/bash\necho hello\n")
69
+ server = core.serve(host="127.0.0.1", port=0,
70
+ bootstrap_script=str(bs))
71
+ port = server.server_address[1]
72
+ t = threading.Thread(target=server.serve_forever, daemon=True)
73
+ t.start()
74
+ return server, port, f"http://127.0.0.1:{port}"
75
+
76
+
77
+ def test_api_status_endpoint(monkeypatch, tmp_path) -> None:
78
+ monkeypatch.setattr(core.shutil, "which", lambda _: None) # no adb
79
+ server, port, base = _start_test_server(tmp_path)
80
+ try:
81
+ with urlopen(f"{base}/api/status") as resp:
82
+ data = json.loads(resp.read())
83
+ assert data["ok"] is True
84
+ assert data["adb"] is False
85
+ assert isinstance(data["devices"], list)
86
+ finally:
87
+ server.shutdown()
88
+
89
+
90
+ def test_bootstrap_sh_endpoint(tmp_path) -> None:
91
+ server, port, base = _start_test_server(tmp_path)
92
+ try:
93
+ with urlopen(f"{base}/bootstrap.sh") as resp:
94
+ body = resp.read().decode()
95
+ assert "hello" in body
96
+ finally:
97
+ server.shutdown()
98
+
99
+
100
+ def test_qr_png_endpoint(monkeypatch, tmp_path) -> None:
101
+ monkeypatch.setattr(core, "_QR_CACHE", tmp_path / "qr.png")
102
+
103
+ def fake_write(url, path):
104
+ path.parent.mkdir(parents=True, exist_ok=True)
105
+ path.write_bytes(b"\x89PNG_FAKE_DATA")
106
+
107
+ monkeypatch.setattr(core, "_write_qr_png", fake_write)
108
+ server, port, base = _start_test_server(tmp_path)
109
+ try:
110
+ with urlopen(f"{base}/qr.png") as resp:
111
+ assert resp.headers["Content-Type"] == "image/png"
112
+ body = resp.read()
113
+ assert body == b"\x89PNG_FAKE_DATA"
114
+ finally:
115
+ server.shutdown()
116
+
117
+
118
+ def test_root_page_html(monkeypatch, tmp_path) -> None:
119
+ server, port, base = _start_test_server(tmp_path)
120
+ try:
121
+ with urlopen(f"{base}/") as resp:
122
+ body = resp.read().decode()
123
+ assert "urirun Android Node" in body
124
+ assert "bootstrap.sh" in body
125
+ assert "Path C — Install APK" in body
126
+ assert "Path E — Host services" in body
127
+ assert "/api/host-services" in body
128
+ finally:
129
+ server.shutdown()
130
+
131
+
132
+ def test_host_services_default_to_same_lan_host_and_dashboard_port(monkeypatch) -> None:
133
+ monkeypatch.delenv("URIRUN_DASHBOARD_PUBLIC_URL", raising=False)
134
+ monkeypatch.delenv("URIRUN_DASHBOARD_BASE", raising=False)
135
+ monkeypatch.delenv("URIRUN_DASHBOARD_PUBLIC_HOST", raising=False)
136
+ monkeypatch.delenv("URIRUN_DASHBOARD_PORT", raising=False)
137
+ monkeypatch.setattr(core, "_host_phone_scanner_services", lambda _base: [])
138
+ data = core.host_services("http://192.168.188.212:8195/")
139
+ assert data["dashboardBase"] == "http://192.168.188.212:8194"
140
+ scanner = data["services"][0]
141
+ assert scanner["id"] == "service:phone-scanner"
142
+ assert scanner["target"] == "service:phone-scanner"
143
+ assert scanner["url"].startswith("http://192.168.188.212:8194/scanner?")
144
+ assert "autostart=1" in scanner["url"]
145
+ assert "minScore=45" in scanner["url"]
146
+
147
+
148
+ def test_host_services_honor_dashboard_public_url(monkeypatch) -> None:
149
+ monkeypatch.setenv("URIRUN_DASHBOARD_PUBLIC_URL", "https://dash.example.test:9443/base/")
150
+ monkeypatch.setattr(core, "_host_phone_scanner_services", lambda _base: [])
151
+ data = core.host_services("http://192.168.188.212:8195/")
152
+ assert data["dashboardBase"] == "https://dash.example.test:9443/base"
153
+ assert data["services"][0]["url"].startswith("https://dash.example.test:9443/base/scanner?")
154
+
155
+
156
+ def test_host_services_prefers_host_dashboard_summary(monkeypatch) -> None:
157
+ monkeypatch.setenv("URIRUN_DASHBOARD_PUBLIC_URL", "http://dash.example.test:8194")
158
+
159
+ def fake_summary(base, path, timeout=1.5):
160
+ assert base == "http://dash.example.test:8194"
161
+ assert path == "/api/summary"
162
+ return {"services": [
163
+ {"id": "service:phone-scanner", "url": "https://scanner.example.test:9443/scanner?count=2",
164
+ "label": "Host registry scanner", "routes": ["scanner://page/camera/command/autonomous"]},
165
+ ]}
166
+
167
+ monkeypatch.setattr(core, "_host_dashboard_json", fake_summary)
168
+ data = core.host_services("http://192.168.188.212:8195/")
169
+ scanner = data["services"][0]
170
+ assert scanner["url"] == "https://scanner.example.test:9443/scanner?count=2"
171
+ assert scanner["label"] == "Host registry scanner"
172
+ assert scanner["source"] == "host-dashboard-summary"
173
+
174
+
175
+ def test_host_services_endpoint(monkeypatch, tmp_path) -> None:
176
+ monkeypatch.delenv("URIRUN_DASHBOARD_PUBLIC_URL", raising=False)
177
+ monkeypatch.delenv("URIRUN_DASHBOARD_BASE", raising=False)
178
+ monkeypatch.delenv("URIRUN_DASHBOARD_PUBLIC_HOST", raising=False)
179
+ monkeypatch.delenv("URIRUN_DASHBOARD_SCHEME", raising=False)
180
+ monkeypatch.delenv("URIRUN_DASHBOARD_PORT", raising=False)
181
+ monkeypatch.setattr(core, "_host_phone_scanner_services", lambda _base: [])
182
+ core._WEB_NODES.clear()
183
+ server, port, base = _start_test_server(tmp_path)
184
+ try:
185
+ with urlopen(f"{base}/api/host-services") as resp:
186
+ data = json.loads(resp.read())
187
+ assert data["ok"] is True
188
+ assert data["services"][0]["id"] == "service:phone-scanner"
189
+ assert f"http://127.0.0.1:8194/scanner?" in data["services"][0]["url"]
190
+ finally:
191
+ server.shutdown()
192
+
193
+
194
+ def test_apk_list_reports_dirs_when_empty(monkeypatch, tmp_path) -> None:
195
+ monkeypatch.setattr(core, "_apk_dirs", lambda: [tmp_path / "apk"])
196
+ assert core._list_apks() == []
197
+
198
+
199
+ def test_apk_endpoint_serves_found_apk(monkeypatch, tmp_path) -> None:
200
+ apk_dir = tmp_path / "bin"
201
+ apk_dir.mkdir()
202
+ apk = apk_dir / "urirun test.apk"
203
+ apk.write_bytes(b"APK")
204
+ monkeypatch.setattr(core, "_apk_dirs", lambda: [apk_dir])
205
+ server, port, base = _start_test_server(tmp_path)
206
+ try:
207
+ with urlopen(f"{base}/apk/") as resp:
208
+ listing = json.loads(resp.read())
209
+ assert listing["count"] == 1
210
+ assert listing["apks"][0]["name"] == "urirun test.apk"
211
+ assert listing["apks"][0]["url"] == "/apk/urirun%20test.apk"
212
+
213
+ with urlopen(f"{base}/apk/urirun%20test.apk") as resp:
214
+ assert resp.headers["Content-Type"] == "application/vnd.android.package-archive"
215
+ assert resp.read() == b"APK"
216
+ finally:
217
+ server.shutdown()
218
+
219
+
220
+ def test_main_manifest_prints_json(capsys) -> None:
221
+ from urirun_service_android_node.core import main
222
+ rc = main(["manifest"])
223
+ assert rc == 0
224
+ out = capsys.readouterr().out
225
+ data = json.loads(out)
226
+ assert data["id"] == "android-node"
227
+ assert data["defaultPort"] == 8195
228
+
229
+
230
+ def test_web_node_register_and_list() -> None:
231
+ core._WEB_NODES.clear()
232
+ r = core.web_node_register(
233
+ {"userAgent": "Mozilla/5.0 (Linux; Android 8.1; Nexus 7)"},
234
+ service_url="http://192.168.1.1:8195/",
235
+ client_ip="192.168.1.55")
236
+ assert r["ok"] is True
237
+ assert r["platform"] == "android"
238
+ assert r["nodeUrl"].endswith(f"/api/webpage-node/relay/{r['id']}")
239
+ assert r["relayUrl"] == r["nodeUrl"]
240
+ assert r["clientIp"] == "192.168.1.55"
241
+ assert r["clientUrl"] == "http://192.168.1.55"
242
+ assert f"webpage://{r['id']}/page/query/info" in r["routes"]
243
+ listing = core.web_node_list()
244
+ assert listing["count"] == 1
245
+ assert listing["devices"][0]["online"] is True
246
+ assert listing["devices"][0]["clientIp"] == "192.168.1.55"
247
+ assert listing["devices"][0]["relayUrl"].endswith(f"/api/webpage-node/relay/{r['id']}")
248
+ assert listing["devices"][0]["capabilities"] == ["webpage", "dom", "camera", "sensor", "iframe", "user-activity"]
249
+
250
+
251
+ def test_web_node_action_queue_roundtrip() -> None:
252
+ core._WEB_NODES.clear()
253
+ dev = core.web_node_register({"userAgent": "Chrome"}, service_url="http://h:8195/")["id"]
254
+ core.web_node_enqueue(dev, {"id": "a1", "type": "navigate", "url": "https://example.com"})
255
+ polled = core.web_node_poll(dev)
256
+ assert polled["actions"] == [{"id": "a1", "type": "navigate", "url": "https://example.com"}]
257
+ # queue drains after poll
258
+ assert core.web_node_poll(dev)["actions"] == []
259
+ # result is accepted
260
+ assert core.web_node_result(dev, {"id": dev, "actionId": "a1", "result": {"ok": 1}})["ok"] is True
261
+
262
+
263
+ def test_web_node_poll_unknown_device() -> None:
264
+ core._WEB_NODES.clear()
265
+ assert core.web_node_poll("nope")["ok"] is False
266
+
267
+
268
+ def test_page_embeds_web_node_client() -> None:
269
+ from urirun_service_android_node.page import SETUP_HTML
270
+ assert "/api/webpage-node/register" in SETUP_HTML
271
+ assert "urirun-webnode-id" in SETUP_HTML
272
+ assert "webpage-route-list" in SETUP_HTML
273
+
274
+
275
+ def test_webpage_relay_health_routes_and_timeout() -> None:
276
+ core._WEB_NODES.clear()
277
+ dev = core.web_node_register({"userAgent": "Chrome"}, service_url="http://h:8195/")["id"]
278
+
279
+ health = core.web_node_health(dev, service_url="http://h:8195/")
280
+ assert health["ok"] is True
281
+ assert health["nodeType"] == "webpage"
282
+ assert health["routeCount"] == len(core.WEBPAGE_ROUTES)
283
+
284
+ routes = core.web_node_routes(dev)
285
+ assert routes["ok"] is True
286
+ assert f"webpage://{dev}/camera/command/start" in {route["uri"] for route in routes["routes"]}
287
+
288
+ result = core.web_node_run(dev, {
289
+ "uri": f"webpage://{dev}/page/query/info",
290
+ "payload": {},
291
+ }, timeout=0.01)
292
+ assert result["ok"] is False
293
+ assert result["queued"] is True
294
+
295
+
296
+ def test_web_node_forget_by_id_and_name() -> None:
297
+ core._WEB_NODES.clear()
298
+ reg = core.web_node_register({"userAgent": "Chrome"}, service_url="http://h:8195/")
299
+ dev_id = reg["id"]
300
+ # forget by id
301
+ assert core.web_node_forget(dev_id)["forgot"] == dev_id
302
+ assert core.web_node_list()["count"] == 0
303
+ # forget by name
304
+ reg2 = core.web_node_register({"userAgent": "Android"}, service_url="http://h:8195/")
305
+ assert core.web_node_forget(reg2["name"])["forgot"] == reg2["id"]
306
+ assert core.web_node_list()["count"] == 0
307
+ # forgetting an unknown id is a graceful no-op
308
+ assert core.web_node_forget("nope")["ok"] is True
309
+
310
+
311
+ def test_relay_bare_url_returns_node_health(tmp_path):
312
+ """The bare relay URL (nodeUrl with no /health|/routes|/run suffix) is the node root and
313
+ must return health, not 404 — host mesh and users hit <nodeUrl> directly."""
314
+ core._WEB_NODES.clear()
315
+ server, port, base = _start_test_server(tmp_path)
316
+ try:
317
+ import urllib.request
318
+ # register a webpage node on this live test server
319
+ req = urllib.request.Request(f"{base}/api/webpage-node/register",
320
+ data=b'{"userAgent":"Android Chrome"}',
321
+ headers={"Content-Type": "application/json"}, method="POST")
322
+ reg = json.loads(urllib.request.urlopen(req, timeout=3).read())
323
+ dev = reg["id"]
324
+ # bare relay URL → health (was 404 before the fix)
325
+ bare = json.loads(urllib.request.urlopen(f"{base}/api/webpage-node/relay/{dev}", timeout=3).read())
326
+ assert bare["ok"] is True
327
+ assert bare["kind"] == "node"
328
+ assert bare["nodeType"] == "webpage"
329
+ # /health still works too
330
+ health = json.loads(urllib.request.urlopen(f"{base}/api/webpage-node/relay/{dev}/health", timeout=3).read())
331
+ assert health["ok"] is True
332
+ finally:
333
+ server.shutdown()
334
+
335
+
336
+ def test_relay_bare_url_post_dispatches_run(tmp_path):
337
+ """POST to the bare relay URL (the node's url, no /run suffix) must dispatch a URI via run,
338
+ not 404 — the host mesh POSTs {uri, payload} to <node_url> directly."""
339
+ core._WEB_NODES.clear()
340
+ server, port, base = _start_test_server(tmp_path)
341
+ try:
342
+ import urllib.request
343
+ reg = urllib.request.Request(f"{base}/api/webpage-node/register",
344
+ data=b'{"userAgent":"Android"}',
345
+ headers={"Content-Type": "application/json"}, method="POST")
346
+ dev = json.loads(urllib.request.urlopen(reg, timeout=3).read())["id"]
347
+ # POST bare relay url with a uri; no page poller → run times out (queued) but NOT 404.
348
+ body = json.dumps({"uri": f"webpage://{dev}/page/query/info", "payload": {}}).encode()
349
+ req = urllib.request.Request(f"{base}/api/webpage-node/relay/{dev}", data=body,
350
+ headers={"Content-Type": "application/json"}, method="POST")
351
+ r = json.loads(urllib.request.urlopen(req, timeout=12).read())
352
+ assert "not found" not in str(r.get("error", ""))
353
+ assert r.get("queued") is True or r.get("ok") is True # routed to run
354
+ finally:
355
+ server.shutdown()
@@ -0,0 +1,3 @@
1
+ from .core import DEFAULT_PORT, SERVICE_ID, main, serve, service_manifest, urirun_service
2
+
3
+ __all__ = ["DEFAULT_PORT", "SERVICE_ID", "main", "serve", "service_manifest", "urirun_service"]