pi-lean-dimension 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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,95 @@
1
+ """Shared fixtures and helpers for Python bridge unit tests."""
2
+
3
+ import importlib.util
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+
9
+ # ── Bridge loading ─────────────────────────────────────────────
10
+
11
+ _BACKEND_CLASSES = {
12
+ "chromium-py": "ChromiumPyBridge",
13
+ "firefox-py": "FirefoxPyBridge",
14
+ }
15
+
16
+
17
+ def _load_bridge_class(backend_name: str):
18
+ """Load a bridge class by backend name (chromium-py, firefox-py)."""
19
+ dir_name = backend_name.replace("-", "_")
20
+ bridge_path = (
21
+ Path(__file__).resolve().parents[3]
22
+ / "backends"
23
+ / backend_name
24
+ / "bridge.py"
25
+ )
26
+ assert bridge_path.exists(), f"bridge.py not found at {bridge_path}"
27
+ spec = importlib.util.spec_from_file_location(
28
+ f"{dir_name}_bridge", bridge_path
29
+ )
30
+ assert spec is not None, f"Could not create spec for {bridge_path}"
31
+ module = importlib.util.module_from_spec(spec)
32
+ spec.loader.exec_module(module) # type: ignore[union-attr]
33
+ return getattr(module, _BACKEND_CLASSES[backend_name])
34
+
35
+
36
+ @pytest.fixture(params=["chromium-py", "firefox-py"])
37
+ def bridge_factory(request):
38
+ """Parametrized fixture yielding (backend_name, bridge_instance)."""
39
+ backend_name = request.param
40
+ cls = _load_bridge_class(backend_name)
41
+ return backend_name, cls()
42
+
43
+
44
+ # ── Shared constants ──────────────────────────────────────────
45
+
46
+ METHOD_NAMES = [
47
+ "browser.navigate",
48
+ "browser.snapshot",
49
+ "browser.click",
50
+ "browser.type",
51
+ "browser.scroll",
52
+ "browser.goBack",
53
+ "browser.press",
54
+ "browser.screenshot",
55
+ "browser.getConsoleMessages",
56
+ "browser.clearConsole",
57
+ "browser.evaluate",
58
+ "browser.cleanup",
59
+ ]
60
+
61
+
62
+ # ── Mock objects ──────────────────────────────────────────────
63
+
64
+
65
+ class _MockConsolePage:
66
+ """Mock Playwright page that records ``page.on("console")`` handlers.
67
+
68
+ Lets us invoke captured handlers directly without a real browser.
69
+ """
70
+
71
+ def __init__(self) -> None:
72
+ self._handlers: dict[str, list] = {}
73
+
74
+ def on(self, event: str, handler) -> None:
75
+ self._handlers.setdefault(event, []).append(handler)
76
+
77
+ def fire_console(self, text: str) -> None:
78
+ """Simulate a console event by calling all 'console' handlers."""
79
+ for handler in self._handlers.get("console", []):
80
+ handler(_FakeConsoleMessage(text))
81
+
82
+
83
+ class _FakeConsoleMessage:
84
+ """Duck-typed substitute for Playwright's ConsoleMessage."""
85
+
86
+ def __init__(self, text: str) -> None:
87
+ self._text = text
88
+
89
+ @property
90
+ def type(self) -> str:
91
+ return "log"
92
+
93
+ @property
94
+ def text(self) -> str:
95
+ return self._text
@@ -16,39 +16,11 @@ from pi_browser_bridge.accessibility import (
16
16
  parse_snapshot,
17
17
  build_locator_args,
18
18
  _parse_line,
19
- _count_leading_spaces,
20
- _role_icon,
21
- _truncate,
22
19
  INTERACTIVE_ROLES,
23
20
  INFORMATIONAL_ROLES,
24
21
  )
25
22
 
26
23
 
