diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..af917f6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +output/ +*.png +__pycache__/ +*.pyc +.env diff --git a/README.md b/README.md index 73bd04a..0d98789 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,46 @@ # playwright-robot-android-browserstack -Sample project demonstrating Robot Framework with Playwright Library running on real Android devices via BrowserStack Automate. + +Sample project demonstrating Robot Framework with the [playwright-browserstack](https://pypi.org/project/playwright-browserstack/) library running on real Android devices via BrowserStack Automate. + +## Prerequisites + +- Python >= 3.9 +- A [BrowserStack](https://www.browserstack.com/) account (set `BROWSERSTACK_USERNAME` and `BROWSERSTACK_ACCESS_KEY`) + +## Setup + +```bash +pip install -r requirements.txt +``` + +## Run + +```bash +export BROWSERSTACK_USERNAME= +export BROWSERSTACK_ACCESS_KEY= + +robot tests/sample_test.robot +``` + +## Project Structure + +``` +libraries/ + PlaywrightAndroidLibrary.py # Robot Framework keyword library (Playwright Android) +resources/ + android_keywords.robot # High-level BrowserStack keywords + variables.robot # Credentials + device capabilities +tests/ + sample_test.robot # Sample test suite (The Internet demo site) +output/ # Robot output artifacts (gitignored) +``` + +## What the sample tests cover + +| Test | Action | +|------|--------| +| Verify Page Title | Navigate to The Internet, assert page title | +| Verify Checkbox Interactions | Check/uncheck checkboxes | +| Verify Dropdown Selection | Select Option 1 from a dropdown | +| Verify Go Back Navigation | Browser back navigation | +| Take Screenshot And Mark Passed | Screenshot + mark BrowserStack session passed | diff --git a/libraries/PlaywrightAndroidLibrary.py b/libraries/PlaywrightAndroidLibrary.py new file mode 100644 index 0000000..76e72f8 --- /dev/null +++ b/libraries/PlaywrightAndroidLibrary.py @@ -0,0 +1,228 @@ +""" +Robot Framework keyword library for Playwright Android (BrowserStack). + +Usage in .robot files: + Library ../libraries/PlaywrightAndroidLibrary.py +""" + +import json +import urllib.parse +from playwright.sync_api import sync_playwright, expect + + +class PlaywrightAndroidLibrary: + """Playwright Android keywords for Robot Framework.""" + + ROBOT_LIBRARY_SCOPE = "SUITE" + + def __init__(self): + self._pw = None + self._device = None + self._context = None + self._page = None + + # ── Connection ──────────────────────────────────────────────────────────── + + def connect_to_browserstack(self, username, access_key, caps: dict, timeout=120_000): + """Connect to a BrowserStack Android device. + + Example: + | ${caps}= | Create Dictionary | deviceName=Samsung Galaxy S23 | osVersion=13.0 | + | Connect To BrowserStack | myuser | mykey | ${caps} | + """ + caps = dict(caps) + caps["browserstack.username"] = username + caps["browserstack.accessKey"] = access_key + ws_endpoint = ( + "wss://cdp.browserstack.com/playwright?caps=" + + urllib.parse.quote(json.dumps(caps)) + ) + self._pw = sync_playwright().start() + self._device = self._pw.android.connect(ws_endpoint, timeout=int(timeout)) + + def get_device_info(self): + """Return (serial, model) of the connected device.""" + self._require_device() + return self._device.serial, self._device.model + + # ── Browser ─────────────────────────────────────────────────────────────── + + def launch_browser(self, pkg=None, has_touch=False): + """Launch Chrome on the device and create a new page. + + Example: + | Launch Browser | + | Launch Browser | has_touch=True | + """ + self._require_device() + try: + self._device.shell("am force-stop com.android.chrome") + except Exception: + pass + self._context = self._device.launch_browser( + pkg=pkg, has_touch=has_touch == "True" or has_touch is True + ) + self._page = self._context.new_page() + + def close_browser(self): + """Close the browser context.""" + if self._page: + self._page.close() + self._page = None + if self._context: + self._context.close() + self._context = None + + # ── Navigation ──────────────────────────────────────────────────────────── + + def navigate_to(self, url, timeout=30_000): + """Navigate to a URL. + + Example: + | Navigate To | https://example.com | + """ + self._require_page() + self._page.goto(url, timeout=int(timeout)) + + def go_back(self): + """Navigate back in browser history.""" + self._require_page() + self._page.go_back() + + def wait_for_timeout(self, milliseconds): + """Wait for the given number of milliseconds.""" + self._require_page() + self._page.wait_for_timeout(int(milliseconds)) + + # ── Assertions ──────────────────────────────────────────────────────────── + + def page_title_should_be(self, expected_title): + """Assert the current page title equals the expected value.""" + self._require_page() + expect(self._page).to_have_title(expected_title) + + def page_title_should_contain(self, text): + """Assert the current page title contains the given text.""" + self._require_page() + actual = self._page.title() + assert text in actual, f"Title {actual!r} does not contain {text!r}" + + def heading_should_contain(self, name, text): + """Assert a heading with the given name contains text.""" + self._require_page() + heading = self._page.get_by_role("heading", name=name) + content = heading.text_content() + assert text in content, f"Heading text {content!r} does not contain {text!r}" + + # ── Interactions ────────────────────────────────────────────────────────── + + def click_link(self, name): + """Click a link by its visible text. + + Example: + | Click Link | Checkboxes | + """ + self._require_page() + self._page.get_by_role("link", name=name).click() + + def select_option_by_label(self, selector, label): + """Select a dropdown option by its visible label. + + Example: + | Select Option By Label | #dropdown | Option 1 | + """ + self._require_page() + self._page.locator(selector).select_option(label=label) + + # ── Checkboxes ──────────────────────────────────────────────────────────── + + def checkbox_should_be_unchecked(self, index=0): + """Assert the Nth checkbox (0-based) is unchecked.""" + self._require_page() + checkboxes = self._page.get_by_role("checkbox") + cb = checkboxes.nth(int(index)) + assert not cb.is_checked(), f"Checkbox {index} should be unchecked" + + def checkbox_should_be_checked(self, index=0): + """Assert the Nth checkbox (0-based) is checked.""" + self._require_page() + checkboxes = self._page.get_by_role("checkbox") + cb = checkboxes.nth(int(index)) + assert cb.is_checked(), f"Checkbox {index} should be checked" + + def check_checkbox(self, index=0): + """Check the Nth checkbox (0-based).""" + self._require_page() + self._page.get_by_role("checkbox").nth(int(index)).check() + + def uncheck_checkbox(self, index=0): + """Uncheck the Nth checkbox (0-based).""" + self._require_page() + self._page.get_by_role("checkbox").nth(int(index)).uncheck() + + # ── Screenshot ──────────────────────────────────────────────────────────── + + def take_page_screenshot(self, path="screenshot.png"): + """Save a screenshot of the current page. + + Example: + | Take Page Screenshot | output/my_test.png | + """ + self._require_page() + self._page.screenshot(path=path) + + def take_device_screenshot(self, path="device_screenshot.png"): + """Save a full-device screenshot. + + Example: + | Take Device Screenshot | output/device.png | + """ + self._require_device() + self._device.screenshot(path=path) + + # ── BrowserStack session status ─────────────────────────────────────────── + + def set_session_name(self, name): + """Set the BrowserStack session name.""" + self._require_page() + self._page.evaluate( + "_ => {}", + f"browserstack_executor: {json.dumps({'action': 'setSessionName', 'arguments': {'name': name}})}", + ) + + def mark_session_as_passed(self, reason="Test passed"): + """Mark the BrowserStack session as passed.""" + self._set_session_status("passed", reason) + + def mark_session_as_failed(self, reason="Test failed"): + """Mark the BrowserStack session as failed.""" + self._set_session_status("failed", reason) + + def _set_session_status(self, status, reason): + self._require_page() + self._page.evaluate( + "_ => {}", + f"browserstack_executor: {json.dumps({'action': 'setSessionStatus', 'arguments': {'status': status, 'reason': reason}})}", + ) + + # ── Teardown ────────────────────────────────────────────────────────────── + + def disconnect_device(self): + """Close the browser, disconnect the device, and stop Playwright.""" + self.close_browser() + if self._device: + self._device.close() + self._device = None + if self._pw: + self._pw.stop() + self._pw = None + + # ── Internal ────────────────────────────────────────────────────────────── + + def _require_device(self): + if self._device is None: + raise RuntimeError("No device connected. Call 'Connect To BrowserStack' first.") + + def _require_page(self): + if self._page is None: + raise RuntimeError("No page open. Call 'Launch Browser' first.") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cd99a95 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +playwright-browserstack>=1.56.1 +robotframework>=6.0 diff --git a/resources/android_keywords.robot b/resources/android_keywords.robot new file mode 100644 index 0000000..6935a04 --- /dev/null +++ b/resources/android_keywords.robot @@ -0,0 +1,28 @@ +*** Settings *** +Library ../libraries/PlaywrightAndroidLibrary.py +Resource variables.robot + + +*** Keywords *** +Connect To BrowserStack Device + [Documentation] Open a BrowserStack Android session using suite-level caps. + Connect To BrowserStack ${BS_USERNAME} ${BS_ACCESS_KEY} ${BS_CAPS} + ${serial} ${model}= Get Device Info + Log Connected: serial=${serial} model=${model} + +Open Chrome On Device + [Documentation] Launch Chrome and open a blank page. + Launch Browser + +Open Chrome With Touch On Device + [Documentation] Launch Chrome with touch enabled (required for touchscreen.tap). + Launch Browser has_touch=True + +Teardown Session + [Arguments] ${status}=${TEST STATUS} ${msg}=${TEST MESSAGE} + [Documentation] Mark session status, screenshot, then disconnect. + Run Keyword If '${status}' == 'PASS' + ... Mark Session As Passed ${msg} + ... ELSE Mark Session As Failed ${msg} + Take Page Screenshot output/${TEST NAME}.png + Disconnect Device diff --git a/resources/variables.robot b/resources/variables.robot new file mode 100644 index 0000000..b4453ab --- /dev/null +++ b/resources/variables.robot @@ -0,0 +1,16 @@ +*** Variables *** +# BrowserStack credentials — override via CLI: -v BS_USERNAME:myuser +${BS_USERNAME} %{BROWSERSTACK_USERNAME} +${BS_ACCESS_KEY} %{BROWSERSTACK_ACCESS_KEY} + +# Device capabilities +&{BS_CAPS} +... deviceName=Samsung Galaxy S23 +... osVersion=13.0 +... browserName=chrome +... realMobile=true +... name=playwright-robot-android-sample +... build=playwright-robot-android + +# URLs +${BASE_URL} https://the-internet.herokuapp.com/ diff --git a/tests/sample_test.robot b/tests/sample_test.robot new file mode 100644 index 0000000..5c14e9f --- /dev/null +++ b/tests/sample_test.robot @@ -0,0 +1,54 @@ +*** Settings *** +Documentation Sample Playwright Android test on BrowserStack using Robot Framework. +... Mirrors browserstack_sample_test.py — tests The Internet demo site. +Library ../libraries/PlaywrightAndroidLibrary.py +Resource ../resources/android_keywords.robot +Suite Setup Connect To BrowserStack Device +Suite Teardown Disconnect Device +Test Teardown Run Keyword If Test Failed Mark Session As Failed ${TEST NAME} failed + + +*** Test Cases *** + +Verify Page Title + [Documentation] Navigate to The Internet and assert the page title. + Open Chrome On Device + Set Session Name playwright-robot-android-sample + Navigate To ${BASE_URL} + Wait For Timeout 3000 + Page Title Should Be The Internet + +Verify Checkbox Interactions + [Documentation] Check and uncheck checkboxes on the Checkboxes page. + Navigate To ${BASE_URL} + Click Link Checkboxes + + # Checkbox 1 starts unchecked — check it + Checkbox Should Be Unchecked 0 + Check Checkbox 0 + Checkbox Should Be Checked 0 + + # Checkbox 2 starts checked — uncheck it + Checkbox Should Be Checked 1 + Uncheck Checkbox 1 + Checkbox Should Be Unchecked 1 + +Verify Dropdown Selection + [Documentation] Select Option 1 from the dropdown. + Navigate To ${BASE_URL} + Click Link Dropdown + Select Option By Label \#dropdown Option 1 + +Verify Go Back Navigation + [Documentation] Go back from Dropdown page and verify the heading. + Navigate To ${BASE_URL} + Click Link Dropdown + Go Back + Heading Should Contain Available Examples Available Examples + +Take Screenshot And Mark Passed + [Documentation] Take a screenshot and mark the session as passed. + Navigate To ${BASE_URL} + Take Page Screenshot output/final_screenshot.png + Mark Session As Passed All tests completed successfully + Close Browser