Skip to content

Commit 754daaf

Browse files
committed
feat(robot): add Selenium vs Playwright comparison PoC
Add the public Robot Framework comparison PoC artifacts: - API v66 Selenium locator refresh (`locators_66.py`) and focused tests - 10 Playwright keyword ports plus an E2E comparison suite - ADR 0004 recommending a time-bounded Playwright migration - Reproducible Selenium 4 shadow-DOM evidence under docs/adrs/0004-evidence Internal docs/superpowers artifacts are intentionally excluded from this branch history.
1 parent c425ffd commit 754daaf

10 files changed

Lines changed: 891 additions & 5 deletions

File tree

cumulusci/robotframework/SalesforcePlaywright.py

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,8 @@ def open_test_browser(
107107

108108
browser_id = self.browser.new_browser(browser=browser_enum, headless=headless)
109109
context_id = self.browser.new_context(
110-
viewport={"width": width, "height": height}, recordVideo=record_video
110+
viewport={"width": int(width), "height": int(height)},
111+
recordVideo=record_video,
111112
)
112113
self.browser.set_browser_timeout("15 seconds")
113114
page_details = self.browser.new_page(login_url)
@@ -210,6 +211,131 @@ def _check_for_classic(self):
210211
except AssertionError:
211212
return False
212213

214+
@capture_screenshot_on_error
215+
def open_app_launcher(self, timeout="15 seconds"):
216+
"""Opens the Salesforce App Launcher by clicking the waffle button.
217+
218+
Waits for the App Launcher modal dialog to appear.
219+
"""
220+
self.browser.click("button.slds-icon-waffle_container")
221+
self.wait_until_modal_is_open(timeout=timeout)
222+
223+
@capture_screenshot_on_error
224+
def select_app_launcher_app(self, app_name, timeout="15 seconds"):
225+
"""Searches for and selects an app in the App Launcher.
226+
227+
Requires the App Launcher dialog to already be open.
228+
"""
229+
search_input = "input[placeholder='Search apps and items...']"
230+
self.browser.fill_text(search_input, app_name)
231+
time.sleep(1)
232+
self.browser.click(f"a.slds-app-launcher__tile--small:has-text('{app_name}')")
233+
self.wait_until_loading_is_complete()
234+
235+
@capture_screenshot_on_error
236+
def select_app_launcher_tab(self, tab_name, timeout="15 seconds"):
237+
"""Searches for and selects a tab/item in the App Launcher.
238+
239+
Requires the App Launcher dialog to already be open.
240+
"""
241+
search_input = "input[placeholder='Search apps and items...']"
242+
self.browser.fill_text(search_input, tab_name)
243+
time.sleep(1)
244+
self.browser.click(f"one-app-launcher-menu-item a:has-text('{tab_name}')")
245+
self.wait_until_loading_is_complete()
246+
247+
@capture_screenshot_on_error
248+
def populate_field(self, name, value):
249+
"""Finds a form field by its label and fills it with the given value.
250+
251+
``name`` is the label text of the field.
252+
``value`` is the text to enter.
253+
"""
254+
input_el = f"lightning-input label:has-text('{name}')"
255+
try:
256+
self.browser.get_element(input_el)
257+
self.browser.fill_text(
258+
f"lightning-input:has(label:has-text('{name}')) input", value
259+
)
260+
return
261+
except (AssertionError, Exception):
262+
pass
263+
264+
textarea_el = f"lightning-textarea:has(label:has-text('{name}')) textarea"
265+
try:
266+
self.browser.get_element(textarea_el)
267+
self.browser.fill_text(textarea_el, value)
268+
return
269+
except (AssertionError, Exception):
270+
pass
271+
272+
generic = f"label:has-text('{name}')"
273+
self.browser.get_element(generic)
274+
self.browser.fill_text(
275+
f":near({generic}) input, :near({generic}) textarea", value
276+
)
277+
278+
@capture_screenshot_on_error
279+
def populate_form(self, **kwargs):
280+
"""Fills in multiple form fields at once.
281+
282+
Each keyword argument maps a field label to its desired value.
283+
284+
Example::
285+
286+
Populate Form First Name=Alice Last Name=Smith
287+
"""
288+
for name, value in kwargs.items():
289+
self.populate_field(name, value)
290+
291+
@capture_screenshot_on_error
292+
def click_modal_button(self, button_text, timeout="15 seconds"):
293+
"""Clicks a button with the given text inside the currently open modal dialog.
294+
295+
Waits for the modal to be present first.
296+
"""
297+
self.wait_until_modal_is_open(timeout=timeout)
298+
self.browser.click(
299+
f"div.slds-modal__container button:has-text('{button_text}')"
300+
)
301+
302+
@capture_screenshot_on_error
303+
def wait_until_modal_is_open(self, timeout="15 seconds"):
304+
"""Waits until a Salesforce modal dialog (``slds-modal``) is visible."""
305+
self.browser.wait_for_elements_state("section.slds-modal", "visible", timeout)
306+
307+
@capture_screenshot_on_error
308+
def wait_until_modal_is_closed(self, timeout="15 seconds"):
309+
"""Waits until all Salesforce modal dialogs (``slds-modal``) have disappeared."""
310+
self.browser.wait_for_elements_state("section.slds-modal", "detached", timeout)
311+
312+
@capture_screenshot_on_error
313+
def click_related_list_button(self, heading, button_title):
314+
"""Clicks a button within a related list identified by its heading.
315+
316+
``heading`` is the title text of the related list (e.g. "Contacts").
317+
``button_title`` is the visible text of the button to click.
318+
"""
319+
card = f"article.slds-card:has(span[title='{heading}'])"
320+
self.browser.click(f"{card} button:has-text('{button_title}')")
321+
322+
@capture_screenshot_on_error
323+
def get_related_list_count(self, heading):
324+
"""Returns the item count displayed in a related list's heading.
325+
326+
``heading`` is the title of the related list (e.g. "Contacts").
327+
Returns the integer count parsed from the heading, or 0 if no
328+
count is found.
329+
"""
330+
span_text = self.browser.get_text(
331+
f"article.slds-card:has(span[title='{heading}']) "
332+
f"span.slds-card__header-title"
333+
)
334+
match = re.search(r"\((\d+)\)", span_text)
335+
if match:
336+
return int(match.group(1))
337+
return 0
338+
213339
def breakpoint(self):
214340
"""Serves as a breakpoint for the robot debugger
215341
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import copy
2+
3+
from cumulusci.robotframework import locators_57
4+
5+
lex_locators = copy.deepcopy(locators_57.lex_locators)
6+
7+
# API v66: Actions ribbon migrated from Aura (oneActionsRibbon) to LWC in some
8+
# views (e.g. filtered list views). The slds-page-header div is in the light
9+
# DOM on all page types and serves as a reliable "page loaded" signal.
10+
lex_locators["actions"] = (
11+
"//runtime_platform_actions-actions-ribbon//ul"
12+
"|//ul[contains(concat(' ',normalize-space(@class),' '),' oneActionsRibbon ')]"
13+
"|//div[contains(@class, 'slds-page-header')]"
14+
)
15+
16+
# API v66: App name changed from nested spans inside div.navLeft to an h1.appName
17+
# element. Use a broader XPath that matches both old and new structures.
18+
lex_locators["app_launcher"]["current_app"] = (
19+
"//*[contains(@class,'appName')][.//text()='{}']"
20+
)
21+
22+
# API v66: Related list container migrated from Aura force_relatedListContainer
23+
# to LWC. The count text is now inside a different element structure.
24+
# Use a broader selector that matches both old and new related list layouts.
25+
lex_locators["record"]["related"]["count"] = (
26+
"//*[@data-component-id='force_relatedListContainer']"
27+
"//article//span[@title='{0}']/following-sibling::span"
28+
"|//lst-related-list-single-container"
29+
"//article//span[@title='{0}']/following-sibling::span"
30+
"|//article[.//span[@title='{0}']]//span[contains(@class,'countText')]"
31+
)
32+
33+
# API v66: List View Controls button moved into LWC shadow DOM on some page
34+
# types (e.g. filtered list views). Selenium 3 cannot pierce shadow DOM.
35+
# The CSS fallback below works only when the button is in light DOM; shadow DOM
36+
# occurrences are a known, unfixable limitation of the Selenium approach.
37+
lex_locators["list_view_menu"]["button"] = (
38+
"css:button[title='List View Controls'],button[aria-label='List View Controls']"
39+
)

cumulusci/robotframework/tests/salesforce/TestLibraryA.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010

1111
locators = {
1212
# eg: A:breadcrumb:Home
13-
"breadcrumb": "//span[contains(@class, 'breadcrumbDetail') and text()='{}']",
13+
# API v66: breadcrumbDetail class removed; use text match on any link
14+
"breadcrumb": "//a[normalize-space(.)='{}']",
1415
"something": "//whatever",
1516
}
1617

cumulusci/robotframework/tests/salesforce/locators.robot

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ Locator strategy 'text'
1717

1818
# Try to use the locator strategy on an element
1919
# we know should be on the page.
20-
Wait until page contains element text:Mobile Publisher
20+
Wait until page contains element text:Object Manager
2121

2222
Locator strategy 'title'
2323
[Documentation]
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
*** Settings ***
2+
Resource cumulusci/robotframework/SalesforcePlaywright.robot
3+
4+
Suite Setup Open test browser
5+
Suite Teardown Delete records and close browser
6+
7+
Force Tags playwright
8+
9+
*** Test Cases ***
10+
Create contact via app launcher and verify
11+
[Documentation]
12+
... End-to-end test exercising app launcher navigation, form population,
13+
... modal handling, and record verification via SalesforcePlaywright keywords.
14+
... This test is part of the Robot Framework maintenance comparison PoC.
15+
16+
Open app launcher
17+
Select app launcher app Sales
18+
Wait until loading is complete
19+
20+
Click css:.slds-page-header button[name='New']
21+
Wait until modal is open
22+
23+
Populate form
24+
... First Name=Test
25+
... Last Name=RobotPlaywright
26+
27+
Click modal button Save
28+
Wait until modal is closed
29+
Wait until loading is complete
30+
31+
${record_id}= Get current record id
32+
Should not be empty ${record_id}

cumulusci/robotframework/tests/test_salesforce_locators.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,14 @@ def test_locators_in_robot_context(self, get_latest_api_version):
1919
# This instantiates the robot library, mimicking a robot library import.
2020
# We've mocked out the code that would otherwise throw an error since
2121
# we're not running in the context of a robot test. The library should
22-
# return the latest version of the locators.
22+
# fall back to the latest version since the mock doesn't reach the
23+
# init path that resolves the API version from the org.
2324
sf = Salesforce()
2425

25-
expected = "cumulusci.robotframework.locators_57"
26+
locator_folder = Path("./cumulusci/robotframework")
27+
locator_modules = sorted(locator_folder.glob("locators_[0-9][0-9].py"))
28+
expected = f"cumulusci.robotframework.{locator_modules[-1].stem}"
29+
2630
actual = sf.locators_module.__name__
2731
message = "expected to load '{}', actually loaded '{}'".format(expected, actual)
2832
assert expected == actual, message
@@ -71,3 +75,17 @@ def test_locators_57(self):
7175
)
7276
assert len(keys_56) > 0
7377
assert keys_57.issubset(keys_56)
78+
79+
def test_locators_66(self):
80+
"""Verify that locators_66 is a superset of locators_57"""
81+
import cumulusci.robotframework.locators_57 as locators_57
82+
import cumulusci.robotframework.locators_66 as locators_66
83+
84+
keys_57 = set(locators_57.lex_locators)
85+
keys_66 = set(locators_66.lex_locators)
86+
87+
assert id(locators_57.lex_locators) != id(locators_66.lex_locators), (
88+
"locators_57.lex_locators and locators_66.lex_locators are the same object"
89+
)
90+
assert len(keys_57) > 0
91+
assert keys_66.issubset(keys_57)
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Selenium 4 Shadow DOM Verification — Findings
2+
3+
**Date:** 2026-04-27
4+
**Org:** `robot-poc` scratch (API v66, instance USA882S)
5+
**Page tested:** `/lightning/o/Account/list` (the same page where `forms.robot::Lightning based form - radiobutton` fails)
6+
**Element targeted:** `button[title='List View Controls']` (the `sf:list_view_menu.button` locator that fails in `forms.robot`)
7+
8+
## Setup
9+
10+
- Isolated venv at `/tmp/selenium4-poc/` with `selenium==4.43.0` and headless Chrome.
11+
- Authenticated via Salesforce frontdoor URL (`/secur/frontdoor.jsp?sid=<access_token>`).
12+
- Two scripts: [`verify_shadow_dom.py`](verify_shadow_dom.py) (overall capability) and [`measure_nesting.py`](measure_nesting.py) (depth measurement).
13+
14+
## Measurements
15+
16+
| Measurement | Value |
17+
| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
18+
| Shadow roots present on the page | **452** |
19+
| Selenium 3 XPath/CSS matches for the target button (light DOM) | **0** |
20+
| Shadow boundary depth from button to `<body>` | **6 hops** |
21+
| Host chain (button → outer) | `lightning-button-menu``lst-list-view-manager-settings-menu``lst-list-view-manager-header``lst-common-list-internal``lst-list-view-manager``lst-object-home` |
22+
| Outermost host (`lst-object-home`) findable in light DOM? | **Yes (1 match)** |
23+
| Selenium 4 chained `shadow_root` traversal feasible? | Yes, but requires **7 `find_element` + 6 `shadow_root` accesses + 1 final action = ~14 statements** of Python per element |
24+
| Playwright equivalent | `page.get_by_role("button", name="List View Controls").click()` — 1 line, auto-pierces all 6 boundaries |
25+
26+
## Implications
27+
28+
1. **Selenium 3 cannot reach the element.** Confirmed: 0 matches via XPath or CSS in the light DOM.
29+
2. **Selenium 4 can technically reach it,** because the outermost shadow host is in light DOM. But it requires manual chaining through 6 boundaries with internal LWC component names (`lst-*`).
30+
3. **The host chain contains LWC-internal names.** Names like `lst-list-view-manager-settings-menu` are SLDS implementation details and likely to be renamed across API versions. Each rename = locator break in the same way Aura `force_*` classes break today.
31+
4. **Playwright auto-pierces shadow DOM** at any depth, using accessibility-tree selectors that map to the public ARIA contract — meaningfully more stable than internal LWC component names.
32+
33+
## Cost ratio for shadow-DOM-bound elements
34+
35+
| Path | Statements per element | Stability of selectors | Per-version maintenance |
36+
| --------------------------------------------------- | ---------------------- | --------------------------------------- | ------------------------------ |
37+
| Selenium 3 (current) | N/A — unfixable | N/A | Growing failures every release |
38+
| Selenium 4 chained traversal | ~14 | LWC-internal names (brittle, like Aura) | Per-release rewrites likely |
39+
| Playwright (`get_by_role`, `text=`, `data-testid=`) | 1 | ARIA / SLDS public contract | Near-zero |
40+
41+
For this single measured element, the Selenium 4 path is ~14× more verbose than the Playwright path. The 452 shadow-host count above describes how heavily Lightning is LWC-componentized on this page; it is a count of shadow hosts, not of unreachable elements. Other shadow-DOM-bound elements in the test suite may be 1–2 hops shallow or 6+ hops deep — we did not measure the distribution.
42+
43+
## Reproducibility
44+
45+
```bash
46+
# In a clean tmpdir
47+
uv venv .venv --python 3.13
48+
uv pip install --python ./.venv 'selenium>=4'
49+
./.venv/bin/python verify_shadow_dom.py
50+
./.venv/bin/python measure_nesting.py
51+
```
52+
53+
Requires `sf` CLI authenticated against an org alias and the script edited to point at it.

0 commit comments

Comments
 (0)