lean-browser-use 0.0.0-stage → 0.1.0

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.
Files changed (76) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/CREDITS.md +18 -0
  3. package/LICENSE +21 -0
  4. package/README.md +159 -2
  5. package/agents/aun-browser-runner.md +48 -0
  6. package/bin/lean-browser-use.mjs +3 -0
  7. package/briefs/browser-read.md +63 -0
  8. package/briefs/helper-read.md +63 -0
  9. package/docs/PROVENANCE.md +32 -0
  10. package/docs/benchmark.md +91 -0
  11. package/docs/guards.md +54 -0
  12. package/docs/how-it-works.md +27 -0
  13. package/docs/install.md +78 -0
  14. package/docs/measure.md +53 -0
  15. package/docs/reader.md +39 -0
  16. package/docs/search.md +16 -0
  17. package/hooks/batch-nudge.mjs +14 -0
  18. package/hooks/batch-nudge.sh +8 -0
  19. package/hooks/common.mjs +71 -0
  20. package/hooks/hooks.settings.json +58 -0
  21. package/hooks/pick-guard.mjs +27 -0
  22. package/hooks/pick-guard.sh +8 -0
  23. package/hooks/retry-guard.mjs +69 -0
  24. package/hooks/retry-guard.sh +8 -0
  25. package/llms.txt +40 -0
  26. package/package.json +61 -4
  27. package/proof/benchmark.json +166 -0
  28. package/proof/results.json +241 -0
  29. package/reader/README.md +48 -0
  30. package/reader/crawl_helper.py +256 -0
  31. package/reader/reader +14 -0
  32. package/reader/reader_base.py +106 -0
  33. package/reader/reader_compile.py +106 -0
  34. package/reader/reader_crawl.py +67 -0
  35. package/reader/reader_extract.py +261 -0
  36. package/reader/reader_fetch.py +148 -0
  37. package/reader/reader_runtime.py +199 -0
  38. package/reader/reader_test_harness.py +173 -0
  39. package/reader/reader_test_support.py +214 -0
  40. package/reader/specs/claude-pricing.json +223 -0
  41. package/reader/specs/npm-package.json +67 -0
  42. package/reader/test_reader.py +345 -0
  43. package/reader/test_reader_1.py +309 -0
  44. package/reader/test_reader_2.py +348 -0
  45. package/reader/test_reader_3.py +254 -0
  46. package/reader/test_reader_contract.py +57 -0
  47. package/reader/testdata/claude-pricing.html +4 -0
  48. package/reader/testdata/compile_stub.py +24 -0
  49. package/reader/testdata/npm-react.html +4 -0
  50. package/reader/testdata/npm-schema.json +14 -0
  51. package/reader/testdata/npm-zod.html +4 -0
  52. package/reader/testdata/pricing-schema.json +24 -0
  53. package/searxng/README.md +29 -0
  54. package/searxng/compose.yml +21 -0
  55. package/searxng/core-config/settings.yml +26 -0
  56. package/searxng/deploy.sh +36 -0
  57. package/searxng/searx +73 -0
  58. package/searxng/test_searx.py +107 -0
  59. package/searxng/test_searx_offline.py +67 -0
  60. package/skills/lean-browser-use/SKILL.md +35 -0
  61. package/skills/lean-browser-use/references/cleanup.md +17 -0
  62. package/skills/lean-browser-use/references/cost-model.md +11 -0
  63. package/skills/lean-browser-use/references/draft-offline.md +9 -0
  64. package/skills/lean-browser-use/references/failure-modes.md +16 -0
  65. package/skills/lean-browser-use/references/logged-in-run.md +23 -0
  66. package/skills/lean-browser-use/references/packet-principle.md +15 -0
  67. package/skills/lean-browser-use/references/route-by-job.md +18 -0
  68. package/skills/lean-browser-use/references/runner-rules.md +27 -0
  69. package/skills/lean-browser-use/references/verify.md +9 -0
  70. package/src/cli.mjs +42 -0
  71. package/src/doctor.mjs +60 -0
  72. package/src/install.mjs +116 -0
  73. package/src/measure.mjs +64 -0
  74. package/src/paths.mjs +51 -0
  75. package/src/uninstall.mjs +68 -0
  76. package/src/util.mjs +35 -0
