aboutsummaryrefslogtreecommitdiff
path: root/playwright-py
diff options
context:
space:
mode:
Diffstat (limited to 'playwright-py')
-rw-r--r--playwright-py/LICENSE.txt202
-rw-r--r--playwright-py/SKILL.md175
-rw-r--r--playwright-py/examples/broken_links.py58
-rw-r--r--playwright-py/examples/console_logging.py35
-rw-r--r--playwright-py/examples/element_discovery.py40
-rw-r--r--playwright-py/examples/login_flow.py55
-rw-r--r--playwright-py/examples/responsive_sweep.py51
-rw-r--r--playwright-py/examples/static_html_automation.py33
-rw-r--r--playwright-py/scripts/detect_dev_servers.py71
-rw-r--r--playwright-py/scripts/safe_actions.py100
-rwxr-xr-xplaywright-py/scripts/with_server.py106
11 files changed, 926 insertions, 0 deletions
diff --git a/playwright-py/LICENSE.txt b/playwright-py/LICENSE.txt
new file mode 100644
index 0000000..7a4a3ea
--- /dev/null
+++ b/playwright-py/LICENSE.txt
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License. \ No newline at end of file
diff --git a/playwright-py/SKILL.md b/playwright-py/SKILL.md
new file mode 100644
index 0000000..1dee60e
--- /dev/null
+++ b/playwright-py/SKILL.md
@@ -0,0 +1,175 @@
+---
+name: playwright-py
+description: Browser automation and UI testing with Playwright using the Python (sync_api) bindings. Native Python scripts using `playwright.sync_api`, server lifecycle management via `with_server.py` (can manage backend + frontend simultaneously), headless Chromium by default, reconnaissance-then-action methodology for dynamic pages. Ships bundled helpers (dev server probe, safe click/type with retries, cookie banner handler, env-driven header injection) and worked examples (login flow, broken-link scan, responsive viewport sweep). Use when testing a web app with a Python stack (Django, FastAPI, Flask), when wiring browser tests into pytest, or when backend and frontend need to be launched together. See also `/playwright-js` for JavaScript/TypeScript variant (React, Next.js, Vue frontends).
+license: Complete terms in LICENSE.txt
+---
+
+# Web Application Testing
+
+To test local web applications, write native Python Playwright scripts.
+
+**Helper Scripts Available**:
+- `scripts/with_server.py` - Manages server lifecycle (supports multiple servers)
+
+**Always run scripts with `--help` first** to see usage. DO NOT read the source until you try running the script first and find that a customized solution is abslutely necessary. These scripts can be very large and thus pollute your context window. They exist to be called directly as black-box scripts rather than ingested into your context window.
+
+## Decision Tree: Choosing Your Approach
+
+```
+User task → Is it static HTML?
+ ├─ Yes → Read HTML file directly to identify selectors
+ │ ├─ Success → Write Playwright script using selectors
+ │ └─ Fails/Incomplete → Treat as dynamic (below)
+ │
+ └─ No (dynamic webapp) → Is the server already running?
+ ├─ No → Run: python scripts/with_server.py --help
+ │ Then use the helper + write simplified Playwright script
+ │
+ └─ Yes → Reconnaissance-then-action:
+ 1. Navigate and wait for networkidle
+ 2. Take screenshot or inspect DOM
+ 3. Identify selectors from rendered state
+ 4. Execute actions with discovered selectors
+```
+
+## Example: Using with_server.py
+
+To start a server, run `--help` first, then use the helper:
+
+**Single server:**
+```bash
+python scripts/with_server.py --server "npm run dev" --port 5173 -- python your_automation.py
+```
+
+**Multiple servers (e.g., backend + frontend):**
+```bash
+python scripts/with_server.py \
+ --server "cd backend && python server.py" --port 3000 \
+ --server "cd frontend && npm run dev" --port 5173 \
+ -- python your_automation.py
+```
+
+To create an automation script, include only Playwright logic (servers are managed automatically):
+```python
+from playwright.sync_api import sync_playwright
+
+with sync_playwright() as p:
+ browser = p.chromium.launch(headless=True) # Always launch chromium in headless mode
+ page = browser.new_page()
+ page.goto('http://localhost:5173') # Server already running and ready
+ page.wait_for_load_state('networkidle') # CRITICAL: Wait for JS to execute
+ # ... your automation logic
+ browser.close()
+```
+
+## Reconnaissance-Then-Action Pattern
+
+1. **Inspect rendered DOM**:
+ ```python
+ page.screenshot(path='/tmp/inspect.png', full_page=True)
+ content = page.content()
+ page.locator('button').all()
+ ```
+
+2. **Identify selectors** from inspection results
+
+3. **Execute actions** using discovered selectors
+
+## Common Pitfall
+
+❌ **Don't** inspect the DOM before waiting for `networkidle` on dynamic apps
+✅ **Do** wait for `page.wait_for_load_state('networkidle')` before inspection
+
+## Best Practices
+
+- **Use bundled scripts as black boxes** - To accomplish a task, consider whether one of the scripts available in `scripts/` can help. These scripts handle common, complex workflows reliably without cluttering the context window. Use `--help` to see usage, then invoke directly.
+- Use `sync_playwright()` for synchronous scripts
+- Always close the browser when done
+- Use descriptive selectors: `text=`, `role=`, CSS selectors, or IDs
+- Add appropriate waits: `page.wait_for_selector()` or `page.wait_for_timeout()`
+
+## Reference Files
+
+- **examples/** - Examples showing common patterns:
+ - `element_discovery.py` - Discovering buttons, links, and inputs on a page
+ - `static_html_automation.py` - Using file:// URLs for local HTML
+ - `console_logging.py` - Capturing console logs during automation
+ - `login_flow.py` - Worked login example (added in this fork)
+ - `broken_links.py` - Scan visible external links for broken URLs (added in this fork)
+ - `responsive_sweep.py` - Screenshot multiple viewports for responsive QA (added in this fork)
+
+---
+
+## Added: Dev Server Detection
+
+Before testing, see what's running on localhost. Run the bundled helper:
+
+```bash
+python scripts/detect_dev_servers.py
+```
+
+Outputs JSON: `[{"port": 5173, "url": "http://localhost:5173", "server": "vite"}, ...]`. Use this to discover the target URL rather than hardcoding it. If nothing is found, either start the server manually or use `scripts/with_server.py`.
+
+## Added: Retry Helpers
+
+Dynamic pages sometimes fail a click or fill on the first try. `scripts/safe_actions.py` provides retry-wrapped wrappers and a cookie-banner handler:
+
+```python
+from scripts.safe_actions import safe_click, safe_type, handle_cookie_banner
+
+page.goto(TARGET_URL)
+page.wait_for_load_state('networkidle')
+handle_cookie_banner(page) # clicks common accept buttons if present
+safe_type(page, 'input[name="email"]', 'test@example.com')
+safe_click(page, 'button[type="submit"]')
+```
+
+Each helper retries up to 3 times with a short delay and re-raises the last error if all attempts fail.
+
+## Added: Env-Driven Header Injection
+
+For authenticated testing without hardcoding tokens. Set env vars:
+
+```bash
+export PW_HEADER_NAME="Authorization"
+export PW_HEADER_VALUE="Bearer eyJhbGciOi…"
+# or multiple:
+export PW_EXTRA_HEADERS='{"X-API-Key": "…", "X-Tenant": "acme"}'
+```
+
+Then in your script:
+
+```python
+from scripts.safe_actions import build_context_with_headers
+
+with sync_playwright() as p:
+ browser = p.chromium.launch(headless=True)
+ context = build_context_with_headers(browser) # auto-applies env vars
+ page = context.new_page()
+ ...
+```
+
+Falls back to no extra headers when env vars are unset.
+
+## Added: Script Discipline
+
+Write ad-hoc Playwright automation scripts to `/tmp/pw-<topic>-<date>.py`, not into the project directory. Reasons:
+
+- OS reaps `/tmp` periodically; no stale test files to clean up
+- Scripts don't clutter git status
+- Keeps the project tree focused on code and not on investigation artifacts
+
+For reusable tests that belong to the project (pytest suites, CI scripts), commit them under `tests/` as usual. One-off investigation scripts go in `/tmp`.
+
+---
+
+## Attribution
+
+Forked from [anthropics/skills/skills/webapp-testing](https://github.com/anthropics/skills/tree/main/skills/webapp-testing) — Apache 2.0 licensed. See `LICENSE.txt` in this directory for the original copyright and terms.
+
+**Local additions** (not upstream):
+- `scripts/detect_dev_servers.py`, `scripts/safe_actions.py` — new helpers inspired by the sibling `playwright-js` skill (lackeyjb MIT) which bundles equivalent helpers in JavaScript.
+- `examples/login_flow.py`, `examples/broken_links.py`, `examples/responsive_sweep.py` — worked examples.
+- The five *Added:* sections above (Dev Server Detection, Retry Helpers, Env-Driven Header Injection, Script Discipline, and updated Reference Files list).
+
+The upstream skill is self-contained and headless-by-default; the additions here pair the Python side with the same conveniences Craig's `playwright-js` fork has on the JavaScript side, without changing upstream semantics. \ No newline at end of file
diff --git a/playwright-py/examples/broken_links.py b/playwright-py/examples/broken_links.py
new file mode 100644
index 0000000..c78520f
--- /dev/null
+++ b/playwright-py/examples/broken_links.py
@@ -0,0 +1,58 @@
+"""Worked example: scan visible external links on a page for broken URLs.
+
+Env vars used:
+ TARGET_URL (default: http://localhost:5173)
+
+Run:
+ python examples/broken_links.py
+"""
+
+import os
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from playwright.sync_api import sync_playwright
+from scripts.safe_actions import build_context_with_headers
+
+TARGET_URL = os.environ.get("TARGET_URL", "http://localhost:5173")
+
+
+def main() -> int:
+ with sync_playwright() as p:
+ browser = p.chromium.launch(headless=True)
+ context = build_context_with_headers(browser)
+ page = context.new_page()
+
+ page.goto(TARGET_URL)
+ page.wait_for_load_state("networkidle")
+
+ # Collect unique external hrefs
+ links = page.locator('a[href^="http"]').all()
+ urls = sorted(
+ {link.get_attribute("href") for link in links if link.get_attribute("href")}
+ )
+
+ ok, bad, err = 0, 0, 0
+ for url in urls:
+ try:
+ resp = page.request.head(url, timeout=5000)
+ status = resp.status
+ if status < 400:
+ ok += 1
+ print(f"✓ {status} {url}")
+ else:
+ bad += 1
+ print(f"✗ {status} {url}")
+ except Exception as ex:
+ err += 1
+ print(f"✗ ERR {url} ({type(ex).__name__}: {ex})")
+
+ print(f"\n{ok} ok, {bad} broken, {err} errored out of {len(urls)} total")
+ browser.close()
+ return 0 if (bad == 0 and err == 0) else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/playwright-py/examples/console_logging.py b/playwright-py/examples/console_logging.py
new file mode 100644
index 0000000..9329b5e
--- /dev/null
+++ b/playwright-py/examples/console_logging.py
@@ -0,0 +1,35 @@
+from playwright.sync_api import sync_playwright
+
+# Example: Capturing console logs during browser automation
+
+url = 'http://localhost:5173' # Replace with your URL
+
+console_logs = []
+
+with sync_playwright() as p:
+ browser = p.chromium.launch(headless=True)
+ page = browser.new_page(viewport={'width': 1920, 'height': 1080})
+
+ # Set up console log capture
+ def handle_console_message(msg):
+ console_logs.append(f"[{msg.type}] {msg.text}")
+ print(f"Console: [{msg.type}] {msg.text}")
+
+ page.on("console", handle_console_message)
+
+ # Navigate to page
+ page.goto(url)
+ page.wait_for_load_state('networkidle')
+
+ # Interact with the page (triggers console logs)
+ page.click('text=Dashboard')
+ page.wait_for_timeout(1000)
+
+ browser.close()
+
+# Save console logs to file
+with open('/mnt/user-data/outputs/console.log', 'w') as f:
+ f.write('\n'.join(console_logs))
+
+print(f"\nCaptured {len(console_logs)} console messages")
+print(f"Logs saved to: /mnt/user-data/outputs/console.log") \ No newline at end of file
diff --git a/playwright-py/examples/element_discovery.py b/playwright-py/examples/element_discovery.py
new file mode 100644
index 0000000..917ba72
--- /dev/null
+++ b/playwright-py/examples/element_discovery.py
@@ -0,0 +1,40 @@
+from playwright.sync_api import sync_playwright
+
+# Example: Discovering buttons and other elements on a page
+
+with sync_playwright() as p:
+ browser = p.chromium.launch(headless=True)
+ page = browser.new_page()
+
+ # Navigate to page and wait for it to fully load
+ page.goto('http://localhost:5173')
+ page.wait_for_load_state('networkidle')
+
+ # Discover all buttons on the page
+ buttons = page.locator('button').all()
+ print(f"Found {len(buttons)} buttons:")
+ for i, button in enumerate(buttons):
+ text = button.inner_text() if button.is_visible() else "[hidden]"
+ print(f" [{i}] {text}")
+
+ # Discover links
+ links = page.locator('a[href]').all()
+ print(f"\nFound {len(links)} links:")
+ for link in links[:5]: # Show first 5
+ text = link.inner_text().strip()
+ href = link.get_attribute('href')
+ print(f" - {text} -> {href}")
+
+ # Discover input fields
+ inputs = page.locator('input, textarea, select').all()
+ print(f"\nFound {len(inputs)} input fields:")
+ for input_elem in inputs:
+ name = input_elem.get_attribute('name') or input_elem.get_attribute('id') or "[unnamed]"
+ input_type = input_elem.get_attribute('type') or 'text'
+ print(f" - {name} ({input_type})")
+
+ # Take screenshot for visual reference
+ page.screenshot(path='/tmp/page_discovery.png', full_page=True)
+ print("\nScreenshot saved to /tmp/page_discovery.png")
+
+ browser.close() \ No newline at end of file
diff --git a/playwright-py/examples/login_flow.py b/playwright-py/examples/login_flow.py
new file mode 100644
index 0000000..d114ac6
--- /dev/null
+++ b/playwright-py/examples/login_flow.py
@@ -0,0 +1,55 @@
+"""Worked example: log in and verify redirect.
+
+Env vars used:
+ TARGET_URL (default: http://localhost:5173)
+ TEST_USER (default: test@example.com)
+ TEST_PASS (default: password123)
+
+Run from within the skill directory (so `scripts.safe_actions` resolves):
+ python examples/login_flow.py
+"""
+
+import os
+import sys
+from pathlib import Path
+
+# Make sibling scripts/ importable
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from playwright.sync_api import sync_playwright
+from scripts.safe_actions import (
+ handle_cookie_banner,
+ safe_click,
+ safe_type,
+ build_context_with_headers,
+)
+
+TARGET_URL = os.environ.get("TARGET_URL", "http://localhost:5173")
+TEST_USER = os.environ.get("TEST_USER", "test@example.com")
+TEST_PASS = os.environ.get("TEST_PASS", "password123")
+
+
+def main() -> int:
+ with sync_playwright() as p:
+ browser = p.chromium.launch(headless=True)
+ context = build_context_with_headers(browser)
+ page = context.new_page()
+
+ page.goto(f"{TARGET_URL}/login")
+ page.wait_for_load_state("networkidle")
+
+ handle_cookie_banner(page)
+
+ safe_type(page, 'input[name="username"], input[name="email"]', TEST_USER)
+ safe_type(page, 'input[name="password"]', TEST_PASS)
+ safe_click(page, 'button[type="submit"]')
+
+ page.wait_for_url("**/dashboard", timeout=5000)
+ print(f"✓ Logged in; redirected to {page.url}")
+
+ browser.close()
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/playwright-py/examples/responsive_sweep.py b/playwright-py/examples/responsive_sweep.py
new file mode 100644
index 0000000..d890d5b
--- /dev/null
+++ b/playwright-py/examples/responsive_sweep.py
@@ -0,0 +1,51 @@
+"""Worked example: screenshot each viewport for responsive QA.
+
+Env vars used:
+ TARGET_URL (default: http://localhost:5173)
+ OUTPUT_DIR (default: /tmp)
+
+Run:
+ python examples/responsive_sweep.py
+"""
+
+import os
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from playwright.sync_api import sync_playwright
+from scripts.safe_actions import build_context_with_headers
+
+TARGET_URL = os.environ.get("TARGET_URL", "http://localhost:5173")
+OUTPUT_DIR = Path(os.environ.get("OUTPUT_DIR", "/tmp"))
+
+VIEWPORTS = [
+ ("desktop", 1920, 1080),
+ ("laptop", 1366, 768),
+ ("tablet", 768, 1024),
+ ("mobile", 375, 667),
+]
+
+
+def main() -> int:
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
+ with sync_playwright() as p:
+ browser = p.chromium.launch(headless=True)
+ for name, width, height in VIEWPORTS:
+ context = build_context_with_headers(
+ browser, extra_kwargs={"viewport": {"width": width, "height": height}}
+ )
+ page = context.new_page()
+ page.goto(TARGET_URL)
+ page.wait_for_load_state("networkidle")
+ path = OUTPUT_DIR / f"responsive-{name}.png"
+ page.screenshot(path=str(path), full_page=True)
+ print(f"✓ {name:<8} ({width:>4}x{height:<4}) → {path}")
+ context.close()
+ browser.close()
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/playwright-py/examples/static_html_automation.py b/playwright-py/examples/static_html_automation.py
new file mode 100644
index 0000000..90bbedc
--- /dev/null
+++ b/playwright-py/examples/static_html_automation.py
@@ -0,0 +1,33 @@
+from playwright.sync_api import sync_playwright
+import os
+
+# Example: Automating interaction with static HTML files using file:// URLs
+
+html_file_path = os.path.abspath('path/to/your/file.html')
+file_url = f'file://{html_file_path}'
+
+with sync_playwright() as p:
+ browser = p.chromium.launch(headless=True)
+ page = browser.new_page(viewport={'width': 1920, 'height': 1080})
+
+ # Navigate to local HTML file
+ page.goto(file_url)
+
+ # Take screenshot
+ page.screenshot(path='/mnt/user-data/outputs/static_page.png', full_page=True)
+
+ # Interact with elements
+ page.click('text=Click Me')
+ page.fill('#name', 'John Doe')
+ page.fill('#email', 'john@example.com')
+
+ # Submit form
+ page.click('button[type="submit"]')
+ page.wait_for_timeout(500)
+
+ # Take final screenshot
+ page.screenshot(path='/mnt/user-data/outputs/after_submit.png', full_page=True)
+
+ browser.close()
+
+print("Static HTML automation completed!") \ No newline at end of file
diff --git a/playwright-py/scripts/detect_dev_servers.py b/playwright-py/scripts/detect_dev_servers.py
new file mode 100644
index 0000000..fb8b1ea
--- /dev/null
+++ b/playwright-py/scripts/detect_dev_servers.py
@@ -0,0 +1,71 @@
+#!/usr/bin/env python3
+"""Probe common localhost ports for running HTTP dev servers.
+
+Outputs JSON: [{"port": N, "url": "http://localhost:N", "server": "<hint>"}, ...]
+
+Usage:
+ python detect_dev_servers.py
+ python detect_dev_servers.py --ports 3000,5173,8000
+ python detect_dev_servers.py --host localhost --ports 8080
+"""
+
+import argparse
+import json
+import socket
+import sys
+import urllib.request
+
+DEFAULT_PORTS = [3000, 3001, 4200, 5000, 5173, 5500, 8000, 8080, 8888, 9000]
+
+
+def is_port_open(host: str, port: int, timeout: float = 0.3) -> bool:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ s.settimeout(timeout)
+ try:
+ s.connect((host, port))
+ return True
+ except (socket.timeout, ConnectionRefusedError, OSError):
+ return False
+
+
+def probe_http(url: str, timeout: float = 0.5) -> str | None:
+ """Return a short hint about the server (e.g. its Server header), or None."""
+ try:
+ req = urllib.request.Request(url, headers={"User-Agent": "detect_dev_servers"})
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
+ server = resp.headers.get("Server", "")
+ return server or "http"
+ except Exception:
+ return None
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--ports",
+ default=",".join(str(p) for p in DEFAULT_PORTS),
+ help="Comma-separated port list",
+ )
+ parser.add_argument("--host", default="localhost")
+ args = parser.parse_args()
+
+ try:
+ ports = [int(p.strip()) for p in args.ports.split(",") if p.strip()]
+ except ValueError as err:
+ print(f"ERROR: bad --ports value: {err}", file=sys.stderr)
+ return 2
+
+ results = []
+ for port in ports:
+ if is_port_open(args.host, port):
+ url = f"http://{args.host}:{port}"
+ hint = probe_http(url)
+ if hint is not None:
+ results.append({"port": port, "url": url, "server": hint})
+
+ print(json.dumps(results, indent=2))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/playwright-py/scripts/safe_actions.py b/playwright-py/scripts/safe_actions.py
new file mode 100644
index 0000000..c3f72bf
--- /dev/null
+++ b/playwright-py/scripts/safe_actions.py
@@ -0,0 +1,100 @@
+"""Retry-wrapped Playwright action helpers + common convenience utilities.
+
+Usage:
+ from scripts.safe_actions import (
+ safe_click, safe_type, handle_cookie_banner, build_context_with_headers
+ )
+"""
+
+import json
+import os
+import time
+
+
+def safe_click(page, selector, retries: int = 3, delay: float = 0.5, timeout: int = 5000):
+ """Click SELECTOR. Retry up to RETRIES times with DELAY seconds between.
+
+ Raises the last exception if all attempts fail.
+ """
+ last_err = None
+ for attempt in range(retries):
+ try:
+ page.wait_for_selector(selector, timeout=timeout)
+ page.click(selector)
+ return
+ except Exception as err:
+ last_err = err
+ if attempt < retries - 1:
+ time.sleep(delay)
+ raise last_err # type: ignore[misc]
+
+
+def safe_type(page, selector, value: str, retries: int = 3, delay: float = 0.5, timeout: int = 5000):
+ """Fill SELECTOR with VALUE. Retry on failure."""
+ last_err = None
+ for attempt in range(retries):
+ try:
+ page.wait_for_selector(selector, timeout=timeout)
+ page.fill(selector, value)
+ return
+ except Exception as err:
+ last_err = err
+ if attempt < retries - 1:
+ time.sleep(delay)
+ raise last_err # type: ignore[misc]
+
+
+def handle_cookie_banner(page, selectors=None) -> bool:
+ """Try common cookie-banner accept selectors; click the first that exists.
+
+ Returns True if a banner was found and clicked, False otherwise.
+ Does not raise on failure — many pages have no banner.
+ """
+ selectors = selectors or [
+ "#onetrust-accept-btn-handler",
+ 'button[aria-label*="ccept" i]',
+ 'button:has-text("Accept")',
+ 'button:has-text("I agree")',
+ 'button:has-text("Got it")',
+ '[data-testid="uc-accept-all-button"]',
+ "#cookie-accept",
+ ".cookie-accept",
+ ]
+ for selector in selectors:
+ try:
+ if page.locator(selector).count() > 0:
+ page.click(selector, timeout=1000)
+ return True
+ except Exception:
+ continue
+ return False
+
+
+def build_context_with_headers(browser, extra_kwargs=None):
+ """Create a browser context with extra HTTP headers from env vars.
+
+ Reads:
+ PW_HEADER_NAME / PW_HEADER_VALUE — single header
+ PW_EXTRA_HEADERS='{"X-A":"1","X-B":"2"}' — JSON object of headers
+
+ Unset env vars → plain context with no extra headers.
+ extra_kwargs, if supplied, are passed to browser.new_context().
+ """
+ headers: dict[str, str] = {}
+ name = os.environ.get("PW_HEADER_NAME")
+ value = os.environ.get("PW_HEADER_VALUE")
+ if name and value:
+ headers[name] = value
+ extra = os.environ.get("PW_EXTRA_HEADERS")
+ if extra:
+ try:
+ parsed = json.loads(extra)
+ if isinstance(parsed, dict):
+ headers.update({str(k): str(v) for k, v in parsed.items()})
+ except json.JSONDecodeError:
+ pass
+
+ kwargs: dict = dict(extra_kwargs or {})
+ if headers:
+ kwargs["extra_http_headers"] = headers
+ return browser.new_context(**kwargs)
diff --git a/playwright-py/scripts/with_server.py b/playwright-py/scripts/with_server.py
new file mode 100755
index 0000000..431f2eb
--- /dev/null
+++ b/playwright-py/scripts/with_server.py
@@ -0,0 +1,106 @@
+#!/usr/bin/env python3
+"""
+Start one or more servers, wait for them to be ready, run a command, then clean up.
+
+Usage:
+ # Single server
+ python scripts/with_server.py --server "npm run dev" --port 5173 -- python automation.py
+ python scripts/with_server.py --server "npm start" --port 3000 -- python test.py
+
+ # Multiple servers
+ python scripts/with_server.py \
+ --server "cd backend && python server.py" --port 3000 \
+ --server "cd frontend && npm run dev" --port 5173 \
+ -- python test.py
+"""
+
+import subprocess
+import socket
+import time
+import sys
+import argparse
+
+def is_server_ready(port, timeout=30):
+ """Wait for server to be ready by polling the port."""
+ start_time = time.time()
+ while time.time() - start_time < timeout:
+ try:
+ with socket.create_connection(('localhost', port), timeout=1):
+ return True
+ except (socket.error, ConnectionRefusedError):
+ time.sleep(0.5)
+ return False
+
+
+def main():
+ parser = argparse.ArgumentParser(description='Run command with one or more servers')
+ parser.add_argument('--server', action='append', dest='servers', required=True, help='Server command (can be repeated)')
+ parser.add_argument('--port', action='append', dest='ports', type=int, required=True, help='Port for each server (must match --server count)')
+ parser.add_argument('--timeout', type=int, default=30, help='Timeout in seconds per server (default: 30)')
+ parser.add_argument('command', nargs=argparse.REMAINDER, help='Command to run after server(s) ready')
+
+ args = parser.parse_args()
+
+ # Remove the '--' separator if present
+ if args.command and args.command[0] == '--':
+ args.command = args.command[1:]
+
+ if not args.command:
+ print("Error: No command specified to run")
+ sys.exit(1)
+
+ # Parse server configurations
+ if len(args.servers) != len(args.ports):
+ print("Error: Number of --server and --port arguments must match")
+ sys.exit(1)
+
+ servers = []
+ for cmd, port in zip(args.servers, args.ports):
+ servers.append({'cmd': cmd, 'port': port})
+
+ server_processes = []
+
+ try:
+ # Start all servers
+ for i, server in enumerate(servers):
+ print(f"Starting server {i+1}/{len(servers)}: {server['cmd']}")
+
+ # Use shell=True to support commands with cd and &&
+ process = subprocess.Popen(
+ server['cmd'],
+ shell=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE
+ )
+ server_processes.append(process)
+
+ # Wait for this server to be ready
+ print(f"Waiting for server on port {server['port']}...")
+ if not is_server_ready(server['port'], timeout=args.timeout):
+ raise RuntimeError(f"Server failed to start on port {server['port']} within {args.timeout}s")
+
+ print(f"Server ready on port {server['port']}")
+
+ print(f"\nAll {len(servers)} server(s) ready")
+
+ # Run the command
+ print(f"Running: {' '.join(args.command)}\n")
+ result = subprocess.run(args.command)
+ sys.exit(result.returncode)
+
+ finally:
+ # Clean up all servers
+ print(f"\nStopping {len(server_processes)} server(s)...")
+ for i, process in enumerate(server_processes):
+ try:
+ process.terminate()
+ process.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ process.kill()
+ process.wait()
+ print(f"Server {i+1} stopped")
+ print("All servers stopped")
+
+
+if __name__ == '__main__':
+ main() \ No newline at end of file