webget-cli 0.14.0__tar.gz → 0.15.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. {webget_cli-0.14.0/webget_cli.egg-info → webget_cli-0.15.0}/PKG-INFO +37 -2
  2. {webget_cli-0.14.0 → webget_cli-0.15.0}/README.md +36 -1
  3. {webget_cli-0.14.0 → webget_cli-0.15.0}/pyproject.toml +1 -1
  4. {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_adversarial_http.py +6 -1
  5. webget_cli-0.15.0/tests/test_browser_discovery.py +270 -0
  6. webget_cli-0.15.0/tests/test_doctor.py +119 -0
  7. webget_cli-0.15.0/tests/test_truncate.py +88 -0
  8. webget_cli-0.15.0/webget/browser.py +276 -0
  9. {webget_cli-0.14.0 → webget_cli-0.15.0}/webget/cli.py +125 -1
  10. {webget_cli-0.14.0 → webget_cli-0.15.0}/webget/firecrawl.py +3 -1
  11. {webget_cli-0.14.0 → webget_cli-0.15.0}/webget/http.py +3 -2
  12. {webget_cli-0.14.0 → webget_cli-0.15.0}/webget/ladder.py +77 -46
  13. webget_cli-0.15.0/webget/truncate.py +50 -0
  14. {webget_cli-0.14.0 → webget_cli-0.15.0/webget_cli.egg-info}/PKG-INFO +37 -2
  15. {webget_cli-0.14.0 → webget_cli-0.15.0}/webget_cli.egg-info/SOURCES.txt +5 -0
  16. {webget_cli-0.14.0 → webget_cli-0.15.0}/LICENSE +0 -0
  17. {webget_cli-0.14.0 → webget_cli-0.15.0}/setup.cfg +0 -0
  18. {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_adversarial_auth.py +0 -0
  19. {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_adversarial_cache.py +0 -0
  20. {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_adversarial_concurrency.py +0 -0
  21. {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_adversarial_mcp.py +0 -0
  22. {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_adversarial_ssrf.py +0 -0
  23. {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_auth_review.py +0 -0
  24. {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_base64_strip.py +0 -0
  25. {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_browser_ssrf.py +0 -0
  26. {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_cache_review.py +0 -0
  27. {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_cli_engine.py +0 -0
  28. {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_cli_provenance.py +0 -0
  29. {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_concurrency_review.py +0 -0
  30. {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_discovery_map.py +0 -0
  31. {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_extraction_markdown.py +0 -0
  32. {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_firecrawl_policy.py +0 -0
  33. {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_health.py +0 -0
  34. {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_integration_ladder.py +0 -0
  35. {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_ladder_retry.py +0 -0
  36. {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_login_flow.py +0 -0
  37. {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_mcp_engine.py +0 -0
  38. {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_mcp_leak_review.py +0 -0
  39. {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_mcp_map.py +0 -0
  40. {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_mcp_metadata.py +0 -0
  41. {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_mcp_profile.py +0 -0
  42. {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_mcp_provenance.py +0 -0
  43. {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_mcp_server.py +0 -0
  44. {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_mcp_smoke.py +0 -0
  45. {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_metadata.py +0 -0
  46. {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_nonhtml.py +0 -0
  47. {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_search_engine.py +0 -0
  48. {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_search_failover.py +0 -0
  49. {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_search_provenance.py +0 -0
  50. {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_security_review.py +0 -0
  51. {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_size_review.py +0 -0
  52. {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_ssrf_dual_dns.py +0 -0
  53. {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_webget.py +0 -0
  54. {webget_cli-0.14.0 → webget_cli-0.15.0}/webget/__init__.py +0 -0
  55. {webget_cli-0.14.0 → webget_cli-0.15.0}/webget/cache.py +0 -0
  56. {webget_cli-0.14.0 → webget_cli-0.15.0}/webget/discovery.py +0 -0
  57. {webget_cli-0.14.0 → webget_cli-0.15.0}/webget/health.py +0 -0
  58. {webget_cli-0.14.0 → webget_cli-0.15.0}/webget/profile.py +0 -0
  59. {webget_cli-0.14.0 → webget_cli-0.15.0}/webget/search.py +0 -0
  60. {webget_cli-0.14.0 → webget_cli-0.15.0}/webget/ssrf.py +0 -0
  61. {webget_cli-0.14.0 → webget_cli-0.15.0}/webget_cli.egg-info/dependency_links.txt +0 -0
  62. {webget_cli-0.14.0 → webget_cli-0.15.0}/webget_cli.egg-info/entry_points.txt +0 -0
  63. {webget_cli-0.14.0 → webget_cli-0.15.0}/webget_cli.egg-info/requires.txt +0 -0
  64. {webget_cli-0.14.0 → webget_cli-0.15.0}/webget_cli.egg-info/top_level.txt +0 -0
  65. {webget_cli-0.14.0 → webget_cli-0.15.0}/webget_cli.py +0 -0
  66. {webget_cli-0.14.0 → webget_cli-0.15.0}/webget_mcp.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webget-cli
3
- Version: 0.14.0
3
+ Version: 0.15.0
4
4
  Summary: Local search + scrape CLI with an HTTP fast path, optional browser fallback, and authenticated session profiles. Zero API keys.
5
5
  Author-email: David Tarigan <tarigansdavid@gmail.com>
6
6
  License: Apache-2.0
@@ -103,7 +103,7 @@ webget --help
103
103
 
104
104
  ### Browser runtime (optional)
105
105
 
106
- Crawl4AI drives a Playwright Chromium. `pip install "webget-cli[browser]"`
106
+ Crawl4AI drives a Playwright browser. `pip install "webget-cli[browser]"`
107
107
  installs the Python packages; the browser binary itself is downloaded
108
108
  separately:
109
109
 
@@ -111,6 +111,41 @@ separately:
111
111
  python -m playwright install chromium
112
112
  ```
113
113
 
114
+ **You may not need that download.** Before reaching for Playwright's bundled
115
+ Chromium, webget looks for a Chromium-family browser already installed on the
116
+ machine and uses it when it can actually be driven. Run `webget doctor` to see
117
+ what was found and what will be used:
118
+
119
+ ```bash
120
+ webget doctor
121
+ ```
122
+
123
+ Resolution order (first match wins):
124
+
125
+ | Step | Source | Notes |
126
+ |---|---|---|
127
+ | 1 | `WEBGET_BROWSER_CDP` | Attach to a running browser over CDP. The only route that works for browsers with no Playwright channel. |
128
+ | 2 | `WEBGET_BROWSER_CHANNEL` | A Playwright channel: `chrome`, `msedge`, or `chromium`. |
129
+ | 3 | `WEBGET_BROWSER_PATH` | An explicit binary. Accepted, but see the limitation below. |
130
+ | 4 | Auto-detected browser | Chrome, Edge, Chromium, Brave, Vivaldi or Opera found on the machine. |
131
+ | 5 | Playwright bundled Chromium | The fallback when nothing usable is found. |
132
+
133
+ Two limitations worth knowing, both reported honestly by `webget doctor`
134
+ instead of failing quietly:
135
+
136
+ - **Brave, Vivaldi and Opera cannot be launched by crawl4ai.** Playwright has no
137
+ channel for them, and crawl4ai 0.9.2 does not forward `executable_path` to
138
+ Playwright, so there is no path to the binary. They are detected and reported
139
+ as NOT USABLE. The workaround is CDP: start the browser with
140
+ `--remote-debugging-port=9222`, then set
141
+ `WEBGET_BROWSER_CDP=http://127.0.0.1:9222`.
142
+ - **CDP is opt-in on purpose.** Attaching to a browser you are already logged
143
+ into mixes your personal session cookies into crawl output, so webget never
144
+ auto-detects an open debugging port.
145
+
146
+ System Firefox is never selected: Playwright drives Firefox over WebDriver BiDi
147
+ with its own patched build.
148
+
114
149
  Without the browser extra, `webget` still works for search and plain HTTP
115
150
  fetches. A fetch that needs the browser (JS rendering, `--profile` sessions,
116
151
  `login`) prints a clear warning telling you how to install it.
@@ -66,7 +66,7 @@ webget --help
66
66
 
67
67
  ### Browser runtime (optional)
68
68
 
69
- Crawl4AI drives a Playwright Chromium. `pip install "webget-cli[browser]"`
69
+ Crawl4AI drives a Playwright browser. `pip install "webget-cli[browser]"`
70
70
  installs the Python packages; the browser binary itself is downloaded
71
71
  separately:
72
72
 
@@ -74,6 +74,41 @@ separately:
74
74
  python -m playwright install chromium
75
75
  ```
76
76
 
77
+ **You may not need that download.** Before reaching for Playwright's bundled
78
+ Chromium, webget looks for a Chromium-family browser already installed on the
79
+ machine and uses it when it can actually be driven. Run `webget doctor` to see
80
+ what was found and what will be used:
81
+
82
+ ```bash
83
+ webget doctor
84
+ ```
85
+
86
+ Resolution order (first match wins):
87
+
88
+ | Step | Source | Notes |
89
+ |---|---|---|
90
+ | 1 | `WEBGET_BROWSER_CDP` | Attach to a running browser over CDP. The only route that works for browsers with no Playwright channel. |
91
+ | 2 | `WEBGET_BROWSER_CHANNEL` | A Playwright channel: `chrome`, `msedge`, or `chromium`. |
92
+ | 3 | `WEBGET_BROWSER_PATH` | An explicit binary. Accepted, but see the limitation below. |
93
+ | 4 | Auto-detected browser | Chrome, Edge, Chromium, Brave, Vivaldi or Opera found on the machine. |
94
+ | 5 | Playwright bundled Chromium | The fallback when nothing usable is found. |
95
+
96
+ Two limitations worth knowing, both reported honestly by `webget doctor`
97
+ instead of failing quietly:
98
+
99
+ - **Brave, Vivaldi and Opera cannot be launched by crawl4ai.** Playwright has no
100
+ channel for them, and crawl4ai 0.9.2 does not forward `executable_path` to
101
+ Playwright, so there is no path to the binary. They are detected and reported
102
+ as NOT USABLE. The workaround is CDP: start the browser with
103
+ `--remote-debugging-port=9222`, then set
104
+ `WEBGET_BROWSER_CDP=http://127.0.0.1:9222`.
105
+ - **CDP is opt-in on purpose.** Attaching to a browser you are already logged
106
+ into mixes your personal session cookies into crawl output, so webget never
107
+ auto-detects an open debugging port.
108
+
109
+ System Firefox is never selected: Playwright drives Firefox over WebDriver BiDi
110
+ with its own patched build.
111
+
77
112
  Without the browser extra, `webget` still works for search and plain HTTP
78
113
  fetches. A fetch that needs the browser (JS rendering, `--profile` sessions,
79
114
  `login`) prints a clear warning telling you how to install it.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "webget-cli"
7
- version = "0.14.0"
7
+ version = "0.15.0"
8
8
  description = "Local search + scrape CLI with an HTTP fast path, optional browser fallback, and authenticated session profiles. Zero API keys."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -166,9 +166,14 @@ class TestResponseBodies:
166
166
  assert _one(res)["status"] == "success" # httpx auto-decompresses
167
167
 
168
168
  def test_huge_response_is_bounded(self, fresh_cache):
169
+ from webget.truncate import ELLIPSIS_MARKER
170
+
169
171
  server = fresh_cache
170
172
  # 5MB body; scrape_many must truncate, not blow memory or hang.
171
173
  res = asyncio.run(_fetch(server.url("/huge"), max_chars=1000))
172
174
  out = _one(res)
173
175
  assert out["status"] == "success"
174
- assert len(out["markdown"]) <= 1000
176
+ # smart_truncate cuts at the limit and appends the ellipsis marker
177
+ # (this body has no clean boundary, so it is a hard cut at the limit).
178
+ assert len(out["markdown"]) <= 1000 + len(ELLIPSIS_MARKER)
179
+ assert out["markdown"].endswith(ELLIPSIS_MARKER)
@@ -0,0 +1,270 @@
1
+ """Tests for browser discovery and the ladder's use of it.
2
+
3
+ No Chrome needed: everything here stubs the filesystem probe or checks the
4
+ precedence rules directly. The point is that the resolution ORDER and the
5
+ usability verdict are the product, not the individual detections.
6
+
7
+ The usability rules encode a hard constraint discovered by running the real
8
+ crawl4ai 0.9.2:
9
+
10
+ browser_manager._build_browser_args() returns
11
+ {"headless": ..., "args": ..., "channel": ...}
12
+ and then calls playwright.chromium.launch(**browser_args).
13
+
14
+ There is no executable_path in that dict, so a browser without a Playwright
15
+ channel (Brave, Vivaldi, Opera) cannot be launched at all, even though
16
+ Playwright itself supports executable_path. BrowserConfig(...) even raises
17
+ TypeError on executable_path. These tests pin that reality down so a future
18
+ crawl4ai upgrade that fixes it will fail loudly here instead of silently.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ import unittest
24
+ from unittest import mock
25
+
26
+ from webget import browser as browser_mod
27
+
28
+
29
+ class ScanTest(unittest.TestCase):
30
+ def test_brave_has_no_playwright_channel(self):
31
+ with mock.patch("shutil.which") as which, mock.patch.object(
32
+ browser_mod, "_macos_bundle", return_value=None
33
+ ):
34
+ which.side_effect = lambda b: "/usr/bin/brave" if b == "brave-browser" else None
35
+ found = browser_mod.scan_installed_browsers()
36
+ self.assertEqual(found, [("brave", None, "/usr/bin/brave")])
37
+
38
+ def test_chrome_maps_to_channel(self):
39
+ with mock.patch("shutil.which") as which, mock.patch.object(
40
+ browser_mod, "_macos_bundle", return_value=None
41
+ ):
42
+ which.side_effect = lambda b: "/usr/bin/google-chrome" if b == "google-chrome" else None
43
+ found = browser_mod.scan_installed_browsers()
44
+ self.assertEqual(found, [("chrome", "chrome", "/usr/bin/google-chrome")])
45
+
46
+ def test_chrome_preferred_over_chromium(self):
47
+ with mock.patch("shutil.which") as which, mock.patch.object(
48
+ browser_mod, "_macos_bundle", return_value=None
49
+ ):
50
+ which.side_effect = lambda b: {
51
+ "google-chrome": "/usr/bin/google-chrome",
52
+ "chromium": "/usr/bin/chromium",
53
+ }.get(b)
54
+ found = browser_mod.scan_installed_browsers()
55
+ self.assertEqual([f[0] for f in found], ["chrome", "chromium"])
56
+
57
+ def test_firefox_is_never_selected(self):
58
+ """System Firefox cannot be driven by Playwright; it must not appear."""
59
+ with mock.patch("shutil.which") as which, mock.patch.object(
60
+ browser_mod, "_macos_bundle", return_value=None
61
+ ):
62
+ which.side_effect = lambda b: "/usr/bin/firefox" if b == "firefox" else None
63
+ self.assertEqual(browser_mod.scan_installed_browsers(), [])
64
+
65
+ def test_nothing_found_is_empty(self):
66
+ with mock.patch("shutil.which", return_value=None), mock.patch.object(
67
+ browser_mod, "_macos_bundle", return_value=None
68
+ ):
69
+ self.assertEqual(browser_mod.scan_installed_browsers(), [])
70
+
71
+
72
+ class UsabilityTest(unittest.TestCase):
73
+ """Detected != usable. These pin the crawl4ai constraint."""
74
+
75
+ def test_brave_detected_but_reported_unusable(self):
76
+ with mock.patch.object(
77
+ browser_mod, "scan_installed_browsers",
78
+ return_value=[("brave", None, "/usr/bin/brave")],
79
+ ):
80
+ choice = browser_mod.resolve_browser()
81
+ self.assertFalse(choice.usable)
82
+ self.assertEqual(choice.to_browser_config_kwargs(), {})
83
+ self.assertIn("brave", choice.describe().lower())
84
+ self.assertIn("NOT usable", choice.describe())
85
+
86
+ def test_unusable_caveat_names_the_way_out(self):
87
+ with mock.patch.object(
88
+ browser_mod, "scan_installed_browsers",
89
+ return_value=[("brave", None, "/usr/bin/brave")],
90
+ ):
91
+ choice = browser_mod.resolve_browser()
92
+ self.assertIn("WEBGET_BROWSER_CDP", choice.caveat)
93
+ self.assertIn("remote-debugging-port", choice.caveat)
94
+
95
+ def test_falls_back_to_bundled_when_nothing_found(self):
96
+ with mock.patch.object(browser_mod, "scan_installed_browsers", return_value=[]):
97
+ choice = browser_mod.resolve_browser()
98
+ self.assertEqual(choice.source, "bundled")
99
+ self.assertTrue(choice.usable)
100
+ self.assertEqual(choice.to_browser_config_kwargs(), {})
101
+ self.assertEqual(choice.describe(), "playwright bundled chromium")
102
+
103
+ def test_usable_browser_wins_over_unusable_one(self):
104
+ """If both Brave and Chrome exist, pick Chrome, do not stop at Brave."""
105
+ with mock.patch.object(
106
+ browser_mod, "scan_installed_browsers",
107
+ return_value=[("brave", None, "/usr/bin/brave"), ("chrome", "chrome", "/usr/bin/google-chrome")],
108
+ ):
109
+ choice = browser_mod.resolve_browser()
110
+ self.assertTrue(choice.usable)
111
+ self.assertEqual(choice.channel, "chrome")
112
+
113
+
114
+ class ConfigKwargsTest(unittest.TestCase):
115
+ """crawl4ai forwards chrome_channel and cdp_url, nothing else."""
116
+
117
+ def test_channel_emits_chrome_channel(self):
118
+ choice = browser_mod.BrowserChoice(name="chrome", channel="chrome")
119
+ self.assertEqual(choice.to_browser_config_kwargs(), {"chrome_channel": "chrome"})
120
+
121
+ def test_default_chromium_emits_nothing(self):
122
+ """crawl4ai skips the default value because it breaks Windows."""
123
+ choice = browser_mod.BrowserChoice(name="chromium", channel="chromium")
124
+ self.assertEqual(choice.to_browser_config_kwargs(), {})
125
+
126
+ def test_path_never_emits_executable_path(self):
127
+ """BrowserConfig raises TypeError on executable_path in 0.9.2."""
128
+ choice = browser_mod.BrowserChoice(name="brave", path="/usr/bin/brave")
129
+ self.assertNotIn("executable_path", choice.to_browser_config_kwargs())
130
+ self.assertEqual(choice.to_browser_config_kwargs(), {})
131
+
132
+ def test_cdp_emits_cdp_url(self):
133
+ choice = browser_mod.BrowserChoice(name="cdp", cdp_url="http://127.0.0.1:9222")
134
+ self.assertEqual(choice.to_browser_config_kwargs(), {"cdp_url": "http://127.0.0.1:9222"})
135
+
136
+ def test_cdp_wins_over_channel(self):
137
+ choice = browser_mod.BrowserChoice(name="cdp", channel="chrome", cdp_url="http://x:1")
138
+ self.assertEqual(choice.to_browser_config_kwargs(), {"cdp_url": "http://x:1"})
139
+
140
+ def test_empty_when_bundled(self):
141
+ self.assertEqual(browser_mod.BrowserChoice(name="chromium").to_browser_config_kwargs(), {})
142
+
143
+
144
+ class ResolvePrecedenceTest(unittest.TestCase):
145
+ def setUp(self):
146
+ self._saved = dict(os.environ)
147
+ for key in ("WEBGET_BROWSER_PATH", "WEBGET_BROWSER_CHANNEL", "WEBGET_BROWSER_CDP"):
148
+ os.environ.pop(key, None)
149
+
150
+ def tearDown(self):
151
+ os.environ.clear()
152
+ os.environ.update(self._saved)
153
+
154
+ def test_cdp_wins_over_everything(self):
155
+ os.environ["WEBGET_BROWSER_CDP"] = "http://127.0.0.1:9222"
156
+ os.environ["WEBGET_BROWSER_CHANNEL"] = "chrome"
157
+ choice = browser_mod.resolve_browser()
158
+ self.assertEqual(choice.cdp_url, "http://127.0.0.1:9222")
159
+ self.assertEqual(choice.source, "env:WEBGET_BROWSER_CDP")
160
+
161
+ def test_env_channel_wins_over_detection(self):
162
+ os.environ["WEBGET_BROWSER_CHANNEL"] = "msedge"
163
+ with mock.patch.object(
164
+ browser_mod, "scan_installed_browsers",
165
+ return_value=[("brave", None, "/usr/bin/brave")],
166
+ ):
167
+ choice = browser_mod.resolve_browser()
168
+ self.assertEqual(choice.channel, "msedge")
169
+ self.assertEqual(choice.source, "env:WEBGET_BROWSER_CHANNEL")
170
+
171
+ def test_unsupported_env_channel_is_reported_not_ignored(self):
172
+ """Asking for a channel Playwright does not have must not be silent."""
173
+ os.environ["WEBGET_BROWSER_CHANNEL"] = "brave"
174
+ choice = browser_mod.resolve_browser()
175
+ self.assertFalse(choice.usable)
176
+ self.assertIn("brave", choice.caveat.lower())
177
+ self.assertIn("WEBGET_BROWSER_CDP", choice.caveat)
178
+
179
+ def test_missing_env_path_raises_instead_of_falling_through(self):
180
+ """A typo must not silently become 'use the bundled browser'."""
181
+ os.environ["WEBGET_BROWSER_PATH"] = "/nope/does-not-exist"
182
+ with mock.patch("os.path.exists", return_value=False), self.assertRaises(
183
+ ValueError
184
+ ) as ctx:
185
+ browser_mod.resolve_browser()
186
+ self.assertIn("/nope/does-not-exist", str(ctx.exception))
187
+
188
+ def test_env_path_is_honest_about_being_unsupported(self):
189
+ """We accept the var but must say it cannot be honoured by crawl4ai."""
190
+ os.environ["WEBGET_BROWSER_PATH"] = "/opt/my/brave"
191
+ with mock.patch("os.path.exists", return_value=True):
192
+ choice = browser_mod.resolve_browser()
193
+ self.assertFalse(choice.usable)
194
+ self.assertIn("executable_path", choice.caveat)
195
+
196
+ def test_detected_channel_browser_beats_bundled(self):
197
+ with mock.patch.object(
198
+ browser_mod, "scan_installed_browsers",
199
+ return_value=[("chrome", "chrome", "/usr/bin/google-chrome")],
200
+ ):
201
+ choice = browser_mod.resolve_browser()
202
+ self.assertTrue(choice.is_external)
203
+ self.assertTrue(choice.usable)
204
+
205
+
206
+ class CdpTest(unittest.TestCase):
207
+ def test_cdp_is_opt_in_only(self):
208
+ """Attaching to a logged-in browser leaks the user's session; never auto."""
209
+ saved = os.environ.pop("WEBGET_BROWSER_CDP", None)
210
+ try:
211
+ self.assertIsNone(browser_mod.cdp_endpoint())
212
+ os.environ["WEBGET_BROWSER_CDP"] = "http://127.0.0.1:9222"
213
+ self.assertEqual(browser_mod.cdp_endpoint(), "http://127.0.0.1:9222")
214
+ finally:
215
+ os.environ.pop("WEBGET_BROWSER_CDP", None)
216
+ if saved is not None:
217
+ os.environ["WEBGET_BROWSER_CDP"] = saved
218
+
219
+ def test_resolve_does_not_auto_detect_cdp(self):
220
+ saved = os.environ.pop("WEBGET_BROWSER_CDP", None)
221
+ try:
222
+ with mock.patch.object(browser_mod, "scan_installed_browsers", return_value=[]):
223
+ choice = browser_mod.resolve_browser()
224
+ self.assertEqual(choice.source, "bundled")
225
+ self.assertIsNone(choice.cdp_url)
226
+ finally:
227
+ if saved is not None:
228
+ os.environ["WEBGET_BROWSER_CDP"] = saved
229
+
230
+
231
+ class LadderWiringTest(unittest.TestCase):
232
+ """The ladder must pass the resolved kwargs into BrowserConfig."""
233
+
234
+ def test_ladder_passes_resolved_kwargs(self):
235
+ import inspect
236
+
237
+ from webget import ladder
238
+
239
+ src = inspect.getsource(ladder)
240
+ self.assertIn("_browser_module.resolve_browser()", src)
241
+ self.assertIn("**bc_kwargs", src)
242
+
243
+ def test_ladder_does_not_pass_executable_path(self):
244
+ """BrowserConfig rejects it, so it must never reach the constructor."""
245
+ import inspect
246
+
247
+ from webget import ladder
248
+
249
+ src = inspect.getsource(ladder)
250
+ self.assertNotIn("executable_path=", src)
251
+
252
+ def test_ladder_reports_a_bad_env_path_as_a_reason(self):
253
+ import inspect
254
+
255
+ from webget import ladder
256
+
257
+ src = inspect.getsource(ladder)
258
+ self.assertIn("except ValueError as exc:", src)
259
+
260
+ def test_ladder_warns_when_browser_is_unusable(self):
261
+ import inspect
262
+
263
+ from webget import ladder
264
+
265
+ src = inspect.getsource(ladder)
266
+ self.assertIn("choice.usable", src)
267
+
268
+
269
+ if __name__ == "__main__":
270
+ unittest.main()
@@ -0,0 +1,119 @@
1
+ """Tests for `webget doctor`.
2
+
3
+ The command exists so a user can see WHY a crawl used (or ignored) a browser.
4
+ These tests pin the shape of the report and, more importantly, that it never
5
+ claims something is usable when the launcher cannot drive it.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import unittest
12
+ from contextlib import redirect_stdout
13
+ from io import StringIO
14
+ from unittest import mock
15
+
16
+ from webget import browser as browser_mod
17
+ from webget import cli
18
+
19
+
20
+ def _run_doctor(json_out=False):
21
+ buf = StringIO()
22
+ with redirect_stdout(buf):
23
+ cli.cmd_doctor(json_out=json_out)
24
+ return buf.getvalue()
25
+
26
+
27
+ class DoctorOutputTest(unittest.TestCase):
28
+ def setUp(self):
29
+ self._saved = dict(os.environ)
30
+ for key in ("WEBGET_BROWSER_CDP", "WEBGET_BROWSER_CHANNEL", "WEBGET_BROWSER_PATH"):
31
+ os.environ.pop(key, None)
32
+
33
+ def tearDown(self):
34
+ os.environ.clear()
35
+ os.environ.update(self._saved)
36
+
37
+ def test_text_output_names_the_selected_browser(self):
38
+ with mock.patch.object(
39
+ browser_mod, "scan_installed_browsers",
40
+ return_value=[("chrome", "chrome", "/usr/bin/google-chrome")],
41
+ ):
42
+ out = _run_doctor()
43
+ self.assertIn("webget doctor", out)
44
+ self.assertIn("chrome", out)
45
+ self.assertIn("usable", out)
46
+
47
+ def test_unusable_browser_is_flagged_and_explained(self):
48
+ with mock.patch.object(
49
+ browser_mod, "scan_installed_browsers",
50
+ return_value=[("brave", None, "/usr/bin/brave")],
51
+ ):
52
+ out = _run_doctor()
53
+ self.assertIn("NOT USABLE", out)
54
+ self.assertIn("WEBGET_BROWSER_CDP", out)
55
+ self.assertIn("no playwright channel", out)
56
+
57
+ def test_reports_bundled_fallback_when_nothing_found(self):
58
+ with mock.patch.object(browser_mod, "scan_installed_browsers", return_value=[]):
59
+ out = _run_doctor()
60
+ self.assertIn("playwright bundled chromium", out)
61
+ self.assertIn("detected on this machine: none", out)
62
+
63
+ def test_json_output_is_valid_and_complete(self):
64
+ with mock.patch.object(browser_mod, "scan_installed_browsers", return_value=[]):
65
+ out = _run_doctor(json_out=True)
66
+ report = json.loads(out)
67
+ for key in ("browser", "crawl4ai", "playwright_cache", "env"):
68
+ self.assertIn(key, report)
69
+ self.assertIn("selected", report["browser"])
70
+ self.assertIn("usable", report["browser"])
71
+
72
+ def test_env_overrides_are_shown(self):
73
+ os.environ["WEBGET_BROWSER_CDP"] = "http://127.0.0.1:9222"
74
+ out = _run_doctor()
75
+ self.assertIn("WEBGET_BROWSER_CDP=http://127.0.0.1:9222", out)
76
+
77
+ def test_bad_env_path_reports_error_without_crashing(self):
78
+ """A bad WEBGET_BROWSER_PATH must surface as an error line.
79
+
80
+ Note: do NOT mock os.path.exists globally here. crawl4ai's import chain
81
+ calls dotenv.find_dotenv(), which uses os.path.exists internally and
82
+ asserts on the result; a blanket mock breaks the import and the failure
83
+ looks like a doctor bug rather than a test artifact.
84
+ """
85
+ os.environ["WEBGET_BROWSER_PATH"] = "/nope/missing"
86
+ out = _run_doctor()
87
+ self.assertIn("ERROR", out)
88
+ self.assertIn("/nope/missing", out)
89
+
90
+ def test_doctor_does_not_require_crawl4ai(self):
91
+ """A missing optional dep must be reported, not raised."""
92
+ real_import = __builtins__["__import__"] if isinstance(__builtins__, dict) else __builtins__.__import__
93
+
94
+ def fake_import(name, *args, **kwargs):
95
+ if name == "crawl4ai":
96
+ raise ImportError("not installed")
97
+ return real_import(name, *args, **kwargs)
98
+
99
+ with mock.patch("builtins.__import__", side_effect=fake_import), mock.patch.object(
100
+ browser_mod, "scan_installed_browsers", return_value=[]
101
+ ):
102
+ out = _run_doctor()
103
+ self.assertIn("NOT installed", out)
104
+ self.assertIn("pip install webget-cli[browser]", out)
105
+
106
+
107
+ class DoctorWiringTest(unittest.TestCase):
108
+ def test_doctor_is_dispatched_from_main(self):
109
+ import inspect
110
+
111
+ src = inspect.getsource(cli.main)
112
+ self.assertIn('cmd == "doctor"', src)
113
+
114
+ def test_doctor_appears_in_usage(self):
115
+ self.assertIn("webget doctor", cli.__doc__ or "")
116
+
117
+
118
+ if __name__ == "__main__":
119
+ unittest.main()
@@ -0,0 +1,88 @@
1
+ import asyncio
2
+
3
+ import webget_cli as webget
4
+ from webget.truncate import smart_truncate
5
+
6
+ MARKER = "\n\n[...truncated]"
7
+
8
+
9
+ def test_short_text_passthrough():
10
+ assert smart_truncate("Short text.", 100) == "Short text."
11
+
12
+
13
+ def test_exact_length_passthrough():
14
+ assert smart_truncate("abcde", 5) == "abcde"
15
+
16
+
17
+ def test_paragraph_boundary_preferred():
18
+ text = "para one here\n\npara two here"
19
+ assert smart_truncate(text, 20) == "para one here" + MARKER
20
+
21
+
22
+ def test_last_paragraph_boundary_wins():
23
+ text = "alpha\n\nbeta\n\ngamma"
24
+ assert smart_truncate(text, 15) == "alpha\n\nbeta" + MARKER
25
+
26
+
27
+ def test_paragraph_boundary_beats_sentence():
28
+ # The paragraph break sits further along than the last sentence end in
29
+ # the first paragraph; the paragraph boundary must win.
30
+ text = "Alpha. Beta.\n\nGamma delta epsilon."
31
+ assert smart_truncate(text, 20) == "Alpha. Beta." + MARKER
32
+
33
+
34
+ def test_sentence_fallback_when_no_paragraph():
35
+ text = "First sentence. Second sentence. Third sentence."
36
+ assert smart_truncate(text, 25) == "First sentence." + MARKER
37
+
38
+
39
+ def test_sentence_fallback_exclamation_and_question():
40
+ text = "Wow! Great. Hmm? No way."
41
+ assert smart_truncate(text, 12) == "Wow! Great." + MARKER
42
+
43
+
44
+ def test_sentence_fallback_newline_terminated():
45
+ assert smart_truncate("Ends here.\nAnd more text", 12) == "Ends here." + MARKER
46
+
47
+
48
+ def test_word_fallback():
49
+ text = "one two three four five"
50
+ assert smart_truncate(text, 9) == "one two" + MARKER
51
+
52
+
53
+ def test_hard_cut():
54
+ assert smart_truncate("abcdefghijkl", 5) == "abcde" + MARKER
55
+
56
+
57
+ def test_marker_appended_exactly_once():
58
+ text = "alpha\n\nbeta\n\ngamma\n\ndelta"
59
+ result = smart_truncate(text, 20)
60
+ assert result.count(MARKER) == 1
61
+ assert result.endswith(MARKER)
62
+
63
+
64
+ def test_ladder_path_marks_truncated_content(fresh_cache):
65
+ """Regression: the ladder's HTTP fast path must run scraped content
66
+ through smart_truncate, not a hard slice.
67
+
68
+ Exercises the real ladder path at unit level: scrape_many() ->
69
+ _resolve_fetch_http() -> fetch_http() (webget/http.py). The fetcher now
70
+ caps markdown with smart_truncate instead of markdown[:max_chars], so a
71
+ page that overflows the limit must come back already marked, and that
72
+ mark must survive to the final result (the CLI no longer re-slices it off).
73
+
74
+ max_chars is kept comfortably above the 100-char "thin content" floor so
75
+ the ladder reports success and returns the (truncated, marked) markdown.
76
+ """
77
+ server = fresh_cache
78
+ # /normal serves LONG_BODY (~1.4KB of repeating sentences) > max_chars.
79
+ url = server.url("/normal")
80
+ max_chars = 300
81
+ res = asyncio.run(
82
+ webget.scrape_many([url], max_chars=max_chars, strategy="http", no_cache=True)
83
+ )
84
+ out = res[url]
85
+ assert out["status"] == "success", out.get("error")
86
+ md = out["markdown"]
87
+ assert md.endswith(MARKER), "ladder output must carry the truncation marker"
88
+ assert md.count(MARKER) == 1