27
- # ═════════════════════════════════════════════════════════════════════
28
- # _count_leading_spaces
29
- # ═════════════════════════════════════════════════════════════════════
30
-
31
-
32
- class TestCountLeadingSpaces:
33
- def test_zero(self) -> None:
34
- assert _count_leading_spaces("foo") == 0
35
-
36
- def test_two_spaces(self) -> None:
37
- assert _count_leading_spaces(" foo") == 2
38
-
39
- def test_four_spaces(self) -> None:
40
- assert _count_leading_spaces(" foo") == 4
41
-
42
- def test_tab(self) -> None:
43
- assert _count_leading_spaces("\tfoo") == 1
44
-
45
- def test_empty(self) -> None:
46
- assert _count_leading_spaces("") == 0
47
-
48
- def test_only_spaces(self) -> None:
49
- assert _count_leading_spaces(" ") == 3
50
-
51
-
52
24
  # ═════════════════════════════════════════════════════════════════════
53
25
  # _parse_line
54
26
  # ═════════════════════════════════════════════════════════════════════
@@ -411,7 +383,7 @@ class TestBuildLocatorArgs:
411
383
  role, kwargs = build_locator_args(node)
412
384
  assert role == "button"
413
385
  assert kwargs["name"] == "Click"
414
- assert kwargs["exact"] is True
386
+ assert kwargs["exact"] == True
415
387
  assert kwargs["occurrenceIndex"] == 0
416
388
 
417
389
  def test_heading_with_level(self) -> None:
@@ -437,7 +409,7 @@ class TestBuildLocatorArgs:
437
409
  raw='- checkbox "Agree" [checked]',
438
410
  )
439
411
  role, kwargs = build_locator_args(node)
440
- assert kwargs["checked"] is True
412
+ assert kwargs["checked"] == True
441
413
 
442
414
  def test_mixed_checkbox(self) -> None:
443
415
  node = AriaCachedNode(
@@ -461,7 +433,7 @@ class TestBuildLocatorArgs:
461
433
  raw='- treeitem "Node" [expanded=true]',
462
434
  )
463
435
  role, kwargs = build_locator_args(node)
464
- assert kwargs["expanded"] is True
436
+ assert kwargs["expanded"] == True
465
437
 
466
438
  def test_collapsed(self) -> None:
467
439
  node = AriaCachedNode(
@@ -473,8 +445,8 @@ class TestBuildLocatorArgs:
473
445
  raw='- treeitem "Node" [expanded=false, selected]',
474
446
  )
475
447
  role, kwargs = build_locator_args(node)
476
- assert kwargs["expanded"] is False
477
- assert kwargs["selected"] is True
448
+ assert kwargs["expanded"] == False
449
+ assert kwargs["selected"] == True
478
450
 
479
451
  def test_disabled(self) -> None:
480
452
  node = AriaCachedNode(
@@ -486,7 +458,7 @@ class TestBuildLocatorArgs:
486
458
  raw='- button "Save" [disabled]',
487
459
  )
488
460
  role, kwargs = build_locator_args(node)
489
- assert kwargs["disabled"] is True
461
+ assert kwargs["disabled"] == True
490
462
 
491
463
  def test_pressed(self) -> None:
492
464
  node = AriaCachedNode(
@@ -498,7 +470,7 @@ class TestBuildLocatorArgs:
498
470
  raw='- button "Toggle" [pressed]',
499
471
  )
500
472
  role, kwargs = build_locator_args(node)
501
- assert kwargs["pressed"] is True
473
+ assert kwargs["pressed"] == True
502
474
 
503
475
  def test_pressed_mixed(self) -> None:
504
476
  node = AriaCachedNode(
@@ -524,7 +496,7 @@ class TestBuildLocatorArgs:
524
496
  )
525
497
  role, kwargs = build_locator_args(node)
526
498
  assert kwargs["name"] == long
527
- assert kwargs["exact"] is False
499
+ assert kwargs["exact"] == False
528
500
 
529
501
  def test_includes_occurrence_index_unique(self) -> None:
530
502
  """build_locator_args includes occurrenceIndex=0 for unique elements."""
