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.
- package/CHANGELOG.md +21 -0
- package/CREDITS.md +18 -0
- package/LICENSE +21 -0
- package/README.md +159 -2
- package/agents/aun-browser-runner.md +48 -0
- package/bin/lean-browser-use.mjs +3 -0
- package/briefs/browser-read.md +63 -0
- package/briefs/helper-read.md +63 -0
- package/docs/PROVENANCE.md +32 -0
- package/docs/benchmark.md +91 -0
- package/docs/guards.md +54 -0
- package/docs/how-it-works.md +27 -0
- package/docs/install.md +78 -0
- package/docs/measure.md +53 -0
- package/docs/reader.md +39 -0
- package/docs/search.md +16 -0
- package/hooks/batch-nudge.mjs +14 -0
- package/hooks/batch-nudge.sh +8 -0
- package/hooks/common.mjs +71 -0
- package/hooks/hooks.settings.json +58 -0
- package/hooks/pick-guard.mjs +27 -0
- package/hooks/pick-guard.sh +8 -0
- package/hooks/retry-guard.mjs +69 -0
- package/hooks/retry-guard.sh +8 -0
- package/llms.txt +40 -0
- package/package.json +61 -4
- package/proof/benchmark.json +166 -0
- package/proof/results.json +241 -0
- package/reader/README.md +48 -0
- package/reader/crawl_helper.py +256 -0
- package/reader/reader +14 -0
- package/reader/reader_base.py +106 -0
- package/reader/reader_compile.py +106 -0
- package/reader/reader_crawl.py +67 -0
- package/reader/reader_extract.py +261 -0
- package/reader/reader_fetch.py +148 -0
- package/reader/reader_runtime.py +199 -0
- package/reader/reader_test_harness.py +173 -0
- package/reader/reader_test_support.py +214 -0
- package/reader/specs/claude-pricing.json +223 -0
- package/reader/specs/npm-package.json +67 -0
- package/reader/test_reader.py +345 -0
- package/reader/test_reader_1.py +309 -0
- package/reader/test_reader_2.py +348 -0
- package/reader/test_reader_3.py +254 -0
- package/reader/test_reader_contract.py +57 -0
- package/reader/testdata/claude-pricing.html +4 -0
- package/reader/testdata/compile_stub.py +24 -0
- package/reader/testdata/npm-react.html +4 -0
- package/reader/testdata/npm-schema.json +14 -0
- package/reader/testdata/npm-zod.html +4 -0
- package/reader/testdata/pricing-schema.json +24 -0
- package/searxng/README.md +29 -0
- package/searxng/compose.yml +21 -0
- package/searxng/core-config/settings.yml +26 -0
- package/searxng/deploy.sh +36 -0
- package/searxng/searx +73 -0
- package/searxng/test_searx.py +107 -0
- package/searxng/test_searx_offline.py +67 -0
- package/skills/lean-browser-use/SKILL.md +35 -0
- package/skills/lean-browser-use/references/cleanup.md +17 -0
- package/skills/lean-browser-use/references/cost-model.md +11 -0
- package/skills/lean-browser-use/references/draft-offline.md +9 -0
- package/skills/lean-browser-use/references/failure-modes.md +16 -0
- package/skills/lean-browser-use/references/logged-in-run.md +23 -0
- package/skills/lean-browser-use/references/packet-principle.md +15 -0
- package/skills/lean-browser-use/references/route-by-job.md +18 -0
- package/skills/lean-browser-use/references/runner-rules.md +27 -0
- package/skills/lean-browser-use/references/verify.md +9 -0
- package/src/cli.mjs +42 -0
- package/src/doctor.mjs +60 -0
- package/src/install.mjs +116 -0
- package/src/measure.mjs +64 -0
- package/src/paths.mjs +51 -0
- package/src/uninstall.mjs +68 -0
- 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())
|