pi-lean-dimension 0.1.0 → 0.2.1

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 (130) hide show
  1. package/README.md +96 -36
  2. package/node_modules/pi-lean-portal/AGENTS.md +146 -4
  3. package/node_modules/pi-lean-portal/README.md +112 -50
  4. package/node_modules/pi-lean-portal/__tests__/browser-data.test.ts +124 -0
  5. package/node_modules/pi-lean-portal/__tests__/browser-inspect.test.ts +192 -16
  6. package/node_modules/pi-lean-portal/__tests__/browser-toggle-profile.test.ts +3 -3
  7. package/node_modules/pi-lean-portal/__tests__/browser-toggle.test.ts +49 -35
  8. package/node_modules/pi-lean-portal/__tests__/chromium-py-persistence.test.ts +21 -195
  9. package/node_modules/pi-lean-portal/__tests__/chromium-py.test.ts +17 -81
  10. package/node_modules/pi-lean-portal/__tests__/chromium.test.ts +25 -0
  11. package/node_modules/pi-lean-portal/__tests__/contributed/invisible-py/invisible-py.test.ts +299 -0
  12. package/node_modules/pi-lean-portal/__tests__/cookie-persistence.test.ts +22 -182
  13. package/node_modules/pi-lean-portal/__tests__/fetch-backend.test.ts +1 -1
  14. package/node_modules/pi-lean-portal/__tests__/firefox-py-persistence.test.ts +21 -184
  15. package/node_modules/pi-lean-portal/__tests__/firefox-py.test.ts +17 -101
  16. package/node_modules/pi-lean-portal/__tests__/firefox.test.ts +2 -18
  17. package/node_modules/pi-lean-portal/__tests__/helpers/__pycache__/mock-python-bridge.cpython-313.pyc +0 -0
  18. package/node_modules/pi-lean-portal/__tests__/helpers/create-py-backend-harness.ts +105 -0
  19. package/node_modules/pi-lean-portal/__tests__/helpers/load-plugin-config-from-file.ts +53 -0
  20. package/node_modules/pi-lean-portal/__tests__/helpers/mock-plugin.ts +11 -7
  21. package/node_modules/pi-lean-portal/__tests__/helpers/mock-python-bridge.py +4 -0
  22. package/node_modules/pi-lean-portal/__tests__/helpers/persistence-suite.ts +218 -0
  23. package/node_modules/pi-lean-portal/__tests__/helpers/plugin-contract.ts +198 -318
  24. package/node_modules/pi-lean-portal/__tests__/helpers/probe-user-backend.ts +198 -0
  25. package/node_modules/pi-lean-portal/__tests__/helpers/test-server.ts +14 -0
  26. package/node_modules/pi-lean-portal/__tests__/plugin-config-browser.test.ts +150 -15
  27. package/node_modules/pi-lean-portal/__tests__/plugin-loading.test.ts +120 -18
  28. package/node_modules/pi-lean-portal/__tests__/plugin-registry.test.ts +6 -67
  29. package/node_modules/pi-lean-portal/__tests__/probe-user-backend.test.ts +236 -0
  30. package/node_modules/pi-lean-portal/__tests__/python-adapter.test.ts +401 -11
  31. package/node_modules/pi-lean-portal/__tests__/router-session.test.ts +4 -1
  32. package/node_modules/pi-lean-portal/__tests__/run-contributed-suites.test.ts +318 -0
  33. package/node_modules/pi-lean-portal/__tests__/session-manager.test.ts +50 -0
  34. package/node_modules/pi-lean-portal/__tests__/snapshot-cache.test.ts +2 -2
  35. package/node_modules/pi-lean-portal/__tests__/url-safety.test.ts +1 -1
  36. package/node_modules/pi-lean-portal/backends/chromium/index.ts +5 -13
  37. package/node_modules/pi-lean-portal/backends/chromium-py/__pycache__/bridge.cpython-313.pyc +0 -0
  38. package/node_modules/pi-lean-portal/backends/chromium-py/bridge.py +0 -2
  39. package/node_modules/pi-lean-portal/backends/firefox/index.ts +6 -5
  40. package/node_modules/pi-lean-portal/backends/firefox-py/__pycache__/bridge.cpython-313.pyc +0 -0
  41. package/node_modules/pi-lean-portal/backends/firefox-py/bridge.py +7 -6
  42. package/node_modules/pi-lean-portal/backends/playwright-base/playwright-plugin.ts +241 -398
  43. package/node_modules/pi-lean-portal/backends/python-adapter.ts +182 -83
  44. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__init__.py +1 -42
  45. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/__init__.cpython-312.pyc +0 -0
  46. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/__init__.cpython-313.pyc +0 -0
  47. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/accessibility.cpython-312.pyc +0 -0
  48. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/accessibility.cpython-313.pyc +0 -0
  49. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/bot_detection.cpython-312.pyc +0 -0
  50. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/bot_detection.cpython-313.pyc +0 -0
  51. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/bridge.cpython-312.pyc +0 -0
  52. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/bridge.cpython-313.pyc +0 -0
  53. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/browser_data.cpython-312.pyc +0 -0
  54. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/browser_data.cpython-313.pyc +0 -0
  55. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/patch_playwright.cpython-312.pyc +0 -0
  56. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/patch_playwright.cpython-313.pyc +0 -0
  57. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-312.pyc +0 -0
  58. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-313.pyc +0 -0
  59. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/transport.cpython-312.pyc +0 -0
  60. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/transport.cpython-313.pyc +0 -0
  61. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/accessibility.py +12 -147
  62. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/bot_detection.py +12 -37
  63. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/bridge.py +249 -322
  64. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/browser_data.py +92 -0
  65. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/patch_playwright.py +321 -0
  66. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/playwright_base.py +511 -299
  67. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  68. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/{test_accessibility.cpython-313-pytest-9.1.0.pyc → test_accessibility.cpython-313-pytest-9.1.1.pyc} +0 -0
  69. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_accessibility.cpython-313.pyc +0 -0
  70. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_bot_detection.cpython-313-pytest-9.1.1.pyc +0 -0
  71. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_bot_detection.cpython-313.pyc +0 -0
  72. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_browser_data.cpython-313-pytest-9.1.1.pyc +0 -0
  73. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_browser_data.cpython-313.pyc +0 -0
  74. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/{test_chromium_py_bridge.cpython-313-pytest-9.1.0.pyc → test_chromium_py_bridge.cpython-313-pytest-9.1.1.pyc} +0 -0
  75. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/{test_firefox_py_bridge.cpython-313-pytest-9.1.0.pyc → test_firefox_py_bridge.cpython-313-pytest-9.1.1.pyc} +0 -0
  76. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313-pytest-9.1.1.pyc +0 -0
  77. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313.pyc +0 -0
  78. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_py_bridges.cpython-313-pytest-9.1.1.pyc +0 -0
  79. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/{test_transport.cpython-313-pytest-9.1.0.pyc → test_transport.cpython-313-pytest-9.1.1.pyc} +0 -0
  80. package/node_modules/pi-lean-portal/backends/python-base/tests/conftest.py +95 -0
  81. package/node_modules/pi-lean-portal/backends/python-base/tests/test_accessibility.py +8 -132
  82. package/node_modules/pi-lean-portal/backends/python-base/tests/test_bot_detection.py +8 -147
  83. package/node_modules/pi-lean-portal/backends/python-base/tests/test_browser_data.py +131 -0
  84. package/node_modules/pi-lean-portal/backends/python-base/tests/test_playwright_base_quirks.py +768 -0
  85. package/node_modules/pi-lean-portal/backends/python-base/tests/test_py_bridges.py +198 -0
  86. package/node_modules/pi-lean-portal/browser-toggle.ts +33 -69
  87. package/node_modules/pi-lean-portal/contributed/CHOOSING.md +126 -0
  88. package/node_modules/pi-lean-portal/contributed/README.md +304 -0
  89. package/node_modules/pi-lean-portal/contributed/camoufox-py/bridge.py +216 -0
  90. package/node_modules/pi-lean-portal/contributed/invisible-py/__pycache__/bridge.cpython-313.pyc +0 -0
  91. package/node_modules/pi-lean-portal/contributed/invisible-py/bridge.py +434 -0
  92. package/node_modules/pi-lean-portal/core/fetch-backend.ts +0 -5
  93. package/node_modules/pi-lean-portal/core/plugin-api.ts +6 -33
  94. package/node_modules/pi-lean-portal/core/plugin-config.ts +75 -58
  95. package/node_modules/pi-lean-portal/core/plugin-registry.ts +11 -49
  96. package/node_modules/pi-lean-portal/core/router.ts +59 -98
  97. package/node_modules/pi-lean-portal/core/shared/accessibility-tree.ts +10 -143
  98. package/node_modules/pi-lean-portal/core/shared/bot-detection.ts +31 -77
  99. package/node_modules/pi-lean-portal/core/shared/browser-data.json +183 -0
  100. package/node_modules/pi-lean-portal/core/shared/browser-data.ts +50 -0
  101. package/node_modules/pi-lean-portal/core/shared/browser-events.ts +6 -6
  102. package/node_modules/pi-lean-portal/core/shared/dom-extractor.ts +97 -36
  103. package/node_modules/pi-lean-portal/core/shared/nav-settle.ts +12 -15
  104. package/node_modules/pi-lean-portal/core/shared/paths.ts +3 -0
  105. package/node_modules/pi-lean-portal/core/shared/session-manager.ts +7 -21
  106. package/node_modules/pi-lean-portal/{verify-ship-manifest.ts → core/shared/ship-manifest.ts} +34 -9
  107. package/node_modules/pi-lean-portal/core/shared/snapshot-cache.ts +7 -6
  108. package/node_modules/pi-lean-portal/core/shared/storage-state.ts +40 -9
  109. package/node_modules/pi-lean-portal/index.ts +42 -7
  110. package/node_modules/pi-lean-portal/package.json +8 -3
  111. package/node_modules/pi-lean-portal/ship-manifest.test.ts +8 -3
  112. package/node_modules/pi-lean-portal/tools/browser-inspect.ts +2 -6
  113. package/node_modules/pi-lean-portal/tools/browser-navigate.ts +7 -5
  114. package/node_modules/pi-lean-portal/tools/browser-snapshot.ts +2 -5
  115. package/node_modules/pi-lean-portal/tools/utils.ts +22 -4
  116. package/node_modules/pi-lean-portal/tools/web-fetch.ts +3 -4
  117. package/node_modules/pi-lean-search/README.md +6 -2
  118. package/node_modules/pi-lean-search/__tests__/web-search.test.ts +11 -0
  119. package/node_modules/pi-lean-search/index.ts +4 -21
  120. package/node_modules/pi-lean-search/package.json +2 -2
  121. package/node_modules/pi-lean-search/verify-ship-manifest.ts +6 -92
  122. package/node_modules/pi-lean-search/web-search-tool.ts +19 -13
  123. package/package.json +4 -4
  124. package/node_modules/pi-lean-portal/__tests__/helpers/reddit-fixture.ts +0 -264
  125. package/node_modules/pi-lean-portal/__tests__/helpers/toggle-test-utils.ts +0 -31
  126. package/node_modules/pi-lean-portal/__tests__/reddit-dialog.test.ts +0 -302
  127. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/occlusion.cpython-313.pyc +0 -0
  128. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_bot_detection.cpython-313-pytest-9.1.0.pyc +0 -0
  129. package/node_modules/pi-lean-portal/backends/python-base/tests/test_chromium_py_bridge.py +0 -281
  130. package/node_modules/pi-lean-portal/backends/python-base/tests/test_firefox_py_bridge.py +0 -212