@@ -641,99 +613,3 @@ class TestParseSnapshotOccurrenceIndex:
641
613
  assert result.elements["e1"].occurrence_index == 0 # first link
642
614
  assert result.elements["e2"].occurrence_index == 1 # second link
643
615
 
644
-
645
- # ═════════════════════════════════════════════════════════════════════
646
- # _role_icon
647
- # ═════════════════════════════════════════════════════════════════════
648
-
649
-
650
- class TestRoleIcon:
651
- def test_known_role(self) -> None:
652
- assert _role_icon("button") == "\U0001f518 "
653
-
654
- def test_unknown_role(self) -> None:
655
- assert _role_icon("bogus") == ""
656
-
657
- def test_all_interactive_have_icons(self) -> None:
658
- for role in sorted(INTERACTIVE_ROLES):
659
- icon = _role_icon(role)
660
- assert icon != "", f"{role} is missing an icon"
661
- assert icon.endswith(" "), f"{role} icon should end with space"
662
- assert "\ufe0f " not in icon or icon.endswith(" "), (
663
- f"{role} icon '{icon}' should end with plain space after \\ufe0f"
664
- )
665
-
666
- def test_all_informational_have_icons(self) -> None:
667
- for role in sorted(INFORMATIONAL_ROLES):
668
- icon = _role_icon(role)
669
- assert icon != "", f"{role} is missing an icon"
670
- assert icon.endswith(" "), f"{role} icon should end with space"
671
-
672
-
673
- # ═════════════════════════════════════════════════════════════════════
674
- # _truncate
675
- # ═════════════════════════════════════════════════════════════════════
676
-
677
-
678
- class TestTruncate:
679
- def test_short_string(self) -> None:
680
- assert _truncate("hello", 10) == "hello"
681
-
682
- def test_exact_length(self) -> None:
683
- assert _truncate("hello", 5) == "hello"
684
-
685
- def test_long_string(self) -> None:
686
- result = _truncate("hello world", 8)
687
- assert len(result) == 8
688
- assert result.endswith("\u2026") # ellipsis
689
-
690
- def test_empty_string(self) -> None:
691
- assert _truncate("", 5) == ""
692
-
693
-
694
- # ═════════════════════════════════════════════════════════════════════
695
- # Role set completeness — every role has a classification
696
- # ═════════════════════════════════════════════════════════════════════
697
-
698
- # Common ARIA roles that should be classified (not an exhaustive list)
699
- COMMON_ARIA_ROLES = {
700
- "button", "link", "textbox", "searchbox", "combobox",
701
- "checkbox", "radio", "heading", "listbox", "option",
702
- "menuitem", "tab", "switch", "slider", "spinbutton",
703
- "progressbar", "meter", "scrollbar", "gridcell", "cell",
704
- "columnheader", "rowheader", "tabpanel", "img", "figure",
705
- "listitem", "dialog", "alertdialog", "tooltip", "navigation",
706
- "banner", "form", "search", "toolbar", "menu", "menubar",
707
- "note", "alert", "status", "list", "table", "grid",
708
- "treegrid", "article", "section", "blockquote", "code",
709
- "paragraph", "text", "group", "region", "main",
710
- "complementary", "contentinfo", "definition", "term",
711
- "math", "marquee", "timer", "log", "deletion", "insertion",
712
- "mark", "suggestion", "comment",
713
- }
714
-
715
-
716
- class TestRoleSets:
717
- def test_no_role_in_both_sets(self) -> None:
718
- overlap = INTERACTIVE_ROLES & INFORMATIONAL_ROLES
719
- assert overlap == set(), f"Roles in both sets: {overlap}"
720
-
721
- def test_all_known_roles_classified(self) -> None:
722
- """Every role we know about should be in exactly one set."""
723
- union = INTERACTIVE_ROLES | INFORMATIONAL_ROLES
724
- unclassified = COMMON_ARIA_ROLES - union
725
- assert unclassified == set(), f"Unclassified roles: {unclassified}"
726
-
727
- def test_intersection_with_typescript_roles(self) -> None:
728
- """The INTERACTIVE_ROLES set should match the TypeScript one.
729
-
730
- If this test fails, run the TypeScript test to see what changed.
731
- """
732
- # As of Phase A / B, the Python and TS sets should be identical.
733
- # Count is the canonical cross-check:
734
- assert len(INTERACTIVE_ROLES) == 50, (
735
- f"Expected 50 interactive roles, got {len(INTERACTIVE_ROLES)}"
736
- )
737
- assert len(INFORMATIONAL_ROLES) == 18, (
738
- f"Expected 18 informational roles, got {len(INFORMATIONAL_ROLES)}"
739
- )
@@ -41,27 +41,17 @@ class TestCheckBotDetection:
41
41
  def test_normal_page_not_detected(self):
