Skip to content

Commit 5a78d0c

Browse files
authored
fix: relax selenium exact pin to allow Appium 3 compatibility (#1312) (#1319)
* fix: relax selenium exact pin to allow Appium 3 compatibility (#1312)
1 parent 6785bad commit 5a78d0c

12 files changed

Lines changed: 1034 additions & 56 deletions

File tree

.github/workflows/main.yaml

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,67 @@ jobs:
119119
name: ${{ matrix.os }}-py${{ matrix.python }}-test-reports
120120
path: packages/main/tests/results
121121

122+
build-wheel:
123+
runs-on: ubuntu-latest
124+
if: github.event_name == 'pull_request'
125+
permissions:
126+
pull-requests: write
127+
steps:
128+
- uses: actions/checkout@v5
129+
- name: Set up Python
130+
uses: actions/setup-python@v5
131+
with:
132+
python-version: "3.11"
133+
- name: Install uv
134+
run: pip install uv
135+
working-directory: "."
136+
- name: Build wheel
137+
run: uv build --wheel
138+
working-directory: packages/main
139+
- name: Upload wheel artifact
140+
uses: actions/upload-artifact@v4
141+
with:
142+
name: rpaframework-wheel
143+
path: packages/main/dist/*.whl
144+
retention-days: 14
145+
- name: Comment on PR with artifact link
146+
uses: actions/github-script@v7
147+
with:
148+
script: |
149+
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
150+
const sha = context.payload.pull_request.head.sha.slice(0, 7);
151+
const marker = '<!-- rpaframework-wheel-artifact -->';
152+
const body = [
153+
marker,
154+
'## Wheel artifact',
155+
'',
156+
`Built from commit \`${sha}\` — download from the [workflow run](${runUrl}) (Artifacts section at the bottom of the page).`,
157+
'',
158+
'Artifact name: `rpaframework-wheel` (retained 14 days)',
159+
].join('\n');
160+
161+
const { data: comments } = await github.rest.issues.listComments({
162+
owner: context.repo.owner,
163+
repo: context.repo.repo,
164+
issue_number: context.issue.number,
165+
});
166+
const existing = comments.find(c => c.body.includes(marker));
167+
if (existing) {
168+
await github.rest.issues.updateComment({
169+
owner: context.repo.owner,
170+
repo: context.repo.repo,
171+
comment_id: existing.id,
172+
body,
173+
});
174+
} else {
175+
await github.rest.issues.createComment({
176+
issue_number: context.issue.number,
177+
owner: context.repo.owner,
178+
repo: context.repo.repo,
179+
body,
180+
});
181+
}
182+
122183
# publish:
123184
# # Only publish on master workflow runs
124185
# if: github.ref == 'refs/heads/master'

packages/core/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "rpaframework-core"
3-
version = "12.1.0"
3+
version = "12.1.1"
44
description = "Core utilities used by RPA Framework"
55
authors = [{name = "RPA Framework", email = "rpafw@robocorp.com"}]
66
license = {text = "Apache-2.0"}

packages/core/src/RPA/core/webdriver.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,11 @@
3333
windows_browser_apps_to_cmd,
3434
)
3535
from webdriver_manager.drivers.chrome import ChromeDriver as _ChromeDriver
36+
from webdriver_manager.drivers.edge import EdgeChromiumDriver as _EdgeChromiumDriver
3637
from webdriver_manager.drivers.ie import IEDriver as _IEDriver
3738
from webdriver_manager.firefox import GeckoDriverManager
3839
from webdriver_manager.microsoft import (
39-
EdgeChromiumDriverManager,
40+
EdgeChromiumDriverManager as _EdgeChromiumDriverManager,
4041
IEDriverManager as _IEDriverManager,
4142
)
4243
from webdriver_manager.opera import OperaDriverManager
@@ -302,6 +303,46 @@ def _get_driver_binary_path(self, driver: ChromeDriver) -> str:
302303
return binary_path
303304

304305

306+
class EdgeChromiumDriver(_EdgeChromiumDriver):
307+
"""Strips the UTF-16 BOM that Microsoft's LATEST_RELEASE endpoint prepends.
308+
309+
webdriver-manager 4.0.2 calls ``resp.text.rstrip()`` on the response from
310+
``https://msedgedriver.microsoft.com/LATEST_RELEASE_<major>_WINDOWS``.
311+
That endpoint returns a UTF-16 LE response (BOM ``\\xff\\xfe``), which
312+
``requests`` decodes to a string starting with ``\\ufeff`` (the BOM
313+
codepoint). ``.rstrip()`` only strips trailing whitespace, leaving the
314+
leading ``\\ufeff`` intact. When that BOM-prefixed version is interpolated
315+
into the download URL it becomes ``%EF%BB%BF145.0.3800.97/...`` which
316+
returns 404.
317+
"""
318+
319+
@staticmethod
320+
def _strip_bom(text: str) -> str:
321+
return text.strip().lstrip("\ufeff")
322+
323+
def get_stable_release_version(self) -> str:
324+
return self._strip_bom(super().get_stable_release_version())
325+
326+
def get_latest_release_version(self) -> str:
327+
return self._strip_bom(super().get_latest_release_version())
328+
329+
330+
class EdgeChromiumDriverManager(_EdgeChromiumDriverManager):
331+
"""Custom Edge driver manager that uses :class:`EdgeChromiumDriver`."""
332+
333+
def __init__(self, *args, **kwargs):
334+
super().__init__(*args, **kwargs)
335+
original = self.driver
336+
self.driver = EdgeChromiumDriver(
337+
name=getattr(original, "_name", "msedgedriver"),
338+
driver_version=getattr(original, "_driver_version_to_download", None),
339+
url=getattr(original, "_url", None),
340+
latest_release_url=getattr(original, "_latest_release_url", None),
341+
http_client=self.http_client,
342+
os_system_manager=getattr(original, "_os_system_manager", None),
343+
)
344+
345+
305346
class IEDriver(_IEDriver):
306347
"""Custom IE driver class that handles discontinued IE driver gracefully."""
307348

@@ -663,7 +704,7 @@ def _get_driver_binary_path(self, driver: IEDriver) -> str:
663704
"opera": OperaDriverManager,
664705
# NOTE: In Selenium 4 `Edge` is the same with `ChromiumEdge`.
665706
"edge": EdgeChromiumDriverManager,
666-
"chromiumedge": EdgeChromiumDriverManager,
707+
"chromiumedge": EdgeChromiumDriverManager, # uses BOM-stripping subclass
667708
# NOTE: IE is discontinued and not supported/encouraged anymore.
668709
"ie": IEDriverManager,
669710
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
#!/usr/bin/env python3
2+
"""Standalone EdgeDriver integration test for issues #1312 and BOM bug.
3+
4+
Verifies that:
5+
1. Microsoft's LATEST_RELEASE endpoint BOM is stripped before building the
6+
download URL — without the fix, the URL contains %EF%BB%BF and returns 404
7+
2. EdgeDriver binary can be downloaded (tests azureedge.net -> microsoft.com
8+
redirect fix from PR #1227) — this is the primary test
9+
3. A headless Edge session can be started (end-to-end, requires Edge browser installed)
10+
11+
Expected to FAIL without the BOM-strip fix (webdriver-manager 4.0.2 bug).
12+
Expected to PASS after fix: EdgeChromiumDriver.get_latest_release_version() strips \\ufeff.
13+
14+
Exit codes:
15+
0 — download succeeded (session also started if Edge browser is installed)
16+
1 — download or session failed unexpectedly
17+
18+
Usage:
19+
python test_edge_driver.py
20+
# or from packages/core directory:
21+
uv run python tests/python/test_edge_driver.py
22+
"""
23+
import shutil
24+
import sys
25+
from pathlib import Path
26+
27+
28+
def main() -> int:
29+
import importlib.metadata
30+
31+
selenium_ver = importlib.metadata.version("selenium")
32+
print(f"Selenium version: {selenium_ver}")
33+
34+
# Step 0: verify BOM-strip fix in EdgeChromiumDriver
35+
print("Checking BOM strip in EdgeChromiumDriver...")
36+
try:
37+
from RPA.core.webdriver import EdgeChromiumDriver
38+
bom_version = "\ufeff145.0.3800.97"
39+
stripped = EdgeChromiumDriver._strip_bom(bom_version)
40+
assert stripped == "145.0.3800.97", f"BOM not stripped: {stripped!r}"
41+
print(f" BOM strip OK: {bom_version!r} -> {stripped!r}")
42+
except Exception as exc:
43+
print(f"\nFAIL (BOM strip check): {type(exc).__name__}: {exc}")
44+
return 1
45+
46+
# Step 1: download EdgeDriver — exercises the azureedge.net redirect patch
47+
try:
48+
from RPA.core import webdriver
49+
50+
results_dir = Path(__file__).parent / "results"
51+
results_dir.mkdir(exist_ok=True)
52+
print("Downloading EdgeDriver...")
53+
driver_path = webdriver.download("Edge", root=results_dir)
54+
print(f" Driver downloaded: {driver_path}")
55+
except Exception as exc:
56+
print(f"\nFAIL (download): {type(exc).__name__}: {exc}")
57+
return 1
58+
59+
# Step 2: start a headless Edge session — requires Edge browser to be installed
60+
edge_binary = shutil.which("msedge") or shutil.which("microsoft-edge")
61+
if not edge_binary:
62+
# Check standard macOS application path
63+
macos_edge = Path("/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge")
64+
if macos_edge.exists():
65+
edge_binary = str(macos_edge)
66+
if not edge_binary:
67+
# Check standard Windows installation paths (msedge.exe is not on PATH by default)
68+
import os
69+
windows_paths = [
70+
Path(os.environ.get("PROGRAMFILES(X86)", "")) / "Microsoft/Edge/Application/msedge.exe",
71+
Path(os.environ.get("PROGRAMFILES", "")) / "Microsoft/Edge/Application/msedge.exe",
72+
Path(os.environ.get("LOCALAPPDATA", "")) / "Microsoft/Edge/Application/msedge.exe",
73+
]
74+
for p in windows_paths:
75+
if p.exists():
76+
edge_binary = str(p)
77+
break
78+
79+
if not edge_binary:
80+
print(
81+
"\nPASS (download only): EdgeDriver downloaded successfully. "
82+
"Edge browser not installed — skipping session test."
83+
)
84+
return 0
85+
86+
try:
87+
from selenium import webdriver as selenium_webdriver
88+
from selenium.webdriver import EdgeOptions
89+
from selenium.webdriver.edge.service import Service
90+
91+
options = EdgeOptions()
92+
options.add_argument("--headless=new")
93+
options.add_argument("--no-sandbox")
94+
options.add_argument("--disable-dev-shm-usage")
95+
options.binary_location = edge_binary
96+
service = Service(driver_path)
97+
print(f"Starting headless Edge session (binary: {edge_binary})...")
98+
driver = selenium_webdriver.Edge(service=service, options=options)
99+
try:
100+
driver.get("about:blank")
101+
title = driver.title
102+
finally:
103+
driver.quit()
104+
print(f" Session OK, page title: '{title}'")
105+
except Exception as exc:
106+
print(f"\nFAIL (session): {type(exc).__name__}: {exc}")
107+
return 1
108+
109+
print("\nPASS: EdgeDriver download and session both work correctly.")
110+
return 0
111+
112+
113+
if __name__ == "__main__":
114+
sys.exit(main())

packages/main/pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "rpaframework"
3-
version = "31.1.2"
3+
version = "31.2.0"
44
description = "A collection of tools and libraries for RPA"
55
authors = [{name = "RPA Framework", email = "rpafw@robocorp.com"}]
66
license = {text = "Apache-2.0"}
@@ -60,7 +60,7 @@ dependencies = [
6060
"mss>=6.0.0",
6161
"chardet>=3.0.0",
6262
"PySocks>=1.5.6,!=1.5.7,<2.0.0",
63-
"selenium==4.15.2",
63+
"selenium>=4.16.0,<5.0.0",
6464
"click>=8.1.2",
6565
"PyYAML>=5.4.1,<7.0.0",
6666
"tenacity>=8.0.1",

packages/main/scripts/ai-review.sh

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
#!/usr/bin/env bash
2+
# AI code review — runs Claude against the current branch diff (vs master).
3+
#
4+
# Usage:
5+
# ./packages/main/scripts/ai-review.sh # review all changes vs master
6+
# ./packages/main/scripts/ai-review.sh --staged # review only staged changes
7+
#
8+
# Called automatically by pre-push.sh; can also be run standalone.
9+
# Requires `claude` (Claude Code CLI) to be on PATH.
10+
11+
set -euo pipefail
12+
13+
REPO_ROOT="$(git rev-parse --show-toplevel)"
14+
BASE_BRANCH="${AI_REVIEW_BASE:-master}"
15+
if ! [[ "$BASE_BRANCH" =~ ^[A-Za-z0-9/_.-]+$ ]]; then
16+
echo ">>> ai-review: invalid AI_REVIEW_BASE value '$BASE_BRANCH' — aborting." >&2
17+
exit 1
18+
fi
19+
STAGED_ONLY="${1:-}"
20+
21+
if ! command -v claude &>/dev/null; then
22+
echo ">>> ai-review: 'claude' not found on PATH — skipping AI review."
23+
exit 0
24+
fi
25+
26+
# Build the diff
27+
if [[ "$STAGED_ONLY" == "--staged" ]]; then
28+
DIFF="$(git -C "$REPO_ROOT" diff --cached)"
29+
DIFF_LABEL="staged changes"
30+
else
31+
DIFF="$(git -C "$REPO_ROOT" diff "$BASE_BRANCH"...HEAD)"
32+
DIFF_LABEL="changes vs $BASE_BRANCH"
33+
fi
34+
35+
if [[ -z "$DIFF" ]]; then
36+
echo ">>> ai-review: no diff found ($DIFF_LABEL) — skipping."
37+
exit 0
38+
fi
39+
40+
DIFF_LINES="$(echo "$DIFF" | wc -l | tr -d ' ')"
41+
echo ">>> Running AI review on $DIFF_LABEL ($DIFF_LINES lines of diff)..."
42+
43+
PROMPT="You are a senior Python code reviewer. Review the following git diff carefully.
44+
45+
Focus ONLY on real problems (not style nits). Flag:
46+
- Wrong exception types raised (e.g. built-in TimeoutError instead of selenium TimeoutException)
47+
- Missing guards for platform/driver-specific APIs (e.g. Chromium-only features called on Firefox)
48+
- Misleading or incorrect error messages
49+
- Import errors or removed APIs used
50+
- Security issues (command injection, SQL injection, XSS, path traversal, etc.)
51+
- Logic bugs that would cause test failures or silent misbehaviour
52+
- CI/CD configuration mistakes (wrong working-directory, missing permissions, etc.)
53+
54+
Format your response as a concise bulleted list. If there are no issues, say 'No issues found.'
55+
Group by severity: [HIGH], [MEDIUM], [LOW].
56+
57+
GIT DIFF:
58+
$DIFF"
59+
60+
# Unset CLAUDECODE so this can run inside a Claude Code session (e.g. during /review)
61+
echo "$PROMPT" | env -u CLAUDECODE claude --print --output-format text
62+
EXIT_CODE=$?
63+
64+
echo ""
65+
if [[ $EXIT_CODE -ne 0 ]]; then
66+
echo ">>> ai-review: claude exited with code $EXIT_CODE."
67+
fi
68+
exit 0 # AI review is advisory — never block the push

packages/main/scripts/pre-push.sh

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
#!/usr/bin/env bash
2+
# Pre-push hook for packages/main — runs lint, fast tests, and AI review locally.
3+
# Install: cp packages/main/scripts/pre-push.sh .git/hooks/pre-push && chmod +x .git/hooks/pre-push
4+
# Skip AI review: AI_REVIEW=0 git push
5+
6+
set -euo pipefail
7+
REPO_ROOT="$(git rev-parse --show-toplevel)"
8+
cd "$REPO_ROOT/packages/main"
9+
10+
echo ">>> Running pylint..."
11+
uv run pylint --rcfile ../../config/pylint src
12+
13+
echo ">>> Running fast tests (no browser required)..."
14+
uv run pytest tests/python/test_browser.py \
15+
-k "not (TestSelenium or TestRelativeLocators or TestBrowserLogs or TestNetworkInterception or TestVirtualAuthenticator)" \
16+
-v
17+
18+
# AI review (advisory — never blocks the push; set AI_REVIEW=0 to skip)
19+
if [[ "${AI_REVIEW:-1}" != "0" ]]; then
20+
bash "$REPO_ROOT/packages/main/scripts/ai-review.sh"
21+
fi
22+
23+
echo ">>> OK — pushing."

0 commit comments

Comments
 (0)