@@ -0,0 +1,92 @@
1
+ """
2
+ Shared data loader for ``browser-data.json``.
3
+
4
+ Single source of truth that mirrors the TypeScript ``browser-data.ts``
5
+ loader. Opened and validated once at import time, then re-exported
6
+ as module-level dicts that the three consumers
7
+ (``bot_detection``, ``accessibility``, ``playwright_base``) import.
8
+
9
+ Loud failure: missing/corrupt file or missing keys raises RuntimeError
10
+ at import time, so both the TS and Python sides fail identically.
11
+ """
12
+
13
+ import json
14
+ import os
15
+ from typing import Any
16
+
17
+ _SHARED_DATA_PATH = os.path.join(
18
+ os.path.dirname(__file__),
19
+ "..", "..", "..",
20
+ "core", "shared", "browser-data.json",
21
+ )
22
+
23
+
24
+ def _load() -> dict[str, Any]:
25
+ """Open, parse, and validate every expected key in browser-data.json."""
26
+ try:
27
+ with open(_SHARED_DATA_PATH, "r") as f:
28
+ data = json.load(f)
29
+ except Exception as exc:
30
+ raise RuntimeError(
31
+ f"[pi-lean-portal] Failed to load shared browser data from "
32
+ f"{_SHARED_DATA_PATH}: {exc}"
33
+ ) from exc
34
+
35
+ if not isinstance(data.get("version"), int) or data["version"] < 1:
36
+ raise RuntimeError(
37
+ f"[pi-lean-portal] browser-data.json has invalid or missing version "
38
+ f"field in {_SHARED_DATA_PATH}"
39
+ )
40
+
41
+ # ── botSignals ──────────────────────────────────────────────
42
+ bot = data.get("botSignals")
43
+ if not isinstance(bot, dict):
44
+ raise RuntimeError(
45
+ f"[pi-lean-portal] browser-data.json: missing 'botSignals' section "
46
+ f"in {_SHARED_DATA_PATH}"
47
+ )
48
+ for key in ("blockSignals", "bodyOnlySignals", "bodyOnlyPatterns", "htmlSignals"):
49
+ if key not in bot:
50
+ raise RuntimeError(
51
+ f"[pi-lean-portal] browser-data.json: missing "
52
+ f"'botSignals.{key}' in {_SHARED_DATA_PATH}"
53
+ )
54
+
55
+ # ── accessibility ───────────────────────────────────────────
56
+ a11y = data.get("accessibility")
57
+ if not isinstance(a11y, dict):
58
+ raise RuntimeError(
59
+ f"[pi-lean-portal] browser-data.json: missing 'accessibility' section "
60
+ f"in {_SHARED_DATA_PATH}"
61
+ )
62
+ for key in ("interactiveRoles", "informationalRoles", "roleIcons"):
63
+ if key not in a11y:
64
+ raise RuntimeError(
65
+ f"[pi-lean-portal] browser-data.json: missing "
66
+ f"'accessibility.{key}' in {_SHARED_DATA_PATH}"
67
+ )
68
+
69
+ # ── navSettle ───────────────────────────────────────────────
70
+ ns = data.get("navSettle")
71
+ if not isinstance(ns, dict):
72
+ raise RuntimeError(
73
+ f"[pi-lean-portal] browser-data.json: missing 'navSettle' section "
74
+ f"in {_SHARED_DATA_PATH}"
75
+ )
76
+ for key in ("navTimeoutMs", "settleTimeoutMs", "settleRaceMs", "domStabilizationJs"):
77
+ if key not in ns:
78
+ raise RuntimeError(
79
+ f"[pi-lean-portal] browser-data.json: missing "
80
+ f"'navSettle.{key}' in {_SHARED_DATA_PATH}"
81
+ )
82
+
83
+ return data
84
+
85
+
86
+ _RAW = _load()
87
+
88
+ # ─── Section exports (dicts) ─────────────────────────────────────────
89
+
90
+ BOT_SIGNALS: dict[str, Any] = _RAW["botSignals"]
91
+ ACCESSIBILITY: dict[str, Any] = _RAW["accessibility"]
92
+ NAV_SETTLE: dict[str, Any] = _RAW["navSettle"]
@@ -0,0 +1,321 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Idempotent Playwright coreBundle.js patcher — guards pageError.location for Camoufox.
4
+ """
5
+
6
+ import sys
7
+ import sysconfig
8
+ from pathlib import Path
9
+
10
+
11
+ # ── Pattern constants ────────────────────────────────────────────────
12
+
13
+ # The exact JavaScript snippet that crashes (two identical occurrences).
14
+ _VULNERABLE_SNIPPET = (
15
+ "location: {\n"
16
+ " url: pageError.location.url,\n"
17
+ " line: pageError.location.lineNumber,\n"
18
+ " column: pageError.location.columnNumber\n"
19
+ " }"
20
+ )
21
+
22
+ # The guard replacement. Uses a truthy check so that undefined, null,
23
+ # and missing all produce the fallback object. The fallback's values are
24
+ # safe empty-string / zero — never undefined — because the wire
25
+ # serializer validates ``location`` must be an object.
26
+ _GUARDED_SNIPPET = (
27
+ "location: pageError.location ? {\n"
28
+ " url: pageError.location.url,\n"
29
+ " line: pageError.location.lineNumber,\n"
30
+ " column: pageError.location.columnNumber\n"
31
+ " } : { url: \"\", line: 0, column: 0 }"
32
+ )
33
+
34
+ # Marker comment added after a successful patch so we can cheaply detect
35
+ # it on subsequent calls without re-reading the whole file.
36
+ _PATCH_MARKER = "/* pi-bridge: guarded pageError.location */"
37
+
38
+
39
+ # ── Locate the driver file ───────────────────────────────────────────
40
+
41
+ def _default_driver_path() -> Path | None:
42
+ """Return the path to ``coreBundle.js`` for the active Python venv.
43
+
44
+ Resolves via ``playwright.__file__`` when playwright is importable,
45
+ or by guessing the path from ``sys.prefix`` on the standard venv
46
+ layout. Returns ``None`` when neither approach finds a file.
47
+ """
48
+ # Strategy 1: follow the installed playwright package's __file__
49
+ try:
50
+ import playwright # type: ignore[import-unresolved] # noqa: F401
51
+
52
+ pw_dir = Path(playwright.__file__).resolve().parent
53
+ candidate = pw_dir / "driver" / "package" / "lib" / "coreBundle.js"
54
+ if candidate.is_file():
55
+ return candidate
56
+ except (ImportError, AttributeError, OSError):
57
+ pass
58
+
59
+ # Strategy 2: guess from sys.prefix + known venv layout
60
+ site_packages = sysconfig.get_paths().get("purelib")
61
+ if site_packages:
62
+ candidate = (
63
+ Path(site_packages)
64
+ / "playwright"
65
+ / "driver"
66
+ / "package"
67
+ / "lib"
68
+ / "coreBundle.js"
69
+ )
70
+ if candidate.is_file():
71
+ return candidate
72
+
73
+ return None
74
+
75
+
76
+ # ── Core patch logic ─────────────────────────────────────────────────
77
+
78
+ def _check_already_patched(content: str) -> bool:
79
+ """Cheap idempotency check — the marker comment we add after a patch."""
80
+ return _PATCH_MARKER in content
81
+
82
+
83
+ def _check_has_unpatched_pattern(content: str) -> bool:
84
+ """True if the vulnerable snippet exists in **unpatched** form."""
85
+ return _VULNERABLE_SNIPPET in content
86
+
87
+
88
+ def _apply_patch(
89
+ driver_path: Path, *, loud: bool = False, fail_on_readonly: bool = True
90
+ ) -> bool:
91
+ """Apply the guard to ``coreBundle.js``.
92
+
93
+ Args:
94
+ driver_path: Absolute path to the Playwright driver file.
95
+ loud: Print a one-time stderr notice on first patch.
96
+ fail_on_readonly: When ``True`` (default), raise ``RuntimeError``
97
+ if the file is read-only. When ``False``, return ``False``.
98
+
99
+ Returns:
100
+ ``True`` if a patch was applied (first time); ``False`` if already
101
+ patched, the vulnerable pattern was not found, or (when
102
+ ``fail_on_readonly=False``) the file was read-only.
103
+
104
+ Raises:
105
+ RuntimeError: If ``fail_on_readonly=True`` and the file is
106
+ read-only, or the pattern match fails unexpectedly.
107
+ """
108
+ content = driver_path.read_text(encoding="utf-8")
109
+
110
+ # --- Idempotency check ---
111
+ if _check_already_patched(content):
112
+ return False
113
+
114
+ # --- Check for unguarded snippet ---
115
+ if not _check_has_unpatched_pattern(content):
116
+ # The vulnerable snippet isn't present in unguarded form. This could
117
+ # mean Playwright already fixed it upstream (newer version) or
118
+ # restructured the bundle. Warn and skip.
119
+ if loud:
120
+ print(
121
+ (
122
+ "[camoufox-py] WARNING: Playwright coreBundle.js does not contain "
123
+ "the expected vulnerable pattern (pageError.location.url without "
124
+ "a guard). This Playwright version may already have the fix, or "
125
+ "the bundle structure changed. No patch applied."
126
+ ),
127
+ file=sys.stderr,
128
+ flush=True,
129
+ )
130
+ return False
131
+
132
+ # --- Apply the replacement ---
133
+ new_content = content.replace(_VULNERABLE_SNIPPET, _GUARDED_SNIPPET)
134
+
135
+ # Sanity check: the replace should have changed the file
136
+ if new_content == content:
137
+ raise RuntimeError(
138
+ "BUG: pattern matched but replace produced identical content. "
139
+ "Cannot patch Playwright coreBundle.js."
140
+ )
141
+
142
+ # Verify the replace didn't introduce a regression (count occurrences)
143
+ patched_count = new_content.count(
144
+ "pageError.location ? {"
145
+ )
146
+ expected_count = 2 # two identical occurrences in the bundle
147
+ if patched_count != expected_count:
148
+ raise RuntimeError(
149
+ f"BUG: expected {expected_count} patched occurrences, got {patched_count}. "
150
+ "Playwright bundle structure may have changed. "
151
+ "Cannot safely patch coreBundle.js."
152
+ )
153
+
154
+ # Add the marker comment (appended to end for cheap subsequent check)
155
+ new_content += "\n" + _PATCH_MARKER + "\n"
156
+
157
+ # --- Write back ---
158
+ try:
159
+ driver_path.write_text(new_content, encoding="utf-8")
160
+ except PermissionError as exc:
161
+ if fail_on_readonly:
162
+ raise RuntimeError(
163
+ "Cannot write to Playwright coreBundle.js — file is read-only.\n"
164
+ "To patch manually as a one-time command:\n"
165
+ f" python -m pi_browser_bridge.patch_playwright\n"
166
+ f" (from the camoufox-py virtual environment: {sys.prefix})"
167
+ ) from exc
168
+ return False
169
+ except OSError as exc:
170
+ raise RuntimeError(
171
+ "Cannot write to Playwright coreBundle.js.\n"
172
+ f" Path: {driver_path}\n"
173
+ f" Error: {exc}\n"
174
+ "To patch manually, copy the file, edit it, and re-run:\n"
175
+ " python -m pi_browser_bridge.patch_playwright"
176
+ ) from exc
177
+
178
+ # --- Loud one-time notice ---
179
+ if loud:
180
+ print(
181
+ (
182
+ "[camoufox-py] Patched Playwright coreBundle.js "
183
+ f"({driver_path})\n"
184
+ " Guarded pageError.location access — required for "
185
+ "Camoufox binary v135.0.1-beta.24 which emits "
186
+ "uncaught-error events without location metadata."
187
+ ),
188
+ file=sys.stderr,
189
+ flush=True,
190
+ )
191
+
192
+ return True
193
+
194
+
195
+ # ── Public API ───────────────────────────────────────────────────────
196
+
197
+ def patch_playwright(
198
+ *,
199
+ driver_path: str | None = None,
200
+ loud: bool = False,
201
+ fail_on_readonly: bool = True,
202
+ ) -> bool:
203
+ """Idempotently guard ``pageError.location`` in Playwright's coreBundle.js.
204
+
205
+ Designed to be called automatically at bridge launch (from
206
+ ``_launch_browser()``) or as a standalone ``python -m`` command.
207
+
208
+ Args:
209
+ driver_path: Explicit path to ``coreBundle.js``. When ``None``,
210
+ auto-detect from the running venv's Playwright installation.
211
+ loud: Print a one-time stderr notice when a patch is applied.
212
+ fail_on_readonly: When ``True`` (default), raise ``RuntimeError``
213
+ if the file exists but is read-only. When ``False``, just
214
+ return ``False`` and let the caller decide.
215
+
216
+ Returns:
217
+ ``True`` if a patch was applied (first-time). ``False`` if already
218
+ patched, the pattern was not found (Playwright version may not need
219
+ the fix), or (when ``fail_on_readonly=False``) the file was read-only.
220
+
221
+ Raises:
222
+ RuntimeError: If the driver file cannot be located, or
223
+ ``fail_on_readonly=True`` and the file is read-only.
224
+ """
225
+ # --- Resolve driver path ---
226
+ if driver_path is not None:
227
+ resolved = Path(driver_path).resolve()
228
+ else:
229
+ resolved = _default_driver_path()
230
+ if resolved is None:
231
+ raise RuntimeError(
232
+ "Cannot locate Playwright coreBundle.js — is Playwright "
233
+ "installed in the current Python environment?\n"
234
+ f" sys.prefix: {sys.prefix}\n"
235
+ f" sys.executable: {sys.executable}\n"
236
+ "If Playwright is installed, try the manual command:\n"
237
+ " python -m pi_browser_bridge.patch_playwright --help"
238
+ )
239
+
240
+ if not resolved.is_file():
241
+ raise RuntimeError(
242
+ f"Playwright coreBundle.js not found at: {resolved}\n"
243
+ "Is Playwright properly installed in the current "
244
+ "virtual environment?"
245
+ )
246
+
247
+ # --- Idempotent patch ---
248
+ return _apply_patch(resolved, loud=loud, fail_on_readonly=fail_on_readonly)
249
+
250
+
251
+ # ── CLI entry point ──────────────────────────────────────────────────
252
+
253
+ def main() -> None:
254
+ """CLI entry point for ``python -m pi_browser_bridge.patch_playwright``.
255
+
256
+ Accepts:
257
+ ``--driver PATH`` — explicit path to coreBundle.js.
258
+ ``--quiet`` — suppress the one-time patch notice.
259
+ ``--check`` — only check whether a patch is needed (exit 0 = patched
260
+ or not-needed, exit 1 = unpatched).
261
+ """
262
+ import argparse
263
+
264
+ parser = argparse.ArgumentParser(
265
+ description="Guard pageError.location in Playwright's coreBundle.js",
266
+ )
267
+ parser.add_argument(
268
+ "--driver",
269
+ type=str,
270
+ default=None,
271
+ help="Explicit path to coreBundle.js (auto-detected by default)",
272
+ )
273
+ parser.add_argument(
274
+ "--quiet",
275
+ action="store_true",
276
+ help="Suppress the one-time patch notice",
277
+ )
278
+ parser.add_argument(
279
+ "--check",
280
+ action="store_true",
281
+ help="Check-only: exit 0 if already patched / not needed, 1 if "
282
+ "the vulnerable pattern is still present",
283
+ )
284
+ args = parser.parse_args()
285
+
286
+ if args.check:
287
+ driver: Path | None
288
+ if args.driver:
289
+ driver = Path(args.driver).resolve()
290
+ else:
291
+ driver = _default_driver_path()
292
+ if driver is None or not driver.is_file():
293
+ print("coreBundle.js not found — cannot check", file=sys.stderr)
294
+ sys.exit(1)
295
+ content = driver.read_text(encoding="utf-8")
296
+ if _check_already_patched(content):
297
+ print("PATCHED")
298
+ sys.exit(0)
299
+ if _check_has_unpatched_pattern(content):
300
+ print("UNPATCHED")
301
+ sys.exit(1)
302
+ print("NOT_NEEDED")
303
+ sys.exit(0)
304
+
305
+ try:
306
+ patched = patch_playwright(
307
+ driver_path=args.driver,
308
+ loud=not args.quiet,
309
+ fail_on_readonly=True,
310
+ )
311
+ if patched:
312
+ print("Patch applied.", file=sys.stderr)
313
+ else:
314
+ print("Already patched or not needed.", file=sys.stderr)
315
+ except RuntimeError as exc:
316
+ print(f"ERROR: {exc}", file=sys.stderr)
317
+ sys.exit(1)
318
+
319
+
320
+ if __name__ == "__main__":
321
+ main()