42
42
  """A normal page with no bot signals returns False."""
43
43
  page = _MockPage(title="Example Domain", body_text="Welcome to Example")
44
- assert check_bot_detection(page) is False
44
+ assert check_bot_detection(page) == False
45
45
 
46
46
  def test_cloudflare_in_title(self):
47
47
  """'cloudflare' in the title triggers bot detection."""
48
48
  page = _MockPage(title="Just a moment... | Cloudflare")
49
- assert check_bot_detection(page) is True
50
-
51
- def test_just_a_moment_in_title(self):
52
- """'just a moment' in the title triggers bot detection."""
53
- page = _MockPage(title="Just a moment...")
54
- assert check_bot_detection(page) is True
55
-
56
- def test_checking_browser_in_title(self):
57
- """'checking your browser' in the title triggers bot detection."""
58
- page = _MockPage(title="Checking your browser before accessing")
59
- assert check_bot_detection(page) is True
49
+ assert check_bot_detection(page) == True
60
50
 
61
51
  def test_verify_human_in_title(self):
62
52
  """'verify you are human' in the title triggers bot detection."""
63
53
  page = _MockPage(title="Verify you are human")
64
- assert check_bot_detection(page) is True
54
+ assert check_bot_detection(page) == True
65
55
 
66
56
  def test_captcha_in_title_not_detected(self):
67
57
  """'captcha' in the title does NOT trigger bot detection.
@@ -71,20 +61,7 @@ class TestCheckBotDetection:
71
61
  CAPTCHAs in passing (e.g. Wikipedia, tech blogs).
72
62
  """
73
63
  page = _MockPage(title="CAPTCHA Challenge")
74
- assert check_bot_detection(page) is False
75
-
76
- def test_cf_ray_in_body_not_detected(self):
77
- """'cf-ray' in body does NOT trigger bot detection.
78
-
79
- 'cf-ray' is an HTTP response header that rarely appears in
80
- rendered body text. The TypeScript reference (bot-detection.ts)
81
- does not include this signal.
82
- """
83
- page = _MockPage(
84
- title="Access Denied",
85
- body_text="cf-ray: abc123\nserver: cloudflare",
86
- )
87
- assert check_bot_detection(page) is False
64
+ assert check_bot_detection(page) == False
88
65
 
89
66
  def test_akamai_reference_in_body(self):
90
67
  """Akamai-style reference codes in body trigger bot detection."""
@@ -92,7 +69,7 @@ class TestCheckBotDetection:
92
69
  title="Error",
93
70
  body_text="Access denied. Reference #18.abc.def",
94
71
  )
95
- assert check_bot_detection(page) is True
72
+ assert check_bot_detection(page) == True
96
73
 
97
74
  def test_recaptcha_in_html(self):
98
75
  """'recaptcha' in HTML triggers bot detection."""
@@ -101,89 +78,7 @@ class TestCheckBotDetection:
101
78
  body_text="Submit the form",
102
79
  html='<div><script src="recaptcha/api.js"></script></div>',
103
80
  )
