diff --git a/cumulusci/robotframework/SalesforcePlaywright.py b/cumulusci/robotframework/SalesforcePlaywright.py index 2302d1513b..d1e3be94f7 100644 --- a/cumulusci/robotframework/SalesforcePlaywright.py +++ b/cumulusci/robotframework/SalesforcePlaywright.py @@ -107,7 +107,8 @@ def open_test_browser( browser_id = self.browser.new_browser(browser=browser_enum, headless=headless) context_id = self.browser.new_context( - viewport={"width": width, "height": height}, recordVideo=record_video + viewport={"width": int(width), "height": int(height)}, + recordVideo=record_video, ) self.browser.set_browser_timeout("15 seconds") page_details = self.browser.new_page(login_url) @@ -210,6 +211,131 @@ def _check_for_classic(self): except AssertionError: return False + @capture_screenshot_on_error + def open_app_launcher(self, timeout="15 seconds"): + """Opens the Salesforce App Launcher by clicking the waffle button. + + Waits for the App Launcher modal dialog to appear. + """ + self.browser.click("button.slds-icon-waffle_container") + self.wait_until_modal_is_open(timeout=timeout) + + @capture_screenshot_on_error + def select_app_launcher_app(self, app_name, timeout="15 seconds"): + """Searches for and selects an app in the App Launcher. + + Requires the App Launcher dialog to already be open. + """ + search_input = "input[placeholder='Search apps and items...']" + self.browser.fill_text(search_input, app_name) + time.sleep(1) + self.browser.click(f"a.slds-app-launcher__tile--small:has-text('{app_name}')") + self.wait_until_loading_is_complete() + + @capture_screenshot_on_error + def select_app_launcher_tab(self, tab_name, timeout="15 seconds"): + """Searches for and selects a tab/item in the App Launcher. + + Requires the App Launcher dialog to already be open. + """ + search_input = "input[placeholder='Search apps and items...']" + self.browser.fill_text(search_input, tab_name) + time.sleep(1) + self.browser.click(f"one-app-launcher-menu-item a:has-text('{tab_name}')") + self.wait_until_loading_is_complete() + + @capture_screenshot_on_error + def populate_field(self, name, value): + """Finds a form field by its label and fills it with the given value. + + ``name`` is the label text of the field. + ``value`` is the text to enter. + """ + input_el = f"lightning-input label:has-text('{name}')" + try: + self.browser.get_element(input_el) + self.browser.fill_text( + f"lightning-input:has(label:has-text('{name}')) input", value + ) + return + except (AssertionError, Exception): + pass + + textarea_el = f"lightning-textarea:has(label:has-text('{name}')) textarea" + try: + self.browser.get_element(textarea_el) + self.browser.fill_text(textarea_el, value) + return + except (AssertionError, Exception): + pass + + generic = f"label:has-text('{name}')" + self.browser.get_element(generic) + self.browser.fill_text( + f":near({generic}) input, :near({generic}) textarea", value + ) + + @capture_screenshot_on_error + def populate_form(self, **kwargs): + """Fills in multiple form fields at once. + + Each keyword argument maps a field label to its desired value. + + Example:: + + Populate Form First Name=Alice Last Name=Smith + """ + for name, value in kwargs.items(): + self.populate_field(name, value) + + @capture_screenshot_on_error + def click_modal_button(self, button_text, timeout="15 seconds"): + """Clicks a button with the given text inside the currently open modal dialog. + + Waits for the modal to be present first. + """ + self.wait_until_modal_is_open(timeout=timeout) + self.browser.click( + f"div.slds-modal__container button:has-text('{button_text}')" + ) + + @capture_screenshot_on_error + def wait_until_modal_is_open(self, timeout="15 seconds"): + """Waits until a Salesforce modal dialog (``slds-modal``) is visible.""" + self.browser.wait_for_elements_state("section.slds-modal", "visible", timeout) + + @capture_screenshot_on_error + def wait_until_modal_is_closed(self, timeout="15 seconds"): + """Waits until all Salesforce modal dialogs (``slds-modal``) have disappeared.""" + self.browser.wait_for_elements_state("section.slds-modal", "detached", timeout) + + @capture_screenshot_on_error + def click_related_list_button(self, heading, button_title): + """Clicks a button within a related list identified by its heading. + + ``heading`` is the title text of the related list (e.g. "Contacts"). + ``button_title`` is the visible text of the button to click. + """ + card = f"article.slds-card:has(span[title='{heading}'])" + self.browser.click(f"{card} button:has-text('{button_title}')") + + @capture_screenshot_on_error + def get_related_list_count(self, heading): + """Returns the item count displayed in a related list's heading. + + ``heading`` is the title of the related list (e.g. "Contacts"). + Returns the integer count parsed from the heading, or 0 if no + count is found. + """ + span_text = self.browser.get_text( + f"article.slds-card:has(span[title='{heading}']) " + f"span.slds-card__header-title" + ) + match = re.search(r"\((\d+)\)", span_text) + if match: + return int(match.group(1)) + return 0 + def breakpoint(self): """Serves as a breakpoint for the robot debugger diff --git a/cumulusci/robotframework/locators_66.py b/cumulusci/robotframework/locators_66.py new file mode 100644 index 0000000000..b1866f9f0a --- /dev/null +++ b/cumulusci/robotframework/locators_66.py @@ -0,0 +1,39 @@ +import copy + +from cumulusci.robotframework import locators_57 + +lex_locators = copy.deepcopy(locators_57.lex_locators) + +# API v66: Actions ribbon migrated from Aura (oneActionsRibbon) to LWC in some +# views (e.g. filtered list views). The slds-page-header div is in the light +# DOM on all page types and serves as a reliable "page loaded" signal. +lex_locators["actions"] = ( + "//runtime_platform_actions-actions-ribbon//ul" + "|//ul[contains(concat(' ',normalize-space(@class),' '),' oneActionsRibbon ')]" + "|//div[contains(@class, 'slds-page-header')]" +) + +# API v66: App name changed from nested spans inside div.navLeft to an h1.appName +# element. Use a broader XPath that matches both old and new structures. +lex_locators["app_launcher"]["current_app"] = ( + "//*[contains(@class,'appName')][.//text()='{}']" +) + +# API v66: Related list container migrated from Aura force_relatedListContainer +# to LWC. The count text is now inside a different element structure. +# Use a broader selector that matches both old and new related list layouts. +lex_locators["record"]["related"]["count"] = ( + "//*[@data-component-id='force_relatedListContainer']" + "//article//span[@title='{0}']/following-sibling::span" + "|//lst-related-list-single-container" + "//article//span[@title='{0}']/following-sibling::span" + "|//article[.//span[@title='{0}']]//span[contains(@class,'countText')]" +) + +# API v66: List View Controls button moved into LWC shadow DOM on some page +# types (e.g. filtered list views). Selenium 3 cannot pierce shadow DOM. +# The CSS fallback below works only when the button is in light DOM; shadow DOM +# occurrences are a known, unfixable limitation of the Selenium approach. +lex_locators["list_view_menu"]["button"] = ( + "css:button[title='List View Controls'],button[aria-label='List View Controls']" +) diff --git a/cumulusci/robotframework/tests/salesforce/TestLibraryA.py b/cumulusci/robotframework/tests/salesforce/TestLibraryA.py index 6f42d3023d..5b9bce819b 100644 --- a/cumulusci/robotframework/tests/salesforce/TestLibraryA.py +++ b/cumulusci/robotframework/tests/salesforce/TestLibraryA.py @@ -10,7 +10,8 @@ locators = { # eg: A:breadcrumb:Home - "breadcrumb": "//span[contains(@class, 'breadcrumbDetail') and text()='{}']", + # API v66: breadcrumbDetail class removed; use text match on any link + "breadcrumb": "//a[normalize-space(.)='{}']", "something": "//whatever", } diff --git a/cumulusci/robotframework/tests/salesforce/locators.robot b/cumulusci/robotframework/tests/salesforce/locators.robot index 7f2ce50b4a..38c888ae7d 100644 --- a/cumulusci/robotframework/tests/salesforce/locators.robot +++ b/cumulusci/robotframework/tests/salesforce/locators.robot @@ -17,7 +17,7 @@ Locator strategy 'text' # Try to use the locator strategy on an element # we know should be on the page. - Wait until page contains element text:Mobile Publisher + Wait until page contains element text:Object Manager Locator strategy 'title' [Documentation] diff --git a/cumulusci/robotframework/tests/salesforce/playwright/e2e_comparison.robot b/cumulusci/robotframework/tests/salesforce/playwright/e2e_comparison.robot new file mode 100644 index 0000000000..b6bfce706f --- /dev/null +++ b/cumulusci/robotframework/tests/salesforce/playwright/e2e_comparison.robot @@ -0,0 +1,32 @@ +*** Settings *** +Resource cumulusci/robotframework/SalesforcePlaywright.robot + +Suite Setup Open test browser +Suite Teardown Delete records and close browser + +Force Tags playwright + +*** Test Cases *** +Create contact via app launcher and verify + [Documentation] + ... End-to-end test exercising app launcher navigation, form population, + ... modal handling, and record verification via SalesforcePlaywright keywords. + ... This test is part of the Robot Framework maintenance comparison PoC. + + Open app launcher + Select app launcher app Sales + Wait until loading is complete + + Click css:.slds-page-header button[name='New'] + Wait until modal is open + + Populate form + ... First Name=Test + ... Last Name=RobotPlaywright + + Click modal button Save + Wait until modal is closed + Wait until loading is complete + + ${record_id}= Get current record id + Should not be empty ${record_id} diff --git a/cumulusci/robotframework/tests/test_salesforce_locators.py b/cumulusci/robotframework/tests/test_salesforce_locators.py index 0a706b7fe0..b05112a5ef 100644 --- a/cumulusci/robotframework/tests/test_salesforce_locators.py +++ b/cumulusci/robotframework/tests/test_salesforce_locators.py @@ -19,10 +19,14 @@ def test_locators_in_robot_context(self, get_latest_api_version): # This instantiates the robot library, mimicking a robot library import. # We've mocked out the code that would otherwise throw an error since # we're not running in the context of a robot test. The library should - # return the latest version of the locators. + # fall back to the latest version since the mock doesn't reach the + # init path that resolves the API version from the org. sf = Salesforce() - expected = "cumulusci.robotframework.locators_57" + locator_folder = Path("./cumulusci/robotframework") + locator_modules = sorted(locator_folder.glob("locators_[0-9][0-9].py")) + expected = f"cumulusci.robotframework.{locator_modules[-1].stem}" + actual = sf.locators_module.__name__ message = "expected to load '{}', actually loaded '{}'".format(expected, actual) assert expected == actual, message @@ -71,3 +75,17 @@ def test_locators_57(self): ) assert len(keys_56) > 0 assert keys_57.issubset(keys_56) + + def test_locators_66(self): + """Verify that locators_66 is a superset of locators_57""" + import cumulusci.robotframework.locators_57 as locators_57 + import cumulusci.robotframework.locators_66 as locators_66 + + keys_57 = set(locators_57.lex_locators) + keys_66 = set(locators_66.lex_locators) + + assert id(locators_57.lex_locators) != id(locators_66.lex_locators), ( + "locators_57.lex_locators and locators_66.lex_locators are the same object" + ) + assert len(keys_57) > 0 + assert keys_66.issubset(keys_57) diff --git a/docs/adrs/0004-evidence/findings.md b/docs/adrs/0004-evidence/findings.md new file mode 100644 index 0000000000..f666dd6d10 --- /dev/null +++ b/docs/adrs/0004-evidence/findings.md @@ -0,0 +1,53 @@ +# Selenium 4 Shadow DOM Verification — Findings + +**Date:** 2026-04-27 +**Org:** `robot-poc` scratch (API v66, instance USA882S) +**Page tested:** `/lightning/o/Account/list` (the same page where `forms.robot::Lightning based form - radiobutton` fails) +**Element targeted:** `button[title='List View Controls']` (the `sf:list_view_menu.button` locator that fails in `forms.robot`) + +## Setup + +- Isolated venv at `/tmp/selenium4-poc/` with `selenium==4.43.0` and headless Chrome. +- Authenticated via Salesforce frontdoor URL (`/secur/frontdoor.jsp?sid=`). +- Two scripts: [`verify_shadow_dom.py`](verify_shadow_dom.py) (overall capability) and [`measure_nesting.py`](measure_nesting.py) (depth measurement). + +## Measurements + +| Measurement | Value | +| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Shadow roots present on the page | **452** | +| Selenium 3 XPath/CSS matches for the target button (light DOM) | **0** | +| Shadow boundary depth from button to `` | **6 hops** | +| 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` | +| Outermost host (`lst-object-home`) findable in light DOM? | **Yes (1 match)** | +| 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 | +| Playwright equivalent | `page.get_by_role("button", name="List View Controls").click()` — 1 line, auto-pierces all 6 boundaries | + +## Implications + +1. **Selenium 3 cannot reach the element.** Confirmed: 0 matches via XPath or CSS in the light DOM. +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-*`). +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. +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. + +## Cost ratio for shadow-DOM-bound elements + +| Path | Statements per element | Stability of selectors | Per-version maintenance | +| --------------------------------------------------- | ---------------------- | --------------------------------------- | ------------------------------ | +| Selenium 3 (current) | N/A — unfixable | N/A | Growing failures every release | +| Selenium 4 chained traversal | ~14 | LWC-internal names (brittle, like Aura) | Per-release rewrites likely | +| Playwright (`get_by_role`, `text=`, `data-testid=`) | 1 | ARIA / SLDS public contract | Near-zero | + +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. + +## Reproducibility + +```bash +# In a clean tmpdir +uv venv .venv --python 3.13 +uv pip install --python ./.venv 'selenium>=4' +./.venv/bin/python verify_shadow_dom.py +./.venv/bin/python measure_nesting.py +``` + +Requires `sf` CLI authenticated against an org alias and the script edited to point at it. diff --git a/docs/adrs/0004-evidence/measure_nesting.py b/docs/adrs/0004-evidence/measure_nesting.py new file mode 100644 index 0000000000..1478beb163 --- /dev/null +++ b/docs/adrs/0004-evidence/measure_nesting.py @@ -0,0 +1,136 @@ +""" +Measure the shadow DOM nesting depth for the List View Controls button. + +This determines whether Selenium 4's WebElement.shadow_root API can practically +reach the element. If the host is multiple shadow boundaries deep, Selenium 4 +needs to chain shadow_root.find_element().shadow_root.find_element() at every +level — verbose but possible IF every intermediate host is findable. +""" + +import json +import subprocess +import time + +from selenium import webdriver +from selenium.webdriver.chrome.options import Options +from selenium.webdriver.common.by import By + + +def get_org_auth(): + result = subprocess.run( + [ + "sf", + "org", + "display", + "--target-org", + "test-0frwzz98ullr@example.com", + "--json", + ], + capture_output=True, + text=True, + check=True, + ) + data = json.loads(result.stdout)["result"] + return data["instanceUrl"], data["accessToken"] + + +def main(): + instance_url, access_token = get_org_auth() + options = Options() + options.add_argument("--headless=new") + options.add_argument("--no-sandbox") + options.add_argument("--disable-dev-shm-usage") + options.add_argument("--window-size=1280,1024") + + driver = webdriver.Chrome(options=options) + + try: + frontdoor = f"{instance_url}/secur/frontdoor.jsp?sid={access_token}" + driver.get(frontdoor) + time.sleep(3) + lightning_url = instance_url.replace( + ".my.salesforce.com", ".lightning.force.com" + ) + driver.get(f"{lightning_url}/lightning/o/Account/list") + time.sleep(8) + + # Walk up the shadow DOM tree from the target button to body, recording + # each shadow boundary crossed. + js = """ + function findInShadowDom(selector, root = document) { + const matches = []; + const all = root.querySelectorAll('*'); + for (const el of all) { + if (el.matches(selector)) matches.push(el); + if (el.shadowRoot) matches.push(...findInShadowDom(selector, el.shadowRoot)); + } + return matches; + } + const buttons = findInShadowDom("button[title='List View Controls']"); + if (buttons.length === 0) return { error: 'no button found' }; + const target = buttons[0]; + + // Walk parent chain, recording shadow boundaries. + const path = []; + let node = target; + while (node) { + const parent = node.parentNode; + if (!parent) break; + if (parent instanceof ShadowRoot) { + // Crossed a shadow boundary; jump to the host + path.push({ + type: 'shadow_boundary', + host: parent.host.tagName.toLowerCase(), + }); + node = parent.host; + } else { + node = parent; + if (node === document.body) break; + } + } + + // Count light-DOM-findable hosts: an LWC host is "findable by Selenium" + // if it is in light DOM (parent is not a ShadowRoot). + return { + shadow_boundaries: path.length, + host_chain: path, + }; + """ + result = driver.execute_script(js) + print("Shadow boundary nesting depth:", result.get("shadow_boundaries")) + print("Host chain (target → body):") + for i, h in enumerate(result.get("host_chain", [])): + print(f" [{i}] <{h['host']}>") + + # Now test: can Selenium 4 chain shadow_root through the nested hosts? + # Walk from outermost host inward. + chain = result.get("host_chain", []) + outermost = chain[-1]["host"] if chain else None + if outermost: + print(f"\nOutermost shadow host: <{outermost}>") + outer_count = len(driver.find_elements(By.CSS_SELECTOR, outermost)) + print(f" Light DOM matches for <{outermost}>: {outer_count}") + + if outer_count == 0: + print(" [VERDICT] Outermost host itself is NOT findable in light DOM.") + print( + " Selenium 4 WebElement.shadow_root API requires the host to be " + "found via find_element first, which requires light DOM presence." + ) + print( + " Therefore: Selenium 4 CANNOT reach this element through the " + "documented shadow_root API." + ) + else: + print( + " Outermost host IS in light DOM. Selenium 4 chained " + "shadow_root traversal would require " + f"{result['shadow_boundaries']} hops." + ) + + finally: + driver.quit() + + +if __name__ == "__main__": + main() diff --git a/docs/adrs/0004-evidence/verify_shadow_dom.py b/docs/adrs/0004-evidence/verify_shadow_dom.py new file mode 100644 index 0000000000..f87911428c --- /dev/null +++ b/docs/adrs/0004-evidence/verify_shadow_dom.py @@ -0,0 +1,223 @@ +""" +Selenium 4 shadow DOM piercing verification against the robot-poc scratch org. + +Goal: Verify whether Selenium 4's shadow_root API can reach +`list_view_menu.button` (the "List View Controls" gear) on a filtered list view, +which Selenium 3 cannot find. + +Authenticates via Salesforce frontdoor URL using the access token from +sf cli, navigates to the Account list view (which is where forms.robot runs +the failing radiobutton test), and attempts multiple shadow DOM piercing +strategies. +""" + +import json +import subprocess +import sys +import time + +from selenium import webdriver +from selenium.webdriver.chrome.options import Options +from selenium.webdriver.common.by import By + + +def get_org_auth(): + """Get instance URL and access token from sf cli.""" + result = subprocess.run( + [ + "sf", + "org", + "display", + "--target-org", + "test-0frwzz98ullr@example.com", + "--json", + ], + capture_output=True, + text=True, + check=True, + ) + data = json.loads(result.stdout)["result"] + return data["instanceUrl"], data["accessToken"] + + +def main(): + print("=" * 70) + print("Selenium 4 Shadow DOM Piercing Verification") + print("=" * 70) + + instance_url, access_token = get_org_auth() + print(f"Instance: {instance_url}") + print( + f"Selenium: {webdriver.__version__ if hasattr(webdriver, '__version__') else 'unknown'}" + ) + + options = Options() + options.add_argument("--headless=new") + options.add_argument("--no-sandbox") + options.add_argument("--disable-dev-shm-usage") + options.add_argument("--window-size=1280,1024") + + driver = webdriver.Chrome(options=options) + + try: + # 1. Authenticate via frontdoor + frontdoor = f"{instance_url}/secur/frontdoor.jsp?sid={access_token}" + print("\n[1] Authenticating via frontdoor URL...") + driver.get(frontdoor) + time.sleep(3) + print(f" Landed at: {driver.current_url[:120]}") + + # 2. Switch to Lightning if needed (frontdoor lands on classic for some orgs) + if ( + "/one/one.app" not in driver.current_url + and "lightning.force.com" not in driver.current_url + ): + print(" Navigating to Lightning home...") + lightning_url = instance_url.replace( + ".my.salesforce.com", ".lightning.force.com" + ) + driver.get(f"{lightning_url}/lightning/page/home") + time.sleep(5) + print(f" Now at: {driver.current_url[:120]}") + + # 3. Navigate to Account list view (same place forms.robot fails) + lightning_base = driver.current_url.split("/lightning/")[0] + list_view_url = f"{lightning_base}/lightning/o/Account/list" + print(f"\n[2] Navigating to Account list view: {list_view_url}") + driver.get(list_view_url) + time.sleep(8) + print(f" Page title: {driver.title}") + + # 4. Attempt to find list_view_menu.button via Selenium 3 style first + print("\n[3] Selenium-3 style locator (light DOM only):") + s3_locator = ( + "//lightning-button-icon[@title='List View Controls']/button" + "|//*[contains(@class, 'slds-icon-utility-settings')]" + ) + results = driver.find_elements(By.XPATH, s3_locator) + print(f" XPath light-DOM match count: {len(results)}") + for i, r in enumerate(results[:3]): + try: + print(f" [{i}] tag={r.tag_name}, displayed={r.is_displayed()}") + except Exception as e: + print(f" [{i}] error inspecting: {e}") + + # 5. Try Selenium 3 CSS fallback (the one we used in locators_66.py) + print("\n[4] Selenium-3 CSS fallback locator:") + css_locator = ( + "button[title='List View Controls'],button[aria-label='List View Controls']" + ) + results = driver.find_elements(By.CSS_SELECTOR, css_locator) + print(f" Light-DOM CSS match count: {len(results)}") + + # 6. Walk the LWC component tree and look for shadow roots + print("\n[5] Walking shadow DOM tree:") + # The list view Lightning component is `lst-related-list-view-manager` / + # `force-list-view-manager` etc. depending on version. Let's just find ALL + # shadow hosts and check if any contain "List View Controls". + js_search = """ + function findInShadowDom(selector, root = document) { + const matches = []; + const all = root.querySelectorAll('*'); + for (const el of all) { + if (el.matches(selector)) matches.push(el); + if (el.shadowRoot) { + matches.push(...findInShadowDom(selector, el.shadowRoot)); + } + } + return matches; + } + const matches = findInShadowDom( + "button[title='List View Controls'], button[aria-label='List View Controls'], button[name='showListViewActions']" + ); + return matches.map(m => ({ + tag: m.tagName.toLowerCase(), + title: m.getAttribute('title'), + ariaLabel: m.getAttribute('aria-label'), + name: m.getAttribute('name'), + inShadow: m.getRootNode() !== document, + shadowHost: m.getRootNode() !== document ? m.getRootNode().host?.tagName?.toLowerCase() : null, + })); + """ + all_buttons = driver.execute_script(js_search) + print(f" Total matches across all DOM levels: {len(all_buttons)}") + for i, b in enumerate(all_buttons[:5]): + print( + f" [{i}] tag={b['tag']} title={b['title']!r} " + f"in_shadow={b['inShadow']} shadow_host={b['shadowHost']}" + ) + + # 7. Now use Selenium 4 shadow_root API to navigate to one of them + print("\n[6] Selenium 4 shadow_root API access attempt:") + if all_buttons and any(b["inShadow"] for b in all_buttons): + shadow_targets = [b for b in all_buttons if b["inShadow"]] + print( + f" Found {len(shadow_targets)} shadow-DOM-only matches. " + f"Testing Selenium 4 traversal..." + ) + + # Find shadow hosts that contain List View Controls + host_tag = shadow_targets[0]["shadowHost"] + print(f" Targeting shadow host: <{host_tag}>") + + try: + hosts = driver.find_elements(By.CSS_SELECTOR, host_tag) + print(f" Shadow host element count: {len(hosts)}") + pierced = 0 + for h in hosts: + try: + sr = h.shadow_root + try: + inner = sr.find_element( + By.CSS_SELECTOR, + "button[title='List View Controls']", + ) + print( + f" [SUCCESS] Pierced shadow root via " + f"WebElement.shadow_root, found inner button: " + f"tag={inner.tag_name}" + ) + pierced += 1 + break + except Exception: + continue + except Exception: + # Some elements may not be shadow hosts + continue + if pierced == 0: + print( + " [PARTIAL] shadow_root accessible but inner button " + "not found in expected host" + ) + except Exception as e: + print(f" [FAIL] {type(e).__name__}: {e}") + elif all_buttons: + print(" Button(s) found in light DOM, no shadow DOM piercing needed.") + else: + print(" [CAVEAT] No List View Controls button found anywhere on page.") + print(" The element may only render after a list view is selected.") + + # 8. As an additional data point: count shadow roots present + shadow_count = driver.execute_script( + """ + let count = 0; + const walker = document.createTreeWalker(document, NodeFilter.SHOW_ELEMENT); + let node; + while ((node = walker.nextNode())) { + if (node.shadowRoot) count++; + } + return count; + """ + ) + print(f"\n[7] Total shadow roots on this page: {shadow_count}") + + print("\n" + "=" * 70) + print("VERIFICATION COMPLETE") + print("=" * 70) + + finally: + driver.quit() + + +if __name__ == "__main__": + sys.exit(main() or 0) diff --git a/docs/adrs/0004-robot-framework-selenium-vs-playwright.md b/docs/adrs/0004-robot-framework-selenium-vs-playwright.md new file mode 100644 index 0000000000..4979dae3bc --- /dev/null +++ b/docs/adrs/0004-robot-framework-selenium-vs-playwright.md @@ -0,0 +1,258 @@ +--- +date: 2026-04-27 +status: Proposed +author: "@jstvz" +--- + +# 4. Robot Framework: Selenium Locator Maintenance vs Playwright Migration + +## TL;DR + +Migrate CumulusCI's Robot Framework browser-test infrastructure from Selenium 3 to `robotframework-browser` (Playwright) over a time-bounded deprecation period (Phases M1–M5, expected 6–12 months for the downstream-coordination tail). Selenium 4 is no longer required. The `sf:` locator prefix and `Salesforce.robot` resource remain available during deprecation; downstream consumers (NPSP, EDA, OFM, V4S) migrate on their own schedule with tooling support. Selenium per-release maintenance is tractable today (4 locator overrides bridged 10 API versions) but its shadow-DOM trajectory is structurally bad and Selenium 4 only shifts the brittleness from Aura to LWC at ~14× the verbosity (per single-element measurement; see body). + +## Context and Problem Statement + +CumulusCI's Robot Framework tests use Selenium 3 (pinned at [`pyproject.toml:54`](../../pyproject.toml) via `selenium<4` and [`pyproject.toml:50`](../../pyproject.toml) via `robotframework-seleniumlibrary<6`) with versioned locator dictionaries (one Python file per Salesforce API version). Per-release locator maintenance stopped due to team capacity constraints — no new locator file has been added since API v56 ([`locators_57.py`](../../cumulusci/robotframework/locators_57.py) is an identity copy of [`locators_56.py`](../../cumulusci/robotframework/locators_56.py); current Salesforce production is v66, ~10 versions stale). + +Three developments change the calculus: + +1. **Agent-era token budgets.** Automated agents can absorb mechanical maintenance (locator diffs, test updates). The question is no longer "do we have engineers for this," it is "what is the per-release cost in agent tokens, and which path has the lower trajectory." +2. **Shadow DOM migration.** Salesforce continues migrating components from Aura to LWC. LWC components render inside shadow DOM. Selenium 3 cannot pierce shadow DOM at all; Selenium 4 can, but only through verbose chained traversal. This creates a structural ceiling for the Selenium-based approach that grows with every Salesforce release. +3. **Playwright maturity.** `robotframework-browser` is stable, ships Playwright bindings for Robot Framework, and provides accessibility-tree-first selectors that auto-pierce shadow DOM at any depth. + +We need to decide CumulusCI's Robot Framework path: continue Selenium with agent-driven maintenance, migrate to Playwright, or run both. + +### Assumptions + +- Agent tokens are abundant for mechanical tasks. +- Salesforce will continue Aura→LWC migration each release; shadow DOM surface area grows monotonically. +- Downstream consumers (NPSP, EDA, OFM, V4S, etc.) currently depend on the `sf:` locator prefix and `Salesforce.robot` resource. +- `robotframework-browser` is a viable production library (3.x current, actively maintained). + +### Constraints + +- **Selenium 3 cannot pierce shadow DOM.** Verified empirically: 0 matches in light DOM for `List View Controls` button targeted by `forms.robot`. +- **Selenium 4 can pierce shadow DOM** via `WebElement.shadow_root`, but only via explicit chained traversal through every boundary. +- **Backward compatibility.** Downstream consumers use the `sf:` prefix today. Migration cost is real. +- **`robotframework-browser` requires runtime install.** `cci robot install_playwright` adds Node.js + Playwright binaries to the dev environment. Already supported as an optional path. + +## Evidence from PoC + +> **Important caveat:** No Playwright end-to-end test passed during the PoC. The 10 Track B keywords were validated by static review and selector-strategy analysis, not by runtime execution. A pre-existing regex bug in [`SalesforcePlaywright.wait_until_salesforce_is_ready`](../../cumulusci/robotframework/SalesforcePlaywright.py) (line 165) blocks all Playwright E2E execution, including the existing baseline `playwright.robot` suite that predates this PoC. **Phase M1 fixes this as the first migration step.** The Playwright argument here rests on selector-strategy and shadow-DOM-piercing evidence, not on runtime test counts. + +**Methodology in one paragraph.** Track A (Selenium 3) updated [`locators_66.py`](../../cumulusci/robotframework/locators_66.py) with 4 overrides and ran the 11-suite Selenium battery plus 4 page-object suites against a `robot-poc` scratch org (API v66). Track B (Playwright) added 10 keywords to [`SalesforcePlaywright.py`](../../cumulusci/robotframework/SalesforcePlaywright.py) using accessibility-first selectors. After the main PoC, Selenium 4.43.0 was installed in an isolated venv and tested against the same scratch org with two scripts ([`measure_nesting.py`](0004-evidence/measure_nesting.py), [`verify_shadow_dom.py`](0004-evidence/verify_shadow_dom.py)) to measure shadow-DOM traversal cost. All scripts and findings live under [`docs/adrs/0004-evidence/`](0004-evidence/) for reproduction. + +### Track A — Selenium 3 locator refresh (v56 → v66) + +| Metric | Result | +| ----------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Versioned locator overrides added ([`locators_66.py`](../../cumulusci/robotframework/locators_66.py)) | **4** (`actions`, `app_launcher.current_app`, `list_view_menu.button`, `record.related.count`) | +| Selenium test pass rate (11 suites) | **101 / 102** | +| Unfixable failure | **1** — `forms.robot::radiobutton` (List View Controls in shadow DOM) | +| Page object pass rate | **29 / 34** (34 cases across 4 page-object suites) | +| Page object failures | **5** — inline locators in [`ObjectManagerPageObject.py`](../../cumulusci/robotframework/pageobjects/ObjectManagerPageObject.py) (Save button changed ``→`