@@ -0,0 +1,348 @@
1
+ from reader_test_support import *
2
+
3
+ @checks.mark.parametrize("chunked", [False, True])
4
+ def test_proxy_http_frames_body_and_drops_pipelined_requests(monkeypatch, chunked):
5
+ monkeypatch.setattr(socket, "socket", REAL_SOCKET)
6
+ monkeypatch.setattr(socket, "getaddrinfo", REAL_GETADDRINFO)
7
+ received = []
8
+ body = b"4\r\ndata\r\n0\r\n\r\n" if chunked else b"data"
9
+
10
+ async def origin(source, client):
11
+ head = await source.readuntil(b"\r\n\r\n")
12
+ data = await source.readexactly(len(body))
13
+ try:
14
+ extra = await asyncio.wait_for(source.read(1), 0.05)
15
+ except asyncio.TimeoutError:
16
+ extra = b""
17
+ received.append((head, data, extra))
18
+ client.write(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
19
+ await client.drain()
20
+ client.close()
21
+ await client.wait_closed()
22
+
23
+ async def check():
24
+ server = await asyncio.start_server(origin, "127.0.0.1", 0)
25
+ async with server, helper["FilteringProxy"]() as proxy:
26
+ port = server.sockets[0].getsockname()[1]
27
+ url = f"http://127.0.0.1:{port}"
28
+ monkeypatch.setenv("READER_TEST_ALLOW_ORIGIN", url)
29
+ source, client = await asyncio.open_connection("127.0.0.1", proxy.port)
30
+ framing = "Transfer-Encoding: chunked" if chunked else "Content-Length: 4"
31
+ client.write((f"POST {url}/page?key=value HTTP/1.1\r\nHost: invalid.example\r\n"
32
+ f"Proxy-Authorization: discard\r\nConnection: keep-alive\r\n{framing}\r\n\r\n").encode()
33
+ + body + b"GET http://127.0.0.1:9/denied HTTP/1.1\r\n\r\n")
34
+ await client.drain()
35
+ assert (await source.read()).endswith(b"ok")
36
+ assert not proxy.blocked
37
+ head, data, extra = received[0]
38
+ assert head.startswith(b"POST /page?key=value HTTP/1.1\r\n")
39
+ assert f"Host: 127.0.0.1:{port}\r\n".encode() in head
40
+ assert b"Connection: close\r\n" in head
41
+ assert b"Proxy-Authorization" not in head
42
+ assert data == body and extra == b""
43
+ client.close()
44
+ await client.wait_closed()
45
+
46
+ asyncio.run(asyncio.wait_for(check(), 10))
47
+
48
+
49
+ def test_proxy_connect_tunnels_bytes_unchanged(monkeypatch):
50
+ monkeypatch.setattr(socket, "socket", REAL_SOCKET)
51
+ received = []
52
+ addresses = []
53
+ connect = helper["pinned_connection"]
54
+ payload = b"\x16\x03\x01\x00\x10" + bytes(range(256)) + b"\r\n\r\n"
55
+ response = b"\x16\x03\x03" + payload[::-1]
56
+
57
+ def resolve(host, port, **kwargs):
58
+ assert host == "public.example"
59
+ return [(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", (PUBLIC_IP, port))]
60
+
61
+ async def mapped_connection(address):
62
+ addresses.append(address)
63
+ # Map a checked public fixture address to the local byte-echo server.
64
+ assert address[4][0] == PUBLIC_IP
65
+ return await connect((*address[:4], ("127.0.0.1", address[4][1])))
66
+
67
+ monkeypatch.setattr(socket, "getaddrinfo", resolve)
68
+ monkeypatch.setitem(helper["FilteringProxy"].handle.__globals__, "pinned_connection", mapped_connection)
69
+
70
+ async def echo(source, client):
71
+ received.append(await source.read())
72
+ client.write(response)
73
+ await client.drain()
74
+ client.close()
75
+ await client.wait_closed()
76
+
77
+ async def check():
78
+ server = await asyncio.start_server(echo, "127.0.0.1", 0)
79
+ async with server, helper["FilteringProxy"]() as proxy:
80
+ port = server.sockets[0].getsockname()[1]
81
+ source, client = await asyncio.open_connection("127.0.0.1", proxy.port)
82
+ client.write(f"CONNECT public.example:{port} HTTP/1.1\r\n\r\n".encode())
83
+ await client.drain()
84
+ assert await source.readuntil(b"\r\n\r\n") == b"HTTP/1.1 200 Connection Established\r\n\r\n"
85
+ client.write(payload)
86
+ await client.drain()
87
+ client.write_eof()
88
+ assert await source.read() == response
89
+ assert received == [payload]
90
+ assert len(addresses) == 1 and not proxy.blocked
91
+ client.close()
92
+ await client.wait_closed()
93
+
94
+ asyncio.run(asyncio.wait_for(check(), 10))
95
+
96
+
97
+ def test_browser_fetch_local_origin_and_script_rendering(local_http):
98
+ origin, _, pages, requests = local_http
99
+ pages["/page"] = (200, {}, '<p id="value">before</p><p>A public page with enough visible text for crawl4ai.</p>'
100
+ '<script>document.querySelector("p").textContent="after"</script>')
101
+ page, method = reader.fetch(origin + "/page", preferred="crwl")
102
+ assert '<p id="value">after</p>' in page and method == "crwl"
103
+ assert (origin, "/page") in requests
104
+ assert all(host == origin for host, path in requests)
105
+
106
+
107
+ @checks.mark.parametrize("destination", ["other-port", ".".join(['169', '254', '169', '254'])])
108
+ @checks.mark.parametrize("kind", ["redirect", "image", "iframe", "websocket"])
109
+ def test_browser_blocks_private_redirects_and_subresources(local_http, destination, kind):
110
+ origin, denied_origin, pages, requests = local_http
111
+ target = (denied_origin if destination == "other-port" else "http://" + ".".join(["169", "254", "169", "254"])) + "/denied"
112
+ if kind == "redirect":
113
+ pages["/page"] = (302, {"Location": target}, "")
114
+ elif kind == "websocket":
115
+ pages["/page"] = (200, {}, f'<p>allowed</p><script>new WebSocket("{target.replace("http:", "ws:")}")</script>')
116
+ else:
117
+ element = f'<img src="{target}">' if kind == "image" else f'<iframe src="{target}"></iframe>'
118
+ pages["/page"] = (200, {}, '<p>allowed</p>' + element)
119
+ with checks.raises(reader.ReaderError) as exc:
120
+ reader.fetch(origin + "/page", preferred="crwl")
121
+ assert exc.value.code == 10
122
+ assert "non-public" in str(exc.value)
123
+ assert requests == [(origin, "/page")]
124
+
125
+
126
+ @checks.mark.parametrize("status", [301, 302, 303, 307, 308])
127
+ def test_browser_follows_same_host_redirects(local_http, status):
128
+ origin, _, pages, requests = local_http
129
+ pages["/page"] = (status, {"Location": "/step"}, "")
130
+ pages["/step"] = (status, {"Location": origin + "/final"}, "")
131
+ pages["/final"] = (200, {}, '<p>Final page after the checked redirect chain.</p>'
132
+ '<p>This public fixture contains enough visible text for crawl4ai.</p>')
133
+ page, method = reader.fetch(origin + "/page", preferred="crwl")
134
+ assert "Final page" in page and method == "crwl"
135
+ assert requests == [(origin, path) for path in ("/page", "/step", "/final")]
136
+
137
+
138
+ def test_browser_follows_public_host_redirect_with_stub_resolver(local_http, tmp_path):
139
+ origin, _, pages, requests = local_http
140
+ port = origin.rsplit(":", 1)[1]
141
+ target = f"http://public.example:{port}/final"
142
+ pages["/page"] = (302, {"Location": target}, "")
143
+ pages["/final"] = (200, {}, '<p>Public host redirect followed through the filtering proxy.</p>')
144
+ # Stub only DNS and the physical connection to the public fixture address.
145
+ # The real proxy and public-address check run unchanged in the browser child.
146
+ script = f'''import runpy, socket
147
+ helper = runpy.run_path({str(ROOT / "crawl_helper.py")!r})
148
+ PUBLIC_IP = {PUBLIC_IP!r}
149
+ resolve = socket.getaddrinfo
150
+ def fixture_dns(host, port, *args, **kwargs):
151
+ if host == "public.example":
152
+ return [(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", (PUBLIC_IP, port))]
153
+ assert host in ("127.0.0.1", "localhost"), host
154
+ return resolve(host, port, *args, **kwargs)
155
+ socket.getaddrinfo = fixture_dns
156
+ connect = helper["pinned_connection"]
157
+ async def fixture_connection(address):
158
+ if address[4][0] == PUBLIC_IP:
159
+ address = (*address[:4], ("127.0.0.1", address[4][1]))
160
+ return await connect(address)
161
+ helper["FilteringProxy"].handle.__globals__["pinned_connection"] = fixture_connection
162
+ raise SystemExit(helper["main"]())
163
+ '''
164
+ result = subprocess.run([reader.crawl4ai_python(), "-c", script, "fetch"],
165
+ input=json.dumps({"url": origin + "/page"}), capture_output=True,
166
+ text=True, timeout=45, env=reader.crwl_env(tmp_path))
167
+ assert result.returncode == 0, result.stderr[-2000:]
168
+ page = json.loads(result.stdout)
169
+ assert "Public host redirect followed" in page["html"]
170
+ assert page["redirected_url"] == target
171
+ assert requests == [(origin, "/page"), (origin, "/final")]
172
+
173
+
174
+ def test_browser_follows_same_host_subresource_redirects(local_http):
175
+ origin, _, pages, requests = local_http
176
+ pages["/page"] = (200, {}, '<p id="value">before</p><script src="/redirect"></script>'
177
+ '<p>This public fixture contains enough visible text for crawl4ai.</p>')
178
+ pages["/redirect"] = (302, {"Location": "/script"}, "")
179
+ pages["/script"] = (200, {"Content-Type": "application/javascript"},
180
+ 'document.querySelector("p").textContent="loaded";')
181
+ page, _ = reader.fetch(origin + "/page", preferred="crwl")
182
+ assert '<p id="value">loaded</p>' in page
183
+ assert (origin, "/script") in requests
184
+
185
+
186
+ @checks.mark.parametrize("navigation", [False, True])
187
+ def test_browser_checks_script_navigation_destination(local_http, navigation):
188
+ origin, _, pages, requests = local_http
189
+ target = origin if navigation else origin.replace("127.0.0.1", "localhost")
190
+ pages["/page"] = (200, {}, f'<script>location.replace("{target}/final")</script>')
191
+ pages["/final"] = (200, {}, '<p>Final page after the checked script navigation.</p>'
192
+ '<p>This public fixture contains enough visible text for crawl4ai.</p>')
193
+ if navigation:
194
+ page, _ = reader.fetch(origin + "/page", preferred="crwl")
195
+ assert "Final page" in page
196
+ assert (origin, "/final") in requests
197
+ else:
198
+ with checks.raises(reader.ReaderError, match="non-public") as exc:
199
+ reader.fetch(origin + "/page", preferred="crwl")
200
+ assert exc.value.code == 10
201
+ assert requests == [(origin, "/page")]
202
+
203
+
204
+ @checks.mark.parametrize("kind", ["redirect", "image", "iframe"])
205
+ def test_browser_stops_private_later_redirect_hop(local_http, kind):
206
+ origin, denied_origin, pages, requests = local_http
207
+ element = '<img src="/redirect">' if kind == "image" else '<iframe src="/redirect"></iframe>'
208
+ pages["/page"] = ((302, {"Location": "/redirect"}, "") if kind == "redirect" else
209
+ (200, {}, element))
210
+ pages["/redirect"] = (302, {"Location": denied_origin + "/denied"}, "")
211
+ with checks.raises(reader.ReaderError, match="non-public") as exc:
212
+ reader.fetch(origin + "/page", preferred="crwl")
213
+ assert exc.value.code == 10
214
+ assert (origin, "/redirect") in requests
215
+ assert all(path != "/denied" for host, path in requests)
216
+
217
+
218
+ def test_test_origin_override_is_exact_and_opt_in(local_http, monkeypatch):
219
+ origin, denied_origin, _, _ = local_http
220
+ reader.checked_address(origin + "/allowed")
221
+ for target in (origin.replace("127.0.0.1", "localhost"), origin.replace("http:", "https:"),
222
+ denied_origin, "http://" + ".".join(["169", "254", "169", "254"])):
223
+ with checks.raises(reader.ReaderError) as exc:
224
+ reader.checked_address(target)
225
+ assert exc.value.code == 2
226
+ monkeypatch.delenv("READER_TEST_ALLOW_ORIGIN")
227
+ with checks.raises(reader.ReaderError) as exc:
228
+ reader.checked_address(origin)
229
+ assert exc.value.code == 2
230
+
231
+
232
+ def test_css_hostile_html_extracts_without_browser_or_requests(local_http, monkeypatch):
233
+ origin, _, _, requests = local_http
234
+ # Browser startup fails immediately if the CSS helper tries to launch it.
235
+ monkeypatch.setenv("PLAYWRIGHT_BROWSERS_PATH", "/reader-no-browser-for-css")
236
+ spec = {"type": "css", "schema": {"name": "sample", "baseSelector": "p",
237
+ "fields": [{"name": "value", "selector": "span", "type": "text"}]}}
238
+ page = (f'<p><span>extracted</span></p><script>fetch("{origin}/script")</script>'
239
+ f'<img src="{origin}/image"><img src="http://127.0.0.1:9/">')
240
+ assert reader.extract(spec, page) == [{"value": "extracted"}]
241
+ assert requests == []
242
+
243
+
244
+ @checks.mark.parametrize("wrapper", ["shebang", "env", "exec", "missing"])
245
+ def test_crawl4ai_python_resolution(monkeypatch, tmp_path, wrapper):
246
+ monkeypatch.delenv("READER_CRAWL4AI_PYTHON", raising=False)
247
+ executable = tmp_path / "crwl"
248
+ python = str(tmp_path / "python with spaces") if wrapper == "exec" else sys.executable
249
+ if wrapper == "shebang":
250
+ executable.write_text(f"#!{python}\n")
251
+ elif wrapper == "env":
252
+ executable.write_text("#!/usr/bin/env python3\n")
253
+ python = reader.shutil.which("python3")
254
+ elif wrapper == "exec":
255
+ executable.write_text(f'''#!/bin/sh\n\x27\x27\x27exec\x27 "{python}" "$0" "$@"\n\x27 \x27\x27\x27\n''')
256
+ else:
257
+ python = str(ROOT / ".venv/bin/python")
258
+ monkeypatch.setattr(reader, "crwl_binary", lambda: str(executable))
259
+ assert reader.crawl4ai_python() == python
260
+ monkeypatch.setenv("READER_CRAWL4AI_PYTHON", "/custom/python")
261
+ assert reader.crawl4ai_python() == "/custom/python"
262
+
263
+
264
+ def test_compile_embedded_miss_then_css(monkeypatch):
265
+ monkeypatch.setenv("STUB_MODE", "pricing")
266
+ page = '<script type="application/ld+json">{"@type":"BreadcrumbList"}</script><table></table>'
267
+ monkeypatch.setattr(reader, "extract", lambda spec, html: [{"model": "Claude", "input": 2, "output": 10}])
268
+ spec, _ = reader.compile_spec("prices", "https://public.example", {}, "model prices", None, page)
269
+ assert spec["type"] == "css"
270
+ assert len(calls()) == 2
271
+ assert "embedded_json" in calls()[0]
272
+ assert "html" in calls()[1]
273
+
274
+
275
+ def test_params_encoding_and_usage():
276
+ assert reader.render_url("https://public.example/{name}", {"name": "@scope/a?x=y"}) == "https://public.example/%40scope%2Fa%3Fx%3Dy"
277
+ assert reader.parse_params(["name=react", "name=zod", "x=a=b"]) == {"name": "zod", "x": "a=b"}
278
+ for operation in (lambda: reader.render_url("https://public.example/{missing}", {}),
279
+ lambda: reader.parse_params(["bad"]), lambda: reader.spec_path("../secret")):
280
+ with checks.raises(reader.ReaderError) as exc:
281
+ operation()
282
+ assert exc.value.code == 2
283
+
284
+
285
+ def test_cli_compile_run_ls(monkeypatch, capsys):
286
+ monkeypatch.setattr(reader, "fetch", lambda url, **kwargs: (html("zod" if url.endswith("zod") else "react"), "crwl"))
287
+ assert reader.main(["compile", "npm-package", "--url", "https://www.npmjs.com/package/{name}",
288
+ "--want", "package fields", "--param", "name=react"]) == 0
289
+ assert json.loads(capsys.readouterr().out)[0]["name"] == "react"
290
+ assert reader.load("npm-package")["fetch_method"] == "crwl"
291
+ assert reader.main(["run", "npm-package", "--param", "name=zod"]) == 0
292
+ output = json.loads(capsys.readouterr().out)
293
+ assert output["rows"][0]["name"] == "zod"
294
+ assert output["meta"] == {"fetch_method": "crwl"}
295
+ assert reader.main(["ls"]) == 0
296
+ listing = capsys.readouterr().out
297
+ assert all(key in listing for key in ("npm-package", "embedded_json", "compiled_at", "last_ok", "healed_at"))
298
+
299
+
300
+ def test_compile_without_example_warns_once_across_refinements(monkeypatch, capsys):
301
+ monkeypatch.setenv("STUB_MODE", "refine")
302
+ monkeypatch.setattr(reader, "fetch", lambda url, **kwargs: (html(), "urllib"))
303
+ assert reader.main(["compile", "npm-package", "--url", "https://public.example", "--want", "package fields"]) == 0
304
+ captured = capsys.readouterr()
305
+ assert json.loads(captured.out)[0]["name"] == "react"
306
+ assert len(calls()) == 2
307
+ assert captured.err.splitlines() == [
308
+ "reader: warning: wrong-but-plausible values cannot be detected without an example row; "
309
+ "use --example to anchor the compile"]
310
+
311
+
312
+ @checks.mark.parametrize("url", ["file:///etc/passwd", "http://127.0.0.1"])
313
+ def test_cli_compile_and_run_refuse_targets(url):
314
+ env = {**os.environ, "READER_COMPILE_CMD": "reader-must-not-be-called"}
315
+ compiled = subprocess.run([sys.executable, str(ROOT / "reader"), "compile", "bad", "--url", url,
316
+ "--want", "name"], env=env, capture_output=True, text=True)
317
+ assert compiled.returncode == 2 and compiled.stdout == ""
318
+ spec, _ = compile_npm()
319
+ spec["url_template"] = url
320
+ reader.save(spec)
321
+ run = subprocess.run([sys.executable, str(ROOT / "reader"), "run", "npm-package"],
322
+ env=env, capture_output=True, text=True)
323
+ assert run.returncode == 2 and run.stdout == ""
324
+
325
+
326
+ def test_cli_invalid_example_and_corrupt_spec(capsys):
327
+ assert reader.main(["compile", "bad", "--url", "https://public.example", "--want", "name", "--example", "42"]) == 2
328
+ path = reader.spec_path("corrupt")
329
+ path.parent.mkdir(parents=True)
330
+ path.write_text("not JSON")
331
+ assert reader.main(["run", "corrupt"]) == 2
332
+ assert "cannot load spec" in capsys.readouterr().err
333
+
334
+
335
+ def test_candidate_rejects_computed_code_and_required_omissions():
336
+ candidate = json.loads((DATA / "pricing-schema.json").read_text())
337
+ candidate["schema"]["fields"][0]["expression"] = "__import__('os')"
338
+ with checks.raises(ValueError, match="computed"):
339
+ reader.validate_candidate(candidate, None)
340
+ candidate = json.loads((DATA / "npm-schema.json").read_text())
341
+ candidate["required"] = ["missing"]
342
+ with checks.raises(ValueError, match="required"):
343
+ reader.validate_candidate(candidate, None)
344
+
345
+
346
+
347
+ def load_tests(loader, suite, pattern):
348
+ return checks.suite(globals())
@@ -0,0 +1,254 @@
1
+ from reader_test_support import *
2
+
3
+ @checks.mark.parametrize("transform", [[], "unknown", {"regex": "("}, {"regex": "(x)", "group": 3},
4
+ {"regex": 42}, {"number": "yes"}, {"group": 1}])
5
+ def test_candidate_rejects_malformed_transforms(transform):
6
+ candidate = json.loads((DATA / "npm-schema.json").read_text())
7
+ candidate["transforms"] = {"name": transform}
8
+ with checks.raises(ValueError):
9
+ reader.validate_candidate(candidate, None)
10
+
11
+
12
+ def test_heal_preserves_required_fields_without_example(monkeypatch):
13
+ spec, _ = compile_npm()
14
+ spec["example"] = None
15
+ spec["paths"]["license"] = "broken"
16
+ reader.save(spec)
17
+ monkeypatch.setattr(reader, "fetch", lambda url, **kwargs: (html(), "urllib"))
18
+ candidate = {"type": "embedded_json", "source": "__context__", "rows": "context",
19
+ "paths": {"name": "packument.name"}, "required": ["name"]}
20
+ compiler = mock.Mock(return_value=subprocess.CompletedProcess([], 0, json.dumps(candidate), ""))
21
+ monkeypatch.setattr(reader.subprocess, "run", compiler)
22
+ with checks.raises(reader.ReaderError) as exc:
23
+ reader.run_spec(spec, {}, False)
24
+ assert exc.value.code == 12
25
+ assert compiler.call_count == 4
26
+ assert reader.load("npm-package")["required"] == spec["required"]
27
+
28
+
29
+ @checks.mark.parametrize("command", ["", " ", "'unclosed"])
30
+ def test_invalid_compile_command_exits_11(monkeypatch, command):
31
+ monkeypatch.setenv("READER_COMPILE_CMD", command)
32
+ with checks.raises(reader.ReaderError) as exc:
33
+ compile_npm()
34
+ assert exc.value.code == 11
35
+
36
+
37
+ def test_stored_spec_requires_object():
38
+ path = reader.spec_path("array")
39
+ path.parent.mkdir(parents=True)
40
+ path.write_text("[]")
41
+ with checks.raises(reader.ReaderError) as exc:
42
+ reader.load("array")
43
+ assert exc.value.code == 2
44
+
45
+
46
+ @checks.mark.parametrize("key,value", [("example", "bad"), ("example", []), ("example", [42]),
47
+ ("params", []), ("want", None), ("fetch_method", "unknown")])
48
+ def test_stored_spec_validates_metadata(key, value):
49
+ spec, _ = compile_npm()
50
+ spec[key] = value
51
+ reader.save(spec)
52
+ with checks.raises(reader.ReaderError) as exc:
53
+ reader.load("npm-package")
54
+ assert exc.value.code == 2
55
+
56
+
57
+ def test_other_compile_command_keeps_stdin(monkeypatch):
58
+ command = mock.Mock(return_value=subprocess.CompletedProcess([], 0, "{}", ""))
59
+ monkeypatch.setattr(reader.subprocess, "run", command)
60
+ reader.invoke_compile("custom-compiler --flag", "é" * 60000)
61
+ assert command.call_args.args[0] == ["custom-compiler", "--flag"]
62
+ assert command.call_args.kwargs["input"] == "é" * 60000
63
+
64
+
65
+ @checks.mark.parametrize("output, expected", [
66
+ ('lane ready\n{"paths":{"x":"value with } and \\\" quotes"},"required":["x"]}\ndone',
67
+ {"paths": {"x": 'value with } and " quotes'}, "required": ["x"]}),
68
+ ('status {"ignore":true}\n```json\n{"type":"css"}\n```\ndone', {"type": "css"}),
69
+ ('prefix {not JSON}\n{"need_html":true}\n{"later":true}', {"need_html": True}),
70
+ ])
71
+ def test_schema_json_from_mixed_stdout(output, expected):
72
+ assert reader.json_object(output) == expected
73
+
74
+
75
+ @checks.mark.parametrize("status", [401, 403, 429])
76
+ def test_blocked_http_falls_back_to_crwl(monkeypatch, crwl_fetch_stub, status):
77
+ body = io.BytesIO(b"denied")
78
+ urllib = mock.Mock(side_effect=HTTPError("https://www.npmjs.com/package/react", status, "blocked", {}, body))
79
+ monkeypatch.setattr(reader, "fetch_urllib", urllib)
80
+ page, method = reader.fetch("https://www.npmjs.com/package/react")
81
+ assert page == html() and method == "crwl"
82
+ assert body.closed
83
+ assert len(crwl_fetch_stub.read_text().splitlines()) == 1
84
+
85
+
86
+ @checks.mark.parametrize("page", ['<title>Just a moment...</title>',
87
+ '<title>Attention Required! | Cloudflare</title>',
88
+ '<script src="/cdn-cgi/challenge-platform/h/g/orchestrate/chl_page/v1"></script>',
89
+ '<div>Please enable JS and disable any ad blocker</div>'])
90
+ def test_bot_wall_falls_back_to_crwl(monkeypatch, crwl_fetch_stub, page):
91
+ monkeypatch.setattr(reader, "fetch_urllib", lambda url: page)
92
+ assert reader.fetch("https://www.npmjs.com/package/zod") == (html("zod"), "crwl")
93
+
94
+
95
+ def test_cloudflare_detection_script_preserves_valid_page(monkeypatch):
96
+ page = html() + '<script src="/cdn-cgi/challenge-platform/scripts/jsd/main.js"></script>'
97
+ monkeypatch.setattr(reader, "fetch_urllib", lambda url: page)
98
+ crwl = mock.Mock(side_effect=AssertionError("valid page needs no browser retry"))
99
+ monkeypatch.setattr(reader, "fetch_crwl", crwl)
100
+ assert reader.fetch("https://www.npmjs.com/package/react") == (page, "urllib")
101
+ crwl.assert_not_called()
102
+
103
+
104
+ def test_blocked_compile_run_metadata_and_crwl_preference(monkeypatch, crwl_fetch_stub, capsys):
105
+ urllib = mock.Mock(side_effect=HTTPError("https://www.npmjs.com/package/react", 403, "blocked", {}, None))
106
+ monkeypatch.setattr(reader, "fetch_urllib", urllib)
107
+ assert reader.main(["compile", "npm-package", "--url", "https://www.npmjs.com/package/{name}",
108
+ "--want", "package fields", "--param", "name=react"]) == 0
109
+ capsys.readouterr()
110
+ assert reader.load("npm-package")["fetch_method"] == "crwl"
111
+ assert urllib.call_count == 1 and len(calls()) == 1
112
+ for package in ("react", "zod"):
113
+ assert reader.main(["run", "npm-package", "--param", "name=" + package]) == 0
114
+ output = json.loads(capsys.readouterr().out)
115
+ assert output["meta"] == {"fetch_method": "crwl"}
116
+ assert output["rows"][0]["name"] == package
117
+ assert reader.failures(reader.load("npm-package"), output["rows"]) == []
118
+ assert urllib.call_count == 1 and len(calls()) == 1
119
+ assert len(crwl_fetch_stub.read_text().splitlines()) == 3
120
+
121
+
122
+ def test_missing_crwl_fetch_is_exit_10_and_skips_compile(monkeypatch, capsys):
123
+ monkeypatch.setattr(reader, "fetch_urllib", mock.Mock(side_effect=HTTPError("https://public.example", 403, "blocked", {}, None)))
124
+ monkeypatch.setattr(reader, "checked_address", lambda url: public_address())
125
+ monkeypatch.setenv("READER_CRAWL4AI_PYTHON", "reader-missing-crawl-python")
126
+ assert reader.main(["compile", "blocked", "--url", "https://public.example", "--want", "fields"]) == 10
127
+ output = capsys.readouterr()
128
+ assert output.out == "" and "crwl" in output.err
129
+ assert calls() == []
130
+
131
+
132
+ @checks.mark.parametrize("status", [404, 500])
133
+ def test_other_http_errors_skip_crwl(monkeypatch, status):
134
+ monkeypatch.setattr(reader, "fetch_urllib", mock.Mock(side_effect=HTTPError("https://public.example", status, "error", {}, None)))
135
+ crwl = mock.Mock(side_effect=AssertionError("no browser retry"))
136
+ monkeypatch.setattr(reader, "fetch_crwl", crwl)
137
+ with checks.raises(reader.ReaderError) as exc:
138
+ reader.fetch("https://public.example")
139
+ assert exc.value.code == 10
140
+ crwl.assert_not_called()
141
+
142
+
143
+ @checks.mark.parametrize("result", [
144
+ {"html": "<p>ok</p>", "success": False},
145
+ {"html": "<p>denied</p>", "status_code": 403},
146
+ {"html": "<title>Just a moment...</title>"}, {}, {"html": ""},
147
+ {"html": "x" * (reader.FETCH_CAP + 1)},
148
+ ])
149
+ def test_crwl_bad_pages_exit_10(monkeypatch, result):
150
+ monkeypatch.setattr(reader, "checked_address", lambda url: public_address())
151
+ monkeypatch.setattr(reader.subprocess, "run", mock.Mock(return_value=subprocess.CompletedProcess([], 0, json.dumps(result), "")))
152
+ with checks.raises(reader.ReaderError) as exc:
153
+ reader.fetch("https://public.example", preferred="crwl")
154
+ assert exc.value.code == 10 and "crwl" in str(exc.value)
155
+
156
+
157
+ @checks.mark.parametrize("failure", [subprocess.CompletedProcess([], 1, "", ""),
158
+ subprocess.CompletedProcess([], 0, "invalid JSON", ""),
159
+ subprocess.TimeoutExpired("crwl", 90)])
160
+ def test_crwl_command_failures_exit_10(monkeypatch, failure):
161
+ monkeypatch.setattr(reader, "checked_address", lambda url: public_address())
162
+ command = mock.Mock(side_effect=failure) if isinstance(failure, Exception) else mock.Mock(return_value=failure)
163
+ monkeypatch.setattr(reader.subprocess, "run", command)
164
+ with checks.raises(reader.ReaderError) as exc:
165
+ reader.fetch("https://public.example", preferred="crwl")
166
+ assert exc.value.code == 10 and "crwl" in str(exc.value)
167
+
168
+
169
+ @checks.mark.parametrize("url", ["file:///etc/passwd", "http://127.0.0.1"])
170
+ def test_crwl_first_refuses_targets_before_subprocess(monkeypatch, url):
171
+ socket.getaddrinfo.side_effect = None
172
+ socket.getaddrinfo.return_value = [public_address("127.0.0.1")]
173
+ command = mock.Mock(side_effect=AssertionError("refused target cannot reach crwl"))
174
+ monkeypatch.setattr(reader.subprocess, "run", command)
175
+ with checks.raises(reader.ReaderError) as exc:
176
+ reader.fetch(url, preferred="crwl")
177
+ assert exc.value.code == 2
178
+ command.assert_not_called()
179
+
180
+
181
+ def test_crwl_reported_private_redirect_is_refused(monkeypatch):
182
+ socket.getaddrinfo.side_effect = None
183
+ socket.getaddrinfo.side_effect = [[public_address()], [public_address("127.0.0.1")]]
184
+ result = {"html": "<p>private</p>", "redirected_url": "http://127.0.0.1"}
185
+ monkeypatch.setattr(reader.subprocess, "run", mock.Mock(return_value=subprocess.CompletedProcess([], 0, json.dumps(result), "")))
186
+ with checks.raises(reader.ReaderError) as exc:
187
+ reader.fetch("https://public.example", preferred="crwl")
188
+ assert exc.value.code == 2
189
+
190
+
191
+ def test_legacy_spec_uses_urllib_then_remembers_crwl(monkeypatch):
192
+ spec, _ = compile_npm()
193
+ spec.pop("fetch_method")
194
+ reader.save(spec)
195
+ fetch = mock.Mock(return_value=(html(), "crwl"))
196
+ monkeypatch.setattr(reader, "fetch", fetch)
197
+ reader.run_spec(reader.load("npm-package"), {}, False)
198
+ fetch.assert_called_once_with("https://www.npmjs.com/package/react", preferred="urllib")
199
+ assert reader.load("npm-package")["fetch_method"] == "crwl"
200
+
201
+
202
+ def test_bundled_npm_spec_crwl_runs(monkeypatch):
203
+ spec = json.loads((ROOT / "specs/npm-package.json").read_text())
204
+ reader.save(spec)
205
+ def live_shape(url):
206
+ package = url.rsplit("/", 1)[-1]
207
+ embedded, _ = reader.page_data(html(package))
208
+ context = embedded["__context__"]["context"]
209
+ context["packument"].update(
210
+ version=context["packument"]["dist-tags"]["latest"],
211
+ license=context["packageVersion"]["license"],
212
+ repository=context["packageVersion"]["repository"]["url"])
213
+ context["downloads"] = [{"downloads": context["downloads"]["weeklyDownloads"]}]
214
+ return '<script>window.__context__ = ' + json.dumps(embedded["__context__"]) + ';</script>'
215
+
216
+ crwl = mock.Mock(side_effect=live_shape)
217
+ monkeypatch.setattr(reader, "fetch_crwl", crwl)
218
+ monkeypatch.setattr(reader, "fetch_urllib", mock.Mock(side_effect=AssertionError("bundled spec prefers crwl")))
219
+ for package in ("react", "zod"):
220
+ result = reader.run_spec(reader.load("npm-package"), {"name": package}, False)
221
+ assert result["rows"][0]["name"] == package
222
+ assert result["rows"][0]["license"] == "MIT"
223
+ assert type(result["rows"][0]["weekly_downloads"]) is int
224
+ assert reader.failures(spec, result["rows"]) == []
225
+ assert result["meta"] == {"fetch_method": "crwl"}
226
+ assert calls() == []
227
+ assert crwl.call_count == 2
228
+
229
+
230
+ @checks.mark.parametrize("package_license", ["MIT", "4.6.7"])
231
+ def test_bundled_npm_spec_captured_zod_context(monkeypatch, package_license):
232
+ # Public data excerpt from crwl's live zod HTML, captured 2026-10-23.
233
+ context = {
234
+ "packument": {"name": "zod", "version": "4.6.7", "license": "MIT",
235
+ "repository": "https://github.com/colinhacks/zod"},
236
+ "packageVersion": {"name": "zod", "version": "4.6.7", "license": package_license},
237
+ "downloads": [{"downloads": 216094796, "label": "2026-10-09 to 2026-10-15"},
238
+ {"downloads": 144621339, "label": "2026-10-16 to 2026-10-22"}],
239
+ }
240
+ page = '<script>window.__context__ = ' + json.dumps({"context": context}) + ';</script>'
241
+ spec = json.loads((ROOT / "specs/npm-package.json").read_text())
242
+ reader.save(spec)
243
+ monkeypatch.setattr(reader, "fetch", lambda url, **kwargs: (page, "crwl"))
244
+ output = reader.run_spec(reader.load("npm-package"), {"name": "zod"}, False)
245
+ assert output["rows"] == [{"name": "zod", "latest_version": "4.6.7",
246
+ "weekly_downloads": 144621339, "license": "MIT",
247
+ "repository_url": "https://github.com/colinhacks/zod"}]
248
+ assert type(output["rows"][0]["weekly_downloads"]) is int
249
+ assert calls() == []
250
+
251
+
252
+
253
+ def load_tests(loader, suite, pattern):
254
+ return checks.suite(globals())
@@ -0,0 +1,57 @@
1
+ """Compile-command usage and bundled-spec offline contracts."""
2
+ from reader_test_support import *
3
+
4
+
5
+ def test_unset_compile_command_exits_two(monkeypatch, capsys):
6
+ monkeypatch.delenv('READER_COMPILE_CMD')
7
+ assert reader.main(['compile', 'sample', '--url', 'https://example.com', '--want', 'title']) == 2
8
+ message = capsys.readouterr().err
9
+ assert 'READER_COMPILE_CMD' in message
10
+ assert "export READER_COMPILE_CMD='claude -p'" in message
11
+ socket.getaddrinfo.assert_not_called()
12
+
13
+
14
+ def test_unset_command_self_heal_and_valid_run(monkeypatch):
15
+ spec, _ = compile_npm()
16
+ monkeypatch.delenv('READER_COMPILE_CMD')
17
+ monkeypatch.setattr(reader, 'fetch', lambda url, **kwargs: (html(), 'urllib'))
18
+ assert reader.run_spec(spec, {}, False)['rows'][0]['name'] == 'react'
19
+ spec['paths']['license'] = 'absent'
20
+ with checks.raises(reader.ReaderError) as error:
21
+ reader.run_spec(spec, {}, False)
22
+ assert error.value.code == 2
23
+
24
+
25
+ def test_bundled_pricing_spec_offline_contract(monkeypatch):
26
+ spec = json.loads((ROOT / 'specs/claude-pricing.json').read_text())
27
+ page = (DATA / 'claude-pricing.html').read_text()
28
+ # A stdlib HTML parser checks the captured rows before mocking the CSS engine.
29
+ class Table(reader.HTMLParser):
30
+ def __init__(self):
31
+ super().__init__()
32
+ self.rows, self.cells, self.cell, self.retired = [], [], None, False
33
+ def handle_starttag(self, tag, attrs):
34
+ if tag == 'tr': self.cells, self.retired = [], False
35
+ if tag == 'td': self.cell = ''
36
+ if tag == 'button' and 'Retired' in dict(attrs).get('aria-label', ''): self.retired = True
37
+ def handle_data(self, data):
38
+ if self.cell is not None: self.cell += data
39
+ def handle_endtag(self, tag):
40
+ if tag == 'td': self.cells.append(self.cell); self.cell = None
41
+ if tag == 'tr' and len(self.cells) == len(spec['schema']['fields']) and not self.retired:
42
+ self.rows.append(dict(zip([f['name'] for f in spec['schema']['fields']], self.cells)))
43
+ parser = Table(); parser.feed(page)
44
+ def helper(operation, payload, code):
45
+ assert operation == 'css'
46
+ assert payload['schema'] == spec['schema']
47
+ assert payload['html'] == page
48
+ return {'rows': parser.rows}
49
+ monkeypatch.setattr(reader, 'crawl_helper', helper)
50
+ rows = reader.extract(spec, page)
51
+ assert reader.failures(spec, rows) == []
52
+ assert len(rows) == len(spec['sample_output'])
53
+ assert {row['model'] for row in rows} == {row['model'] for row in spec['sample_output']}
54
+
55
+
56
+ def load_tests(loader, suite, pattern):
57
+ return checks.suite(globals())