104
- assert check_bot_detection(page) is True
105
-
106
- def test_hcaptcha_in_html(self):
107
- """'hcaptcha' in HTML triggers bot detection."""
108
- page = _MockPage(
109
- title="Login",
110
- body_text="Login form",
111
- html='<div data-sitekey="abc" class="hcaptcha"></div>',
112
- )
113
- assert check_bot_detection(page) is True
114
-
115
- def test_turnstile_in_html(self):
116
- """Cloudflare challenge markers in body trigger bot detection."""
117
- page = _MockPage(
118
- title="Challenge",
119
- body_text="_cf_chl_opt",
120
- )
121
- assert check_bot_detection(page) is True
122
-
123
- def test_privacy_pass_in_title_not_detected(self):
124
- """'privacy pass' in title does NOT trigger detection.
125
-
126
- This signal is not present in the TypeScript reference
127
- (bot-detection.ts) and was removed to align with it.
128
- """
129
- page = _MockPage(title="Privacy Pass Challenge")
130
- assert check_bot_detection(page) is False
131
-
132
- def test_automated_access_in_title_not_detected(self):
133
- """'automated access' in title does NOT trigger detection.
134
-
135
- This signal is not present in the TypeScript reference
136
- (bot-detection.ts) and was removed to align with it.
137
- """
138
- page = _MockPage(title="Automated Access Blocked")
139
- assert check_bot_detection(page) is False
140
-
141
- def test_browser_check_in_title_not_detected(self):
142
- """'browser check' in title does NOT trigger detection.
143
-
144
- This signal is not present in the TypeScript reference
145
- (bot-detection.ts) and was removed to align with it.
146
- """
147
- page = _MockPage(title="Browser Check")
148
- assert check_bot_detection(page) is False
149
-
150
- def test_wikipedia_captcha_not_false_positive(self):
151
- """Wikipedia's 'captcha' mention in body text is NOT detected.
152
-
153
- This test validates that bot detection is title-level only for
154
- the CAPTCHA signal, preventing false positives from sites that
155
- simply mention CAPTCHA in their content.
156
- """
157
- page = _MockPage(
158
- title="Wikipedia - The Free Encyclopedia",
159
- body_text=(
160
- "CAPTCHA is a type of challenge-response test used in computing"
161
- ),
162
- )
163
- # 'captcha' is not in the title, so it should NOT trigger
164
- assert check_bot_detection(page) is False
165
-
166
- def test_blank_page_not_detected(self):
167
- """An empty or blank page does not trigger bot detection."""
168
- page = _MockPage(title="", body_text="", html="")
169
- assert check_bot_detection(page) is False
170
-
171
- def test_bot_signal_in_body_not_mistaken_if_not_high_specificity(self):
172
- """Generic phrases in body don't trigger unless high-specificity."""
173
- page = _MockPage(
174
- title="Blog",
175
- body_text="This page requires JavaScript to work properly. "
176
- "Please enable JavaScript.",
177
- )
178
- assert check_bot_detection(page) is False
179
-
180
- def test_data_sitekey_in_html(self):
181
- """'data-sitekey' (reCAPTCHA) in HTML triggers bot detection."""
182
- page = _MockPage(
183
- title="Contact",
184
- html='<div class="g-recaptcha" data-sitekey="abc123"></div>',
185
- )
186
- assert check_bot_detection(page) is True
81
+ assert check_bot_detection(page) == True
187
82
 
188
83
  def test_exception_during_title_safe(self):
189
84
  """If title() raises, bot detection still checks body and HTML."""
@@ -199,7 +94,7 @@ class TestCheckBotDetection:
199
94
  return ""
200
95
  return ""
201
96
 
202
- assert check_bot_detection(_BrokenPage()) is True
97
+ assert check_bot_detection(_BrokenPage()) == True
203
98
 
204
99
  def test_exception_during_evaluate_safe(self):
205
100
  """If evaluate() raises, bot detection returns False gracefully."""
@@ -211,38 +106,4 @@ class TestCheckBotDetection:
211
106
  def evaluate(self, expression: str, *args: Any) -> str:
