main
py 230 lines 9.07 KB
Raw
1 from pathlib import Path
2 import sys
3 from types import ModuleType
4
5 import helpers
6 from helpers import cache, files, ui_bundler
7
8
9 def configure_roots(
10 tmp_path: Path,
11 monkeypatch,
12 extension_root: Path,
13 plugin_root: Path,
14 ) -> None:
15 stored: dict[tuple[str, str], dict] = {}
16 monkeypatch.setattr(cache, "get", lambda area, key: stored.get((area, key)))
17 monkeypatch.setattr(
18 cache,
19 "add",
20 lambda area, key, value: stored.__setitem__((area, key), value),
21 )
22 monkeypatch.setattr(cache, "clear", lambda *_args, **_kwargs: stored.clear())
23 monkeypatch.setattr(
24 files,
25 "get_abs_path",
26 lambda *parts: str(tmp_path.joinpath(*parts)),
27 )
28 monkeypatch.setattr(
29 files,
30 "is_in_base_dir",
31 lambda path: Path(path).resolve().is_relative_to(tmp_path),
32 )
33 monkeypatch.setattr(
34 files,
35 "deabsolute_path",
36 lambda path: Path(path).resolve().relative_to(tmp_path).as_posix(),
37 )
38 subagents_module = ModuleType("helpers.subagents")
39 subagents_module.get_paths = lambda *_args, **_kwargs: [str(extension_root)]
40 plugins_module = ModuleType("helpers.plugins")
41 plugins_module.get_enabled_plugin_paths = lambda *_args, **_kwargs: [str(plugin_root)]
42 monkeypatch.setitem(sys.modules, "helpers.subagents", subagents_module)
43 monkeypatch.setitem(sys.modules, "helpers.plugins", plugins_module)
44 monkeypatch.setattr(helpers, "subagents", subagents_module, raising=False)
45 monkeypatch.setattr(helpers, "plugins", plugins_module, raising=False)
46
47
48 def test_bundle_discovers_assets_without_changing_frontend_loaders(
49 tmp_path: Path, monkeypatch
50 ) -> None:
51 webui = tmp_path / "webui"
52 components = webui / "components"
53 extension_root = tmp_path / "extensions" / "webui"
54 plugin_root = tmp_path / "plugins" / "example" / "webui"
55
56 assets = {
57 webui / "index.html": (
58 '<link rel="stylesheet" href="/index.css">'
59 '<script type="module" src="/index.js"></script>'
60 '<script type="module" src="/large.js"></script>'
61 ).encode(),
62 webui / "index.css": (
63 b'@import url("./theme.css");'
64 b'@font-face { src: url("/public/font.bin"); }'
65 ),
66 webui / "theme.css": b"body { color: black; }",
67 webui / "index.js": b'import "./js/module.js";',
68 webui / "large.js": (
69 b'import "./js/large-child.js";' + b" " * (512 * 1024)
70 ),
71 webui / "js" / "large-child.js": b"export const child = true;",
72 webui / "js" / "module.js": b'export { value } from "./value.js";',
73 webui / "js" / "value.js": b"export const value = 1;",
74 webui / "public" / "font.bin": b"\xff\x00\x81",
75 components / "root.html": (
76 '<x-component path="nested/child.html"></x-component>'
77 '<img src="/public/image.png">'
78 '<audio src="/public/audio.mp3"></audio>'
79 '<video src="/public/video.mp4"></video>'
80 ).encode(),
81 components / "nested" / "child.html": b"<div>child</div>",
82 webui / "public" / "image.png": b"image",
83 webui / "public" / "audio.mp3": b"audio",
84 webui / "public" / "video.mp4": b"video",
85 extension_root / "sidebar" / "entry.html": (
86 '<script type="module" src="/plugins/example/webui/feature.js"></script>'
87 ).encode(),
88 extension_root / "sidebar" / "entry.css": (
89 b'@import "/plugins/example/webui/feature.css";'
90 ),
91 plugin_root / "main.html": (
92 '<x-component path="/plugins/example/webui/nested/modal.html"></x-component>'
93 ).encode(),
94 plugin_root / "feature.js": b'import "./nested/helper.js";',
95 plugin_root / "feature.css": b'body { background: url("./icon.svg"); }',
96 plugin_root / "icon.svg": b"<svg></svg>",
97 plugin_root / "nested" / "helper.js": b"export default true;",
98 plugin_root / "nested" / "modal.html": b"<dialog>plugin</dialog>",
99 plugin_root / "nested" / "runtime-only.css": b"runtime-one",
100 }
101 for path, content in assets.items():
102 path.parent.mkdir(parents=True, exist_ok=True)
103 path.write_bytes(content)
104
105 configure_roots(tmp_path, monkeypatch, extension_root, plugin_root)
106 bundle = ui_bundler.get_ui_asset_bundle(["/index.html"], agent=None)
107
108 assert bundle["version"]
109 assert set(bundle["files"]) == {
110 "/extensions/webui/sidebar/entry.html",
111 "/index.css",
112 "/index.html",
113 "/index.js",
114 "/js/large-child.js",
115 "/js/module.js",
116 "/js/value.js",
117 "/plugins/example/webui/feature.js",
118 "/plugins/example/webui/nested/helper.js",
119 "/theme.css",
120 }
121 assert bundle["files"]["/index.js"][:2] == [
122 "text/javascript; charset=utf-8",
123 "text",
124 ]
125 assert "/large.js" not in bundle["files"]
126 assert "/js/large-child.js" in bundle["files"]
127 assert "/components/root.html" not in bundle["files"]
128 assert "/plugins/example/webui/main.html" not in bundle["files"]
129 assert "/public/font.bin" not in bundle["files"]
130 assert "/public/image.png" not in bundle["files"]
131 assert "/public/audio.mp3" not in bundle["files"]
132 assert "/public/video.mp4" not in bundle["files"]
133 assert "/plugins/example/webui/icon.svg" not in bundle["files"]
134 assert all(entry[1] == "text" for entry in bundle["files"].values())
135 assert all(
136 len(entry[2].encode("utf-8")) <= 512 * 1024
137 for entry in bundle["files"].values()
138 )
139
140 previous_version = bundle["version"]
141 assets[webui / "js" / "value.js"] = b"export const value = 22;"
142 (webui / "js" / "value.js").write_bytes(assets[webui / "js" / "value.js"])
143 cache.clear("ui_asset_bundle")
144 rebuilt = ui_bundler.get_ui_asset_bundle(["/index.html"], agent=None)
145 assert rebuilt["version"] != previous_version
146 assert rebuilt["files"]["/js/value.js"][2] == "export const value = 22;"
147
148 selected_version = rebuilt["version"]
149 runtime_only = plugin_root / "nested" / "runtime-only.css"
150 runtime_only.write_bytes(b"runtime-two-is-newer")
151 cache.clear("ui_asset_bundle")
152 runtime_rebuilt = ui_bundler.get_ui_asset_bundle(["/index.html"], agent=None)
153 assert runtime_rebuilt["version"] != selected_version
154 assert "/plugins/example/webui/nested/runtime-only.css" not in runtime_rebuilt["files"]
155
156
157 def test_bundle_excludes_symlink_escapes_and_serializes_json(
158 tmp_path: Path, monkeypatch
159 ) -> None:
160 webui = tmp_path / "webui"
161 component_root = webui / "components"
162 extension_root = tmp_path / "extensions" / "webui"
163 plugin_root = tmp_path / "plugins" / "example" / "webui"
164 for root in (component_root, extension_root, plugin_root):
165 root.mkdir(parents=True)
166 (webui / "index.html").write_text(
167 '<x-component path="safe.html"></x-component>'
168 '<x-component path="escape.html"></x-component>',
169 encoding="utf-8",
170 )
171 (component_root / "safe.html").write_text("</script>&", encoding="utf-8")
172 outside = tmp_path / "outside.html"
173 outside.write_text("outside", encoding="utf-8")
174 (component_root / "escape.html").symlink_to(outside)
175
176 configure_roots(tmp_path, monkeypatch, extension_root, plugin_root)
177 bundle = ui_bundler.get_ui_asset_bundle(["/index.html"], agent=None)
178 payload = ui_bundler.serialize_ui_asset_bundle(bundle)
179
180 assert "/components/safe.html" in payload
181 assert "/components/escape.html" not in payload
182 assert "</script>&" in payload
183
184
185 def test_bundle_recursively_scans_a_caller_supplied_entry(
186 tmp_path: Path, monkeypatch
187 ) -> None:
188 webui = tmp_path / "webui"
189 extension_root = tmp_path / "extensions" / "webui"
190 plugin_root = tmp_path / "plugins" / "example" / "webui"
191 assets = {
192 webui / "index.html": b"<main></main>",
193 webui / "components" / "ad-hoc.html": (
194 b'<script type="module" src="./ad-hoc.js"></script>'
195 b'<link rel="stylesheet" href="/css/ad-hoc.css">'
196 ),
197 webui / "components" / "ad-hoc.js": b'import "../js/shared.js";',
198 webui / "js" / "shared.js": b"export const shared = true;",
199 webui / "css" / "ad-hoc.css": b'@import "./shared.css";',
200 webui / "css" / "shared.css": b"body { color: black; }",
201 }
202 for path, content in assets.items():
203 path.parent.mkdir(parents=True, exist_ok=True)
204 path.write_bytes(content)
205 extension_root.mkdir(parents=True)
206 plugin_root.mkdir(parents=True)
207
208 configure_roots(tmp_path, monkeypatch, extension_root, plugin_root)
209 bundle = ui_bundler.get_ui_asset_bundle(
210 [
211 "/components/ad-hoc.html",
212 "/css/ad-hoc.css",
213 "/components/ad-hoc.html",
214 "https://example.com/remote.js",
215 ],
216 agent=None,
217 )
218
219 assert bundle["version"]
220 assert set(bundle["files"]) == {
221 "/components/ad-hoc.html",
222 "/components/ad-hoc.js",
223 "/css/ad-hoc.css",
224 "/css/shared.css",
225 "/js/shared.js",
226 }
227
228 index_bundle = ui_bundler.get_ui_asset_bundle(["/index.html"], agent=None)
229 assert set(index_bundle["files"]) == {"/index.html"}
230 assert index_bundle["version"] != bundle["version"]