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,345 @@
|
|
|
1
|
+
from reader_test_support import *
|
|
2
|
+
|
|
3
|
+
@checks.mark.parametrize("url", ["file:///etc/passwd", "ftp://example.com", "data:text/html,hello",
|
|
4
|
+
"https://", "https://a:b" + "@" + "example.com", "https://example.com\r/", "http://example.com:bad"])
|
|
5
|
+
def test_refused_url_shapes(url):
|
|
6
|
+
with checks.raises(reader.ReaderError) as exc:
|
|
7
|
+
reader.checked_address(url)
|
|
8
|
+
assert exc.value.code == 2
|
|
9
|
+
socket.getaddrinfo.assert_not_called()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@checks.mark.parametrize("ip", ["127.0.0.1", ".".join(['10', '1', '2', '3']), ".".join(['169', '254', '169', '254']), ".".join(['100', '64', '1', '2']),
|
|
13
|
+
"0.0.0.0", ".".join(['192', '168', '1', '1']), ".".join(['224', '0', '0', '1']), "::1", "fe80::1", "::ffff:127.0.0.1"])
|
|
14
|
+
def test_refused_addresses(ip):
|
|
15
|
+
socket.getaddrinfo.side_effect = None
|
|
16
|
+
socket.getaddrinfo.return_value = [public_address(ip)]
|
|
17
|
+
with checks.raises(reader.ReaderError) as exc:
|
|
18
|
+
reader.checked_address("http://public.example")
|
|
19
|
+
assert exc.value.code == 2
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_every_dns_answer_checked():
|
|
23
|
+
socket.getaddrinfo.side_effect = None
|
|
24
|
+
socket.getaddrinfo.return_value = [public_address(), public_address(".".join(['10', '0', '0', '1']))]
|
|
25
|
+
with checks.raises(reader.ReaderError) as exc:
|
|
26
|
+
reader.checked_address("https://public.example")
|
|
27
|
+
assert exc.value.code == 2
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_dns_failure_is_fetch_exit_10():
|
|
31
|
+
socket.getaddrinfo.side_effect = socket.gaierror("not found")
|
|
32
|
+
with checks.raises(reader.ReaderError) as exc:
|
|
33
|
+
reader.fetch("https://missing.example")
|
|
34
|
+
assert exc.value.code == 10
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_redirect_refuses_private_target():
|
|
38
|
+
socket.getaddrinfo.side_effect = None
|
|
39
|
+
socket.getaddrinfo.return_value = [public_address("127.0.0.1")]
|
|
40
|
+
with checks.raises(reader.ReaderError) as exc:
|
|
41
|
+
reader.PublicRedirect().redirect_request(Request("https://public.example"), None,
|
|
42
|
+
302, "redirect", {}, "http://127.0.0.1")
|
|
43
|
+
assert exc.value.code == 2
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_redirect_retains_checked_address():
|
|
47
|
+
socket.getaddrinfo.side_effect = None
|
|
48
|
+
socket.getaddrinfo.return_value = [public_address()]
|
|
49
|
+
req = reader.PublicRedirect().redirect_request(Request("https://public.example"), None,
|
|
50
|
+
302, "redirect", {}, "https://other.example/page")
|
|
51
|
+
assert req.checked_address == public_address()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_connector_uses_checked_sockaddr_without_dns():
|
|
55
|
+
sock = mock.Mock()
|
|
56
|
+
socket.socket.side_effect = None
|
|
57
|
+
socket.socket.return_value = sock
|
|
58
|
+
conn = reader.connection(reader.http.client.HTTPSConnection, "public.example", public_address())
|
|
59
|
+
assert conn._create_connection(("public.example", 443), 10) is sock
|
|
60
|
+
sock.connect.assert_called_once_with((PUBLIC_IP, 443))
|
|
61
|
+
socket.getaddrinfo.assert_not_called()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_fetch_user_agent_size_bound_and_no_proxy(monkeypatch):
|
|
65
|
+
response = mock.MagicMock()
|
|
66
|
+
response.__enter__.return_value = response
|
|
67
|
+
response.headers.get_content_charset.return_value = "utf-8"
|
|
68
|
+
response.read.return_value = b"<html>ok</html>"
|
|
69
|
+
opener = mock.Mock()
|
|
70
|
+
opener.open.return_value = response
|
|
71
|
+
build = mock.Mock(return_value=opener)
|
|
72
|
+
monkeypatch.setattr(reader, "build_opener", build)
|
|
73
|
+
monkeypatch.setattr(reader, "checked_address", lambda url: public_address())
|
|
74
|
+
assert reader.fetch("https://public.example") == ("<html>ok</html>", "urllib")
|
|
75
|
+
req = opener.open.call_args.args[0]
|
|
76
|
+
assert "Mozilla/" in req.get_header("User-agent")
|
|
77
|
+
assert req.checked_address == public_address()
|
|
78
|
+
assert build.call_args.args[0].proxies == {}
|
|
79
|
+
response.read.assert_called_once_with(reader.FETCH_CAP + 1)
|
|
80
|
+
response.read.return_value = b"x" * (reader.FETCH_CAP + 1)
|
|
81
|
+
with checks.raises(reader.ReaderError) as exc:
|
|
82
|
+
reader.fetch("https://public.example")
|
|
83
|
+
assert exc.value.code == 10
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_fetch_unknown_charset_uses_utf8_replacement(monkeypatch):
|
|
87
|
+
response = mock.MagicMock()
|
|
88
|
+
response.__enter__.return_value = response
|
|
89
|
+
response.headers.get_content_charset.return_value = "reader-unknown-charset"
|
|
90
|
+
response.read.return_value = b"<p>caf\xc3\xa9 \xff</p>"
|
|
91
|
+
opener = mock.Mock()
|
|
92
|
+
opener.open.return_value = response
|
|
93
|
+
monkeypatch.setattr(reader, "build_opener", lambda *args: opener)
|
|
94
|
+
monkeypatch.setattr(reader, "checked_address", lambda url: public_address())
|
|
95
|
+
assert reader.fetch_urllib("https://public.example") == "<p>café \ufffd</p>"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@checks.mark.parametrize("package", ["react", "zod"])
|
|
99
|
+
def test_embedded_npm_two_params(package):
|
|
100
|
+
spec, _ = compile_npm()
|
|
101
|
+
result = reader.extract(spec, html(package))
|
|
102
|
+
assert result[0]["name"] == package
|
|
103
|
+
assert result[0]["license"] == "MIT"
|
|
104
|
+
assert result[0]["latest_version"]
|
|
105
|
+
assert result[0]["weekly_downloads"] > 0
|
|
106
|
+
assert result[0]["repository_url"].startswith("git+https://github.com/")
|
|
107
|
+
assert reader.failures(spec, result) == []
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_embedded_detection_and_json_parse():
|
|
111
|
+
value = {"value": 7}
|
|
112
|
+
encoded = json.dumps(json.dumps(value))
|
|
113
|
+
page = ('<script type="application/ld+json">{"@type":"Thing"}</script>'
|
|
114
|
+
'<script id="__NEXT_DATA__" type="application/json">{"props":{"items":[1]}}</script>'
|
|
115
|
+
'<script>window.__INITIAL_STATE__ = {"x":2}; alert("ignore");</script>'
|
|
116
|
+
f'<script>window.__context__ = JSON.parse({encoded});</script>'
|
|
117
|
+
'<script>window.__BAD_STATE__ = eval("private");</script><h1>Visible</h1>')
|
|
118
|
+
embedded, trimmed = reader.page_data(page)
|
|
119
|
+
assert embedded["ld+json:0"]["@type"] == "Thing"
|
|
120
|
+
assert embedded["__NEXT_DATA__"]["props"]["items"] == [1]
|
|
121
|
+
assert embedded["__INITIAL_STATE__"] == {"x": 2}
|
|
122
|
+
assert embedded["__context__"] == value
|
|
123
|
+
assert "__BAD_STATE__" not in embedded
|
|
124
|
+
assert "alert" not in trimmed and "<h1>Visible</h1>" in trimmed
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_embedded_list_rows_and_escaped_paths():
|
|
128
|
+
spec = {"type": "embedded_json", "source": "__NEXT_DATA__", "rows": "props.rows",
|
|
129
|
+
"paths": {"field": r"nested.key\.with\.dots.0"}}
|
|
130
|
+
page = '<script id="__NEXT_DATA__">{"props":{"rows":[{"nested":{"key.with.dots":["one"]}},{"nested":{"key.with.dots":["two"]}}]}}</script>'
|
|
131
|
+
assert reader.extract(spec, page) == [{"field": "one"}, {"field": "two"}]
|
|
132
|
+
assert reader.extract(spec, "<html></html>") == []
|
|
133
|
+
assert reader.path_get({"a": []}, "a.9") is None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_compile_embedded_before_html_and_metadata():
|
|
137
|
+
spec, rows = compile_npm()
|
|
138
|
+
assert len(calls()) == 1
|
|
139
|
+
assert "embedded_json" in calls()[0] and "html" not in calls()[0]
|
|
140
|
+
for key in ("name", "url_template", "params", "type", "paths", "required", "want", "example",
|
|
141
|
+
"sample_output", "compiled_at", "compiled_by", "crawl4ai_version", "page_hash", "last_ok", "healed_at", "fetch_method"):
|
|
142
|
+
assert key in spec
|
|
143
|
+
assert spec["compiled_by"] == os.environ["READER_COMPILE_CMD"]
|
|
144
|
+
assert spec["sample_output"] == rows
|
|
145
|
+
assert spec["healed_at"] is None
|
|
146
|
+
assert len(spec["page_hash"]) == 64
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_compile_refines_with_named_failures(monkeypatch):
|
|
150
|
+
monkeypatch.setenv("STUB_MODE", "refine")
|
|
151
|
+
spec, rows = compile_npm()
|
|
152
|
+
assert len(calls()) == 2
|
|
153
|
+
assert calls()[1]["validation_failures"][0] == "row[0].license"
|
|
154
|
+
assert calls()[1]["validation_failures"][1].startswith("example row not found; nearest row: ")
|
|
155
|
+
assert "previous_schema" in calls()[1]
|
|
156
|
+
sample = calls()[1]["failing_row_samples"][0]
|
|
157
|
+
assert sample["index"] == 0
|
|
158
|
+
assert json.loads(sample["values"])["name"] == "react"
|
|
159
|
+
assert sample["outer_html"].startswith("<script")
|
|
160
|
+
assert len(sample["outer_html"]) <= 600
|
|
161
|
+
assert reader.failures(spec, rows) == []
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@checks.mark.parametrize("as_list", [False, True])
|
|
165
|
+
def test_compile_anchor_refines_truncated_names(anchor_compile, capsys, as_list):
|
|
166
|
+
example = anchor_compile[1]
|
|
167
|
+
if as_list:
|
|
168
|
+
example = [example, {"model": "only the first example anchors"}]
|
|
169
|
+
assert reader.main(["compile", "prices", "--url", "https://public.example", "--want", "model prices",
|
|
170
|
+
"--example", json.dumps(example)]) == 0
|
|
171
|
+
captured = capsys.readouterr()
|
|
172
|
+
assert json.loads(captured.out) == anchor_compile
|
|
173
|
+
assert captured.err == ""
|
|
174
|
+
assert len(calls()) == 2
|
|
175
|
+
failures = calls()[1]["validation_failures"]
|
|
176
|
+
assert len(failures) == 1
|
|
177
|
+
assert failures[0].startswith("example row not found; nearest row: ")
|
|
178
|
+
assert json.loads(failures[0].split("nearest row: ", 1)[1]) == {
|
|
179
|
+
"model": "Claude Sonnet 5", "input": 2, "output": 10}
|
|
180
|
+
assert calls()[1]["previous_schema"]["transforms"]["model"]
|
|
181
|
+
assert reader.load("prices")["sample_output"] == anchor_compile
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_compile_anchor_failure_keeps_existing_spec(anchor_compile, monkeypatch, capsys):
|
|
185
|
+
monkeypatch.setenv("STUB_MODE", "anchor-broken")
|
|
186
|
+
reader.save_json(reader.spec_path("prices"), {"preserved": True})
|
|
187
|
+
before = reader.spec_path("prices").read_bytes()
|
|
188
|
+
assert reader.main(["compile", "prices", "--url", "https://public.example", "--want", "model prices",
|
|
189
|
+
"--example", json.dumps(anchor_compile[1])]) == 11
|
|
190
|
+
captured = capsys.readouterr()
|
|
191
|
+
assert captured.out == ""
|
|
192
|
+
assert "example row not found; nearest row:" in captured.err
|
|
193
|
+
assert len(calls()) == 4
|
|
194
|
+
assert reader.spec_path("prices").read_bytes() == before
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@checks.mark.parametrize("example,rows", [
|
|
198
|
+
({"model": " Claude Sonnet\t5.5 ", "input": 2}, [{"model": "Claude\nSonnet 5.5", "input": 2.0}]),
|
|
199
|
+
({"model": "Claude", "input": 0}, [{"model": "other", "input": 1}, {"model": "Claude", "input": 0.0}]),
|
|
200
|
+
({"cache": None}, [{"cache": None}]),
|
|
201
|
+
])
|
|
202
|
+
def test_compile_anchor_normalizes_whitespace_and_numbers(example, rows):
|
|
203
|
+
assert reader.anchor_failures(example, rows) == []
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@checks.mark.parametrize("example,rows", [
|
|
207
|
+
({"model": "Claude"}, [{"model": "claude"}]),
|
|
208
|
+
({"input": 2}, [{"input": "2"}]),
|
|
209
|
+
({"input": 1}, [{"input": True}]),
|
|
210
|
+
({"cache": None}, [{}]),
|
|
211
|
+
({"model": "Claude", "input": 2}, [{"model": "Claude", "input": 1}, {"model": "other", "input": 2}]),
|
|
212
|
+
({"model": "Claude"}, []),
|
|
213
|
+
])
|
|
214
|
+
def test_compile_anchor_requires_all_values_in_one_row(example, rows):
|
|
215
|
+
assert reader.anchor_failures(example, rows)[0].startswith("example row not found; nearest row: ")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@checks.mark.parametrize("mode", ["broken", "fail"])
|
|
219
|
+
def test_compile_stops_after_three_refinements(monkeypatch, mode):
|
|
220
|
+
monkeypatch.setenv("STUB_MODE", mode)
|
|
221
|
+
with checks.raises(reader.ReaderError) as exc:
|
|
222
|
+
compile_npm()
|
|
223
|
+
assert exc.value.code == 11
|
|
224
|
+
assert "3 refinements" in str(exc.value)
|
|
225
|
+
assert len(calls()) == (8 if mode == "fail" else 4)
|
|
226
|
+
assert not reader.spec_path("npm-package").exists()
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@checks.mark.parametrize("outcome", ["nonzero", "empty", "invalid"])
|
|
230
|
+
def test_compile_retries_command_once_before_refinement(monkeypatch, tmp_path, outcome):
|
|
231
|
+
command = tmp_path / "flaky.py"
|
|
232
|
+
command.write_text(f'''import os, runpy, sys
|
|
233
|
+
from pathlib import Path
|
|
234
|
+
log = Path(os.environ["STUB_CALL_LOG"])
|
|
235
|
+
if not log.exists():
|
|
236
|
+
prompt = sys.stdin.read()
|
|
237
|
+
import json
|
|
238
|
+
log.write_text(json.dumps(json.loads(prompt.split("INPUT JSON:\\n", 1)[1])) + "\\n")
|
|
239
|
+
print("temporary command failure", file=sys.stderr)
|
|
240
|
+
if {outcome!r} == "nonzero":
|
|
241
|
+
sys.exit(10)
|
|
242
|
+
if {outcome!r} == "invalid":
|
|
243
|
+
print("no schema returned")
|
|
244
|
+
else:
|
|
245
|
+
runpy.run_path({str(DATA / "compile_stub.py")!r}, run_name="__main__")
|
|
246
|
+
''')
|
|
247
|
+
monkeypatch.setenv("READER_COMPILE_CMD", shlex.join([sys.executable, str(command)]))
|
|
248
|
+
spec, rows = compile_npm()
|
|
249
|
+
assert len(calls()) == 2
|
|
250
|
+
assert calls()[0] == calls()[1]
|
|
251
|
+
assert "validation_failures" not in calls()[1]
|
|
252
|
+
assert reader.failures(spec, rows) == []
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@checks.mark.parametrize("outcome", ["nonzero", "invalid"])
|
|
256
|
+
def test_compile_retry_limit_and_last_stderr(monkeypatch, tmp_path, outcome):
|
|
257
|
+
command = tmp_path / "failed.py"
|
|
258
|
+
command.write_text('''import json, os, sys
|
|
259
|
+
payload = json.loads(sys.stdin.read().split("INPUT JSON:\\n", 1)[1])
|
|
260
|
+
with open(os.environ["STUB_CALL_LOG"], "a") as stream:
|
|
261
|
+
stream.write(json.dumps(payload) + "\\n")
|
|
262
|
+
print("discard-prefix:" + "x" * 320 + " final command diagnostic", file=sys.stderr)
|
|
263
|
+
print("not JSON")
|
|
264
|
+
sys.exit(int(sys.argv[1]))
|
|
265
|
+
''')
|
|
266
|
+
monkeypatch.setenv("READER_COMPILE_CMD", shlex.join([
|
|
267
|
+
sys.executable, str(command), "10" if outcome == "nonzero" else "0"]))
|
|
268
|
+
with checks.raises(reader.ReaderError) as exc:
|
|
269
|
+
compile_npm()
|
|
270
|
+
assert exc.value.code == 11
|
|
271
|
+
assert len(calls()) == 8
|
|
272
|
+
for index in range(0, 8, 2):
|
|
273
|
+
assert calls()[index] == calls()[index + 1]
|
|
274
|
+
tail = ("discard-prefix:" + "x" * 320 + " final command diagnostic\n")[-300:]
|
|
275
|
+
assert str(exc.value).endswith("compile stderr: " + tail)
|
|
276
|
+
assert "discard-prefix" not in str(exc.value)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def test_compile_css_refinement_carries_failing_rows(monkeypatch, tmp_path):
|
|
280
|
+
schema = {"type": "css", "schema": {"name": "prices", "baseSelector": "tr", "fields": [
|
|
281
|
+
{"name": "model", "selector": ".model", "type": "text"},
|
|
282
|
+
{"name": "input", "selector": ".input", "type": "text"},
|
|
283
|
+
{"name": "output", "selector": ".output", "type": "text"}]},
|
|
284
|
+
"transforms": {"input": "number", "output": "number"}}
|
|
285
|
+
command = tmp_path / "compile.py"
|
|
286
|
+
command.write_text('''import json, os, sys
|
|
287
|
+
prompt = sys.stdin.read()
|
|
288
|
+
payload = json.loads(prompt.split("INPUT JSON:\\n", 1)[1])
|
|
289
|
+
with open(os.environ["STUB_CALL_LOG"], "a") as stream:
|
|
290
|
+
stream.write(json.dumps({"prompt": prompt, "payload": payload}) + "\\n")
|
|
291
|
+
schema = json.loads(sys.argv[1])
|
|
292
|
+
if payload.get("validation_failures"):
|
|
293
|
+
schema["schema"]["baseSelector"] = "table.prices tr"
|
|
294
|
+
print(json.dumps(schema))
|
|
295
|
+
''')
|
|
296
|
+
monkeypatch.setenv("READER_COMPILE_CMD", shlex.join([sys.executable, str(command), json.dumps(schema)]))
|
|
297
|
+
page = ('<table><tr><td>empty row is skipped by extraction</td></tr></table>'
|
|
298
|
+
'<table class="prices"><tr><td class="model">Claude</td>'
|
|
299
|
+
'<td class="input">2</td><td class="output">10</td></tr></table><table>')
|
|
300
|
+
for index in range(5):
|
|
301
|
+
page += f'<tr><td class="model">Unrelated {index}</td><td>{"x" * 800}</td></tr>'
|
|
302
|
+
page += '</table>'
|
|
303
|
+
spec, rows = reader.compile_spec("prices", "https://public.example", {}, "model prices",
|
|
304
|
+
{"model": "Claude", "input": 2, "output": 10}, page)
|
|
305
|
+
assert rows == [{"model": "Claude", "input": 2, "output": 10}]
|
|
306
|
+
assert spec["schema"]["baseSelector"] == "table.prices tr"
|
|
307
|
+
assert len(calls()) == 2
|
|
308
|
+
refinement = calls()[1]
|
|
309
|
+
assert "narrow baseSelector so only rows matching the want" in refinement["prompt"]
|
|
310
|
+
samples = refinement["payload"]["failing_row_samples"]
|
|
311
|
+
assert len(samples) == 3
|
|
312
|
+
assert [sample["index"] for sample in samples] == [1, 2, 3]
|
|
313
|
+
for index, sample in enumerate(samples):
|
|
314
|
+
assert json.loads(sample["values"]) == {"model": f"Unrelated {index}"}
|
|
315
|
+
assert f'Unrelated {index}</td>' in sample["outer_html"]
|
|
316
|
+
assert sample["outer_html"].startswith('<tr>')
|
|
317
|
+
assert len(sample["outer_html"]) == 600
|
|
318
|
+
assert len(sample["values"]) <= 600
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def test_compile_succeeds_on_third_refinement(monkeypatch, tmp_path):
|
|
322
|
+
command = tmp_path / "compile.py"
|
|
323
|
+
command.write_text(f'''import os, runpy
|
|
324
|
+
from pathlib import Path
|
|
325
|
+
log = Path(os.environ["STUB_CALL_LOG"])
|
|
326
|
+
count = len(log.read_text().splitlines()) if log.exists() else 0
|
|
327
|
+
os.environ["STUB_MODE"] = "broken" if count < 3 else "npm"
|
|
328
|
+
runpy.run_path({str(DATA / "compile_stub.py")!r}, run_name="__main__")
|
|
329
|
+
''')
|
|
330
|
+
monkeypatch.setenv("READER_COMPILE_CMD", shlex.join([sys.executable, str(command)]))
|
|
331
|
+
spec, rows = compile_npm()
|
|
332
|
+
assert len(calls()) == 4
|
|
333
|
+
assert reader.failures(spec, rows) == []
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def test_missing_compile_command_is_exit_11(monkeypatch):
|
|
337
|
+
monkeypatch.setenv("READER_COMPILE_CMD", "reader-command-does-not-exist")
|
|
338
|
+
with checks.raises(reader.ReaderError) as exc:
|
|
339
|
+
compile_npm()
|
|
340
|
+
assert exc.value.code == 11
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def load_tests(loader, suite, pattern):
|
|
345
|
+
return checks.suite(globals())
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
from reader_test_support import *
|
|
2
|
+
|
|
3
|
+
def test_validation_each_row_example_keys_and_numeric():
|
|
4
|
+
spec = {"required": ["name", "input"], "example": {"name": "Claude", "input": 1, "cache": None}}
|
|
5
|
+
assert reader.failures(spec, [{"name": "Claude", "input": 0, "cache": None}]) == []
|
|
6
|
+
result = reader.failures(spec, [{"name": "Claude", "input": 1, "cache": None}, {"name": " ", "input": "$2"}])
|
|
7
|
+
assert set(result) == {"row[1].name", "row[1].cache", "row[1].input must be numeric"}
|
|
8
|
+
assert "list rows >= 1" in reader.failures(spec, [])
|
|
9
|
+
assert reader.failures(spec, ["invalid"])
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_optional_numeric_fields_allow_null_but_keep_example_keys():
|
|
13
|
+
spec = {"required": ["model", "input", "output"],
|
|
14
|
+
"example": {"model": "Claude", "input": 2, "output": 10,
|
|
15
|
+
"cache_write": 2.5, "cache_read": 0.2}}
|
|
16
|
+
row = {"model": "Claude", "input": 2, "output": 10, "cache_write": None, "cache_read": None}
|
|
17
|
+
assert reader.failures(spec, [row]) == []
|
|
18
|
+
assert reader.failures(spec, [{**row, "cache_read": "0.2"}]) == ["row[0].cache_read must be numeric"]
|
|
19
|
+
assert reader.failures(spec, [{**row, "input": None}]) == ["row[0].input"]
|
|
20
|
+
del row["cache_read"]
|
|
21
|
+
assert reader.failures(spec, [row]) == ["row[0].cache_read"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@checks.mark.parametrize("value", [None, "", " ", "x", "ab", 0, 2.5, False, [], {}])
|
|
25
|
+
def test_copy_check_skips_null_empty_short_strings_and_numbers(value):
|
|
26
|
+
spec = {"required": ["name"], "example": {"first": "example one", "second": "example two"}}
|
|
27
|
+
assert reader.failures(spec, [{"name": "row", "first": value, "second": value}]) == []
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_free_model_equal_numeric_prices_are_valid():
|
|
31
|
+
spec = {"required": ["model", "input", "output"],
|
|
32
|
+
"example": {"model": "paid", "input": 2, "output": 10}}
|
|
33
|
+
assert reader.failures(spec, [{"model": "free", "input": 0, "output": 0}]) == []
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@checks.mark.parametrize("example", [
|
|
37
|
+
{"version": "1.2.3", "license": "MIT"},
|
|
38
|
+
[{"version": "1.2.3", "license": "MIT"}],
|
|
39
|
+
])
|
|
40
|
+
def test_validation_rejects_cross_field_value_in_each_row(example):
|
|
41
|
+
spec = {"required": ["version", "license"], "example": example}
|
|
42
|
+
rows = [{"version": "4.6.7", "license": "MIT"},
|
|
43
|
+
{"version": "4.6.7", "license": "4.6.7"}]
|
|
44
|
+
assert reader.failures(spec, rows) == [
|
|
45
|
+
"row[1].license equals version; example values differ"]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@checks.mark.parametrize("example", [None, {}, {"version": "same", "license": "same"}])
|
|
49
|
+
def test_validation_allows_equal_values_without_differing_example(example):
|
|
50
|
+
spec = {"required": ["version", "license"], "example": example}
|
|
51
|
+
assert reader.failures(spec, [{"version": "same", "license": "same"}]) == []
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@checks.mark.parametrize("value, expected", [("144,621,339", 144621339), (0, 0),
|
|
55
|
+
("1.25", 1.25), ("inf", None)])
|
|
56
|
+
def test_number_transform_preserves_integral_type(value, expected):
|
|
57
|
+
row = reader.normalize([{"weekly_downloads": value}], {"weekly_downloads": "number"})[0]
|
|
58
|
+
assert row["weekly_downloads"] == expected
|
|
59
|
+
assert type(row["weekly_downloads"]) is type(expected)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_cross_field_mismatch_heals_once_and_no_heal_exits_12(monkeypatch):
|
|
63
|
+
spec, _ = compile_npm()
|
|
64
|
+
spec["paths"]["license"] = spec["paths"]["latest_version"]
|
|
65
|
+
reader.save(spec)
|
|
66
|
+
monkeypatch.setattr(reader, "fetch", lambda url, **kwargs: (html(), "urllib"))
|
|
67
|
+
before = len(calls())
|
|
68
|
+
with checks.raises(reader.ReaderError) as exc:
|
|
69
|
+
reader.run_spec(reader.load("npm-package"), {}, True)
|
|
70
|
+
assert exc.value.code == 12
|
|
71
|
+
assert "latest_version equals license" in str(exc.value)
|
|
72
|
+
assert len(calls()) == before
|
|
73
|
+
result = reader.run_spec(reader.load("npm-package"), {}, False)
|
|
74
|
+
assert result["rows"][0]["license"] == "MIT"
|
|
75
|
+
assert len(calls()) == before + 1
|
|
76
|
+
assert reader.load("npm-package")["healed_at"]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_run_and_heal_leave_shipped_specs_byte_for_byte_unchanged(monkeypatch):
|
|
80
|
+
spec, _ = compile_npm()
|
|
81
|
+
reader.save(spec)
|
|
82
|
+
spec["name"] = "broken"
|
|
83
|
+
spec["paths"]["license"] = "broken.path"
|
|
84
|
+
reader.save(spec)
|
|
85
|
+
directory = reader.spec_path("unused").parent
|
|
86
|
+
|
|
87
|
+
def hashes():
|
|
88
|
+
return {str(path): hashlib.sha256(path.read_bytes()).hexdigest()
|
|
89
|
+
for path in directory.rglob("*") if path.is_file()}
|
|
90
|
+
|
|
91
|
+
before = hashes()
|
|
92
|
+
monkeypatch.setattr(reader, "fetch", lambda url, **kwargs: (html(), "crwl"))
|
|
93
|
+
monkeypatch.setattr(reader, "now", lambda: "2026-10-23T12:00:00+00:00")
|
|
94
|
+
assert reader.main(["run", "npm-package"]) == 0
|
|
95
|
+
assert hashes() == before
|
|
96
|
+
state = json.loads(reader.state_path("npm-package").read_text())
|
|
97
|
+
assert state == {"last_ok": reader.now(), "healed_at": None,
|
|
98
|
+
"fetch_method": "crwl", "page_hash": reader.page_hash(html())}
|
|
99
|
+
assert not reader.override_path("npm-package").exists()
|
|
100
|
+
assert reader.main(["run", "broken"]) == 0
|
|
101
|
+
assert hashes() == before
|
|
102
|
+
assert reader.override_path("broken").exists()
|
|
103
|
+
assert reader.load("broken")["healed_at"] == reader.now()
|
|
104
|
+
assert json.loads(reader.state_path("broken").read_text())["healed_at"] == reader.now()
|
|
105
|
+
# A later successful run uses the healed rules without another compile.
|
|
106
|
+
before_calls = len(calls())
|
|
107
|
+
assert reader.main(["run", "broken"]) == 0
|
|
108
|
+
assert len(calls()) == before_calls
|
|
109
|
+
assert hashes() == before
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_state_default_and_override_precedence(monkeypatch, tmp_path):
|
|
113
|
+
monkeypatch.delenv("READER_STATE_DIR")
|
|
114
|
+
monkeypatch.setattr(reader.Path, "home", lambda: tmp_path)
|
|
115
|
+
assert reader.state_path("npm-package") == tmp_path / ".cache/lean-browser-use/reader/npm-package.json"
|
|
116
|
+
spec, _ = compile_npm()
|
|
117
|
+
reader.save(spec)
|
|
118
|
+
healed = {**spec, "want": "healed request"}
|
|
119
|
+
reader.save(healed, runtime=True)
|
|
120
|
+
assert reader.load("npm-package")["want"] == "healed request"
|
|
121
|
+
reader.save_state(healed, html(), "crwl")
|
|
122
|
+
assert reader.load("npm-package")["fetch_method"] == "crwl"
|
|
123
|
+
# An explicit compile supersedes stale healed rules and fetch preferences.
|
|
124
|
+
reader.save(spec)
|
|
125
|
+
assert not reader.override_path("npm-package").exists()
|
|
126
|
+
assert not reader.state_path("npm-package").exists()
|
|
127
|
+
assert reader.load("npm-package")["want"] == spec["want"]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_ls_reports_source_and_includes_state_only_specs(capsys):
|
|
131
|
+
spec, _ = compile_npm()
|
|
132
|
+
reader.save(spec)
|
|
133
|
+
assert reader.main(["ls"]) == 0
|
|
134
|
+
assert str(reader.spec_path("npm-package")) in capsys.readouterr().out
|
|
135
|
+
reader.save(spec, runtime=True)
|
|
136
|
+
spec["name"] = "state-only"
|
|
137
|
+
reader.save(spec, runtime=True)
|
|
138
|
+
assert reader.main(["ls"]) == 0
|
|
139
|
+
lines = capsys.readouterr().out.splitlines()
|
|
140
|
+
assert lines[0].endswith("\tsource")
|
|
141
|
+
assert len(lines) == 3
|
|
142
|
+
assert lines[1].endswith(str(reader.override_path("npm-package")))
|
|
143
|
+
assert lines[2].endswith(str(reader.override_path("state-only")))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_run_valid_spec_never_calls_compile(monkeypatch):
|
|
147
|
+
spec, _ = compile_npm()
|
|
148
|
+
reader.save(spec)
|
|
149
|
+
before = len(calls())
|
|
150
|
+
fetch = mock.Mock(return_value=(html("zod"), "urllib"))
|
|
151
|
+
monkeypatch.setattr(reader, "fetch", fetch)
|
|
152
|
+
compiler = mock.Mock(side_effect=AssertionError("run must use no AI"))
|
|
153
|
+
monkeypatch.setattr(reader, "compile_spec", compiler)
|
|
154
|
+
result = reader.run_spec(reader.load("npm-package"), {"name": "zod"}, False)
|
|
155
|
+
assert result["rows"][0]["name"] == "zod"
|
|
156
|
+
assert result["meta"] == {"fetch_method": "urllib"}
|
|
157
|
+
fetch.assert_called_once_with("https://www.npmjs.com/package/zod", preferred="urllib")
|
|
158
|
+
compiler.assert_not_called()
|
|
159
|
+
assert len(calls()) == before
|
|
160
|
+
assert reader.load("npm-package")["last_ok"]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@checks.mark.parametrize("heal", [False, True])
|
|
164
|
+
def test_run_and_heal_ignore_stale_example(monkeypatch, capsys, heal):
|
|
165
|
+
spec, _ = compile_npm()
|
|
166
|
+
if heal:
|
|
167
|
+
spec["paths"]["license"] = "broken.selector"
|
|
168
|
+
reader.save(spec)
|
|
169
|
+
page = html().replace('"19.2.0"', '"20.0.0"')
|
|
170
|
+
monkeypatch.setattr(reader, "fetch", lambda url, **kwargs: (page, "urllib"))
|
|
171
|
+
before = len(calls())
|
|
172
|
+
assert reader.main(["run", "npm-package"]) == 0
|
|
173
|
+
captured = capsys.readouterr()
|
|
174
|
+
assert json.loads(captured.out)["rows"][0]["latest_version"] == "20.0.0"
|
|
175
|
+
assert captured.err == ""
|
|
176
|
+
assert len(calls()) == before + int(heal)
|
|
177
|
+
saved = reader.load("npm-package")
|
|
178
|
+
assert saved["example"]["latest_version"] == "19.2.0"
|
|
179
|
+
assert bool(saved["healed_at"]) == heal
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_run_heals_once_and_no_heal_second_break(monkeypatch):
|
|
183
|
+
spec, _ = compile_npm()
|
|
184
|
+
spec["paths"]["license"] = "broken.selector"
|
|
185
|
+
reader.save(spec)
|
|
186
|
+
monkeypatch.setattr(reader, "fetch", lambda url, **kwargs: (html(), "crwl"))
|
|
187
|
+
before = len(calls())
|
|
188
|
+
result = reader.run_spec(reader.load("npm-package"), {}, False)
|
|
189
|
+
assert result["rows"][0]["license"] == "MIT"
|
|
190
|
+
assert result["meta"] == {"fetch_method": "crwl"}
|
|
191
|
+
assert len(calls()) == before + 1
|
|
192
|
+
saved = reader.load("npm-package")
|
|
193
|
+
assert saved["healed_at"]
|
|
194
|
+
assert saved["fetch_method"] == "crwl"
|
|
195
|
+
saved["paths"]["license"] = "second.break"
|
|
196
|
+
reader.save(saved, runtime=True)
|
|
197
|
+
with checks.raises(reader.ReaderError) as exc:
|
|
198
|
+
reader.run_spec(reader.load("npm-package"), {}, True)
|
|
199
|
+
assert exc.value.code == 12
|
|
200
|
+
assert "license" in str(exc.value)
|
|
201
|
+
assert len(calls()) == before + 1
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def test_failed_heal_keeps_old_spec_and_exits_12(monkeypatch):
|
|
205
|
+
spec, _ = compile_npm()
|
|
206
|
+
spec["paths"]["license"] = "broken"
|
|
207
|
+
reader.save(spec)
|
|
208
|
+
before = reader.spec_path("npm-package").read_bytes()
|
|
209
|
+
monkeypatch.setattr(reader, "fetch", lambda url, **kwargs: (html(), "urllib"))
|
|
210
|
+
monkeypatch.setenv("STUB_MODE", "broken")
|
|
211
|
+
with checks.raises(reader.ReaderError) as exc:
|
|
212
|
+
reader.run_spec(reader.load("npm-package"), {}, False)
|
|
213
|
+
assert exc.value.code == 12
|
|
214
|
+
assert "license" in str(exc.value)
|
|
215
|
+
assert len(calls()) == 5 # initial compile plus one compile cycle of 4 attempts
|
|
216
|
+
assert reader.spec_path("npm-package").read_bytes() == before
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def test_run_fetch_failure_skips_heal(monkeypatch):
|
|
220
|
+
spec, _ = compile_npm()
|
|
221
|
+
monkeypatch.setattr(reader, "fetch", mock.Mock(side_effect=reader.ReaderError(10, "offline")))
|
|
222
|
+
with checks.raises(reader.ReaderError) as exc:
|
|
223
|
+
reader.run_spec(spec, {}, False)
|
|
224
|
+
assert exc.value.code == 10
|
|
225
|
+
assert len(calls()) == 1
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_css_subprocess_contract_and_snapshot(monkeypatch):
|
|
229
|
+
spec = json.loads((DATA / "pricing-schema.json").read_text())
|
|
230
|
+
|
|
231
|
+
def crwl(command, **kwargs):
|
|
232
|
+
assert command == [reader.crawl4ai_python(), str(ROOT / "crawl_helper.py"), "css"]
|
|
233
|
+
assert json.loads(kwargs["input"]) == {"html": "<html>snapshot</html>", "schema": spec["schema"]}
|
|
234
|
+
rows = [
|
|
235
|
+
{"model": "Claude Sonnet 5.5 Description", "input": "$2 / MTok", "output": "$10 / MTok",
|
|
236
|
+
"cache_write_5m": "$2.5", "cache_write_1h": "$4", "cache_read": "$0.10"}]
|
|
237
|
+
return subprocess.CompletedProcess(command, 0, json.dumps({"rows": rows}), "")
|
|
238
|
+
|
|
239
|
+
monkeypatch.setattr(reader.subprocess, "run", crwl)
|
|
240
|
+
result = reader.extract(spec, "<html>snapshot</html>")
|
|
241
|
+
assert result[0]["model"] == "Claude Sonnet 5.5"
|
|
242
|
+
assert result[0]["input"] == 2 and result[0]["output"] == 10
|
|
243
|
+
assert reader.failures(spec, result) == []
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_real_crawl4ai_pricing_snapshot():
|
|
247
|
+
if not Path(reader.crawl4ai_python()).exists() and not reader.shutil.which(reader.crawl4ai_python()):
|
|
248
|
+
checks.skip("optional crawl4ai integration; subprocess contract is tested separately")
|
|
249
|
+
spec = json.loads((DATA / "pricing-schema.json").read_text())
|
|
250
|
+
result = reader.extract(spec, (DATA / "claude-pricing.html").read_text())
|
|
251
|
+
assert len(result) == 16
|
|
252
|
+
assert len({row["model"] for row in result}) == 16
|
|
253
|
+
assert reader.failures(spec, result) == []
|
|
254
|
+
by_name = {row["model"]: row for row in result}
|
|
255
|
+
assert (by_name["Claude Fable 5.1"]["input"], by_name["Claude Fable 5.1"]["output"]) == (10, 50)
|
|
256
|
+
assert (by_name["Claude Sonnet 5.5"]["input"], by_name["Claude Sonnet 5.5"]["output"]) == (2, 10)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def test_proxy_connector_uses_checked_sockaddr_without_dns(monkeypatch):
|
|
260
|
+
monkeypatch.setattr(socket, "socket", REAL_SOCKET)
|
|
261
|
+
|
|
262
|
+
async def check():
|
|
263
|
+
sock = mock.Mock()
|
|
264
|
+
create = mock.Mock(return_value=sock)
|
|
265
|
+
monkeypatch.setattr(socket, "socket", create)
|
|
266
|
+
connect = mock.AsyncMock()
|
|
267
|
+
monkeypatch.setattr(asyncio.get_running_loop(), "sock_connect", connect)
|
|
268
|
+
streams = (mock.Mock(), mock.Mock())
|
|
269
|
+
opened = mock.AsyncMock(return_value=streams)
|
|
270
|
+
monkeypatch.setattr(asyncio, "open_connection", opened)
|
|
271
|
+
assert await helper["pinned_connection"](public_address()) == streams
|
|
272
|
+
create.assert_called_once_with(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP)
|
|
273
|
+
sock.setblocking.assert_called_once_with(False)
|
|
274
|
+
connect.assert_awaited_once_with(sock, (PUBLIC_IP, 443))
|
|
275
|
+
opened.assert_awaited_once_with(sock=sock)
|
|
276
|
+
socket.getaddrinfo.assert_not_called()
|
|
277
|
+
|
|
278
|
+
asyncio.run(check())
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@checks.mark.parametrize("raw_request", [b"GET http://public.example/ HTTP/1.1\r\n\r\n",
|
|
282
|
+
b"CONNECT public.example:443 HTTP/1.1\r\n\r\n"])
|
|
283
|
+
@checks.mark.parametrize("addresses", [[], [public_address(), public_address("127.0.0.1")],
|
|
284
|
+
[public_address(".".join(['169', '254', '169', '254']))]])
|
|
285
|
+
def test_proxy_refuses_unresolved_or_nonpublic_targets(monkeypatch, raw_request, addresses):
|
|
286
|
+
monkeypatch.setattr(socket, "socket", REAL_SOCKET)
|
|
287
|
+
socket.getaddrinfo.side_effect = None
|
|
288
|
+
socket.getaddrinfo.return_value = addresses
|
|
289
|
+
connector = mock.AsyncMock(side_effect=AssertionError("refused address must never connect"))
|
|
290
|
+
monkeypatch.setitem(helper["FilteringProxy"].handle.__globals__, "pinned_connection", connector)
|
|
291
|
+
|
|
292
|
+
async def check():
|
|
293
|
+
async with helper["FilteringProxy"]() as proxy:
|
|
294
|
+
source, client = await asyncio.open_connection("127.0.0.1", proxy.port)
|
|
295
|
+
client.write(raw_request)
|
|
296
|
+
await client.drain()
|
|
297
|
+
assert (await source.read()).startswith(b"HTTP/1.1 502")
|
|
298
|
+
assert len(proxy.blocked) == 1
|
|
299
|
+
assert "non-public" in proxy.blocked[0] or "no addresses" in proxy.blocked[0]
|
|
300
|
+
connector.assert_not_called()
|
|
301
|
+
client.close()
|
|
302
|
+
await client.wait_closed()
|
|
303
|
+
|
|
304
|
+
asyncio.run(asyncio.wait_for(check(), 10))
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def load_tests(loader, suite, pattern):
|
|
309
|
+
return checks.suite(globals())
|