212
107
  raise RuntimeError("evaluate failed")
213
108
 
214
- assert check_bot_detection(_BrokenEvalPage()) is False
215
-
216
- def test_cloudflare_nginx_in_body_not_detected(self):
217
- """'cloudflare-nginx' in body does NOT trigger detection.
218
-
219
- This signal is not present in the TypeScript reference
220
- (bot-detection.ts) and was removed to align with it.
221
- """
222
- page = _MockPage(
223
- title="Error 503",
224
- body_text="Server: cloudflare-nginx",
225
- )
226
- assert check_bot_detection(page) is False
227
-
228
- def test_reference_hash_in_body(self):
229
- """'reference #' in body triggers bot detection."""
230
- page = _MockPage(
231
- title="Blocked",
232
- body_text="Reference #18.abcde12345",
233
- )
234
- assert check_bot_detection(page) is True
235
-
236
- def test_blocked_request_in_body_not_detected(self):
237
- """'blocked request' in body does NOT trigger detection.
238
-
239
- This signal is not present in the TypeScript reference
240
- (bot-detection.ts) — more specific variants like
241
- 'you have been blocked' and 'your request has been blocked'
242
- are used instead.
243
- """
244
- page = _MockPage(
245
- title="403 Forbidden",
246
- body_text="Blocked request",
247
- )
248
- assert check_bot_detection(page) is False
109
+ assert check_bot_detection(_BrokenEvalPage()) == False
@@ -0,0 +1,131 @@
1
+ """
2
+ Tests for shared ``browser-data.json`` loading.
3
+
4
+ Verifies that the shared data file is valid JSON and that the Python
5
+ loaders in ``bot_detection``, ``accessibility``, and ``playwright_base``
6
+ can parse it correctly. A break here means both the TS and Python
7
+ sides are broken in the same way.
8
+
9
+ Pure-logic tests — no Playwright browser required.
10
+ """
11
+
12
+ import json
13
+ import os
14
+
15
+ import pytest
16
+ from pi_browser_bridge.bot_detection import _BLOCK_SIGNALS, _BODY_ONLY_SIGNALS, _BODY_ONLY_PATTERNS, _HTML_SIGNALS
17
+ from pi_browser_bridge.accessibility import INTERACTIVE_ROLES, INFORMATIONAL_ROLES, _role_icon
18
+
19
+ # Path to the shared JSON file (relative to this test file)
20
+ # tests/ -> python-base/ -> backends/ -> pi-lean-portal/ -> core/shared/browser-data.json
21
+ _SHARED_JSON = os.path.join(
22
+ os.path.dirname(__file__),
23
+ "..", "..", "..",
24
+ "core", "shared", "browser-data.json",
25
+ )
26
+
27
+
28
+ @pytest.fixture(scope="module")
29
+ def shared_data() -> dict:
30
+ """Load and parse browser-data.json once per module."""
31
+ try:
32
+ with open(_SHARED_JSON, "r") as f:
33
+ return json.load(f)
34
+ except FileNotFoundError:
35
+ pytest.fail(f"Shared data file not found: {_SHARED_JSON}")
36
+ except json.JSONDecodeError as exc:
37
+ pytest.fail(f"Shared data file is not valid JSON: {exc}")
38
+ except OSError as exc:
39
+ pytest.fail(f"Failed to read shared data file: {exc}")
40
+
41
+
42
+ # ═════════════════════════════════════════════════════════════════════
43
+ # JSON file validity
44
+ # ═════════════════════════════════════════════════════════════════════
45
+
46
+
47
+ class TestSharedJsonValidity:
48
+ def test_json_is_valid(self, shared_data: dict) -> None:
49
+ """The file parses as valid JSON with a version field."""
50
+ assert isinstance(shared_data, dict)
51
+ assert shared_data["version"] == 1
52
+
53
+ def test_has_expected_top_level_keys(self, shared_data: dict) -> None:
54
+ """All three top-level sections are present."""
55
+ assert "botSignals" in shared_data
56
+ assert "accessibility" in shared_data
57
+ assert "navSettle" in shared_data
58
+
59
+ def test_bot_signals_has_expected_keys(self, shared_data: dict) -> None:
60
+ bot = shared_data["botSignals"]
61
+ assert "blockSignals" in bot
62
+ assert "bodyOnlySignals" in bot
63
+ assert "bodyOnlyPatterns" in bot
64
+ assert "htmlSignals" in bot
65
+
66
+ def test_accessibility_has_expected_keys(self, shared_data: dict) -> None:
67
+ a11y = shared_data["accessibility"]
68
+ assert "interactiveRoles" in a11y
69
+ assert "informationalRoles" in a11y
70
+ assert "roleIcons" in a11y
71
+
72
+ def test_nav_settle_has_expected_keys(self, shared_data: dict) -> None:
73
+ ns = shared_data["navSettle"]
74
+ assert "navTimeoutMs" in ns
75
+ assert "settleTimeoutMs" in ns
76
+ assert "settleRaceMs" in ns
77
+ assert "domStabilizationJs" in ns
78
+
79
+
80
+ # ═════════════════════════════════════════════════════════════════════
81
+ # Python-side loaders match the JSON data
82
+ # ═════════════════════════════════════════════════════════════════════
83
+
84
+
85
+ class TestBotDetectionLoader:
86
+ """Verify bot_detection.py loaded its signals from the shared JSON."""
87
+
88
+ def test_block_signals_match_json(self, shared_data: dict) -> None:
89
+ expected = tuple(shared_data["botSignals"]["blockSignals"])
90
+ assert _BLOCK_SIGNALS == expected
91
+
92
+ def test_body_only_signals_match_json(self, shared_data: dict) -> None:
93
+ expected = tuple(shared_data["botSignals"]["bodyOnlySignals"])
94
+ assert _BODY_ONLY_SIGNALS == expected
95
+
96
+ def test_html_signals_match_json(self, shared_data: dict) -> None:
97
+ expected = tuple(shared_data["botSignals"]["htmlSignals"])
98
+ assert _HTML_SIGNALS == expected
99
+
100
+ def test_body_only_patterns_count(self, shared_data: dict) -> None:
101
+ """The number of compiled patterns matches the source strings."""
102
+ expected_count = len(shared_data["botSignals"]["bodyOnlyPatterns"])
103
+ assert len(_BODY_ONLY_PATTERNS) == expected_count
104
+
105
+ def test_reference_code_pattern_matches(self) -> None:
106
+ """The compiled reference# pattern matches expected input."""
107
+ assert len(_BODY_ONLY_PATTERNS) >= 1
108
+ pattern = _BODY_ONLY_PATTERNS[0]
109
+ assert pattern.search("reference #abc123.def") is not None
110
+ assert pattern.search("Reference#abc.def") is not None
111
+ assert pattern.search("normal text") is None
112
+
113
+
114
+ class TestAccessibilityLoader:
115
+ """Verify accessibility.py loaded its data from the shared JSON."""
116
+
117
+ def test_interactive_roles_match_json(self, shared_data: dict) -> None:
118
+ expected = set(shared_data["accessibility"]["interactiveRoles"])
119
+ assert INTERACTIVE_ROLES == expected
120
+
121
+ def test_informational_roles_match_json(self, shared_data: dict) -> None:
122
+ expected = set(shared_data["accessibility"]["informationalRoles"])
123
+ assert INFORMATIONAL_ROLES == expected
124
+
125
+ def test_role_icons_match_json(self, shared_data: dict) -> None:
126
+ icons = shared_data["accessibility"]["roleIcons"]
127
+ for role, expected_icon in icons.items():
128
+ assert _role_icon(role) == expected_icon, f"Icon mismatch for role '{role}'"
129
+
130
+ def test_role_icon_unknown_returns_empty(self) -> None:
131
+ assert _role_icon("nonexistent_role") == ""