aboutsummaryrefslogtreecommitdiff
path: root/playwright-skill/lib
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-04-19 15:24:51 -0500
committerCraig Jennings <c@cjennings.net>2026-04-19 15:24:51 -0500
commit4ffa7417a359ef4eae09f61d7da4de06539462ca (patch)
treeb8eeb8aa5ec2344216c0f0cdcdcc82d0df307ce3 /playwright-skill/lib
parent11f5f003eef12bff9633ca8190e3c43c7dab6708 (diff)
downloadrulesets-4ffa7417a359ef4eae09f61d7da4de06539462ca.tar.gz
rulesets-4ffa7417a359ef4eae09f61d7da4de06539462ca.zip
refactor(playwright): split into playwright-js + playwright-py variants
Rename `playwright-skill/` → `playwright-js/` and add `playwright-py/` as a verbatim fork of Anthropic's official `webapp-testing` skill (Apache-2.0). Cross-pollinate: each skill gains patterns and helpers inspired by the other's strengths, with upstream semantics preserved. ## playwright-js (JS/TS stack) Renamed from playwright-skill; upstream lackeyjb MIT content untouched. New sections added (clearly marked, preserving upstream semantics): - Static HTML vs Dynamic Webapp decision tree (core Anthropic methodology) - Reconnaissance-Then-Action pattern (navigate → networkidle → inspect → act) - Console Log Capture snippet (page.on console/pageerror/requestfailed) Description updated to clarify JS/TS stack fit (React/Next/Vue/Svelte/Node) and reference `/playwright-py` as the Python sibling. ## playwright-py (Python stack) Verbatim fork of anthropics/skills/skills/webapp-testing; upstream SKILL.md and bundled `scripts/with_server.py` + examples kept intact. New scripts and examples added (all lackeyjb-style conveniences in Python): Scripts: scripts/detect_dev_servers.py Probe common localhost ports for HTTP servers; outputs JSON of found services. scripts/safe_actions.py safe_click, safe_type (retry-wrapped), handle_cookie_banner (common selectors), build_context_with_headers (env-var- driven: PW_HEADER_NAME / PW_HEADER_VALUE / PW_EXTRA_HEADERS='{…json…}'). Examples: examples/login_flow.py Login form + wait_for_url. examples/broken_links.py Scan visible external hrefs via HEAD. examples/responsive_sweep.py Multi-viewport screenshots to /tmp. SKILL.md gains 5 "Added:" sections documenting the new scripts, retry helpers, env-header injection, and /tmp script discipline. Attribution notes explicitly mark upstream vs local additions. ## Makefile SKILLS: playwright-skill → playwright-js + playwright-py deps target: extended Playwright step to install Python package + Chromium via `python3 -m pip install --user playwright && python3 -m playwright install chromium` when playwright-py/ is present. Idempotent (detected via `python3 -c "import playwright"`). ## Usage Both skills symlinked globally via `make install`. Invoke whichever matches the project stack — cross-references in descriptions route you to the right one. Run `make deps` once to install both runtimes.
Diffstat (limited to 'playwright-skill/lib')
-rw-r--r--playwright-skill/lib/helpers.js441
1 files changed, 0 insertions, 441 deletions
diff --git a/playwright-skill/lib/helpers.js b/playwright-skill/lib/helpers.js
deleted file mode 100644
index 0920d68..0000000
--- a/playwright-skill/lib/helpers.js
+++ /dev/null
@@ -1,441 +0,0 @@
-// playwright-helpers.js
-// Reusable utility functions for Playwright automation
-
-const { chromium, firefox, webkit } = require('playwright');
-
-/**
- * Parse extra HTTP headers from environment variables.
- * Supports two formats:
- * - PW_HEADER_NAME + PW_HEADER_VALUE: Single header (simple, common case)
- * - PW_EXTRA_HEADERS: JSON object for multiple headers (advanced)
- * Single header format takes precedence if both are set.
- * @returns {Object|null} Headers object or null if none configured
- */
-function getExtraHeadersFromEnv() {
- const headerName = process.env.PW_HEADER_NAME;
- const headerValue = process.env.PW_HEADER_VALUE;
-
- if (headerName && headerValue) {
- return { [headerName]: headerValue };
- }
-
- const headersJson = process.env.PW_EXTRA_HEADERS;
- if (headersJson) {
- try {
- const parsed = JSON.parse(headersJson);
- if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
- return parsed;
- }
- console.warn('PW_EXTRA_HEADERS must be a JSON object, ignoring...');
- } catch (e) {
- console.warn('Failed to parse PW_EXTRA_HEADERS as JSON:', e.message);
- }
- }
-
- return null;
-}
-
-/**
- * Launch browser with standard configuration
- * @param {string} browserType - 'chromium', 'firefox', or 'webkit'
- * @param {Object} options - Additional launch options
- */
-async function launchBrowser(browserType = 'chromium', options = {}) {
- const defaultOptions = {
- headless: process.env.HEADLESS !== 'false',
- slowMo: process.env.SLOW_MO ? parseInt(process.env.SLOW_MO) : 0,
- args: ['--no-sandbox', '--disable-setuid-sandbox']
- };
-
- const browsers = { chromium, firefox, webkit };
- const browser = browsers[browserType];
-
- if (!browser) {
- throw new Error(`Invalid browser type: ${browserType}`);
- }
-
- return await browser.launch({ ...defaultOptions, ...options });
-}
-
-/**
- * Create a new page with viewport and user agent
- * @param {Object} context - Browser context
- * @param {Object} options - Page options
- */
-async function createPage(context, options = {}) {
- const page = await context.newPage();
-
- if (options.viewport) {
- await page.setViewportSize(options.viewport);
- }
-
- if (options.userAgent) {
- await page.setExtraHTTPHeaders({
- 'User-Agent': options.userAgent
- });
- }
-
- // Set default timeout
- page.setDefaultTimeout(options.timeout || 30000);
-
- return page;
-}
-
-/**
- * Smart wait for page to be ready
- * @param {Object} page - Playwright page
- * @param {Object} options - Wait options
- */
-async function waitForPageReady(page, options = {}) {
- const waitOptions = {
- waitUntil: options.waitUntil || 'networkidle',
- timeout: options.timeout || 30000
- };
-
- try {
- await page.waitForLoadState(waitOptions.waitUntil, {
- timeout: waitOptions.timeout
- });
- } catch (e) {
- console.warn('Page load timeout, continuing...');
- }
-
- // Additional wait for dynamic content if selector provided
- if (options.waitForSelector) {
- await page.waitForSelector(options.waitForSelector, {
- timeout: options.timeout
- });
- }
-}
-
-/**
- * Safe click with retry logic
- * @param {Object} page - Playwright page
- * @param {string} selector - Element selector
- * @param {Object} options - Click options
- */
-async function safeClick(page, selector, options = {}) {
- const maxRetries = options.retries || 3;
- const retryDelay = options.retryDelay || 1000;
-
- for (let i = 0; i < maxRetries; i++) {
- try {
- await page.waitForSelector(selector, {
- state: 'visible',
- timeout: options.timeout || 5000
- });
- await page.click(selector, {
- force: options.force || false,
- timeout: options.timeout || 5000
- });
- return true;
- } catch (e) {
- if (i === maxRetries - 1) {
- console.error(`Failed to click ${selector} after ${maxRetries} attempts`);
- throw e;
- }
- console.log(`Retry ${i + 1}/${maxRetries} for clicking ${selector}`);
- await page.waitForTimeout(retryDelay);
- }
- }
-}
-
-/**
- * Safe text input with clear before type
- * @param {Object} page - Playwright page
- * @param {string} selector - Input selector
- * @param {string} text - Text to type
- * @param {Object} options - Type options
- */
-async function safeType(page, selector, text, options = {}) {
- await page.waitForSelector(selector, {
- state: 'visible',
- timeout: options.timeout || 10000
- });
-
- if (options.clear !== false) {
- await page.fill(selector, '');
- }
-
- if (options.slow) {
- await page.type(selector, text, { delay: options.delay || 100 });
- } else {
- await page.fill(selector, text);
- }
-}
-
-/**
- * Extract text from multiple elements
- * @param {Object} page - Playwright page
- * @param {string} selector - Elements selector
- */
-async function extractTexts(page, selector) {
- await page.waitForSelector(selector, { timeout: 10000 });
- return await page.$$eval(selector, elements =>
- elements.map(el => el.textContent?.trim()).filter(Boolean)
- );
-}
-
-/**
- * Take screenshot with timestamp
- * @param {Object} page - Playwright page
- * @param {string} name - Screenshot name
- * @param {Object} options - Screenshot options
- */
-async function takeScreenshot(page, name, options = {}) {
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
- const filename = `${name}-${timestamp}.png`;
-
- await page.screenshot({
- path: filename,
- fullPage: options.fullPage !== false,
- ...options
- });
-
- console.log(`Screenshot saved: ${filename}`);
- return filename;
-}
-
-/**
- * Handle authentication
- * @param {Object} page - Playwright page
- * @param {Object} credentials - Username and password
- * @param {Object} selectors - Login form selectors
- */
-async function authenticate(page, credentials, selectors = {}) {
- const defaultSelectors = {
- username: 'input[name="username"], input[name="email"], #username, #email',
- password: 'input[name="password"], #password',
- submit: 'button[type="submit"], input[type="submit"], button:has-text("Login"), button:has-text("Sign in")'
- };
-
- const finalSelectors = { ...defaultSelectors, ...selectors };
-
- await safeType(page, finalSelectors.username, credentials.username);
- await safeType(page, finalSelectors.password, credentials.password);
- await safeClick(page, finalSelectors.submit);
-
- // Wait for navigation or success indicator
- await Promise.race([
- page.waitForNavigation({ waitUntil: 'networkidle' }),
- page.waitForSelector(selectors.successIndicator || '.dashboard, .user-menu, .logout', { timeout: 10000 })
- ]).catch(() => {
- console.log('Login might have completed without navigation');
- });
-}
-
-/**
- * Scroll page
- * @param {Object} page - Playwright page
- * @param {string} direction - 'down', 'up', 'top', 'bottom'
- * @param {number} distance - Pixels to scroll (for up/down)
- */
-async function scrollPage(page, direction = 'down', distance = 500) {
- switch (direction) {
- case 'down':
- await page.evaluate(d => window.scrollBy(0, d), distance);
- break;
- case 'up':
- await page.evaluate(d => window.scrollBy(0, -d), distance);
- break;
- case 'top':
- await page.evaluate(() => window.scrollTo(0, 0));
- break;
- case 'bottom':
- await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
- break;
- }
- await page.waitForTimeout(500); // Wait for scroll animation
-}
-
-/**
- * Extract table data
- * @param {Object} page - Playwright page
- * @param {string} tableSelector - Table selector
- */
-async function extractTableData(page, tableSelector) {
- await page.waitForSelector(tableSelector);
-
- return await page.evaluate((selector) => {
- const table = document.querySelector(selector);
- if (!table) return null;
-
- const headers = Array.from(table.querySelectorAll('thead th')).map(th =>
- th.textContent?.trim()
- );
-
- const rows = Array.from(table.querySelectorAll('tbody tr')).map(tr => {
- const cells = Array.from(tr.querySelectorAll('td'));
- if (headers.length > 0) {
- return cells.reduce((obj, cell, index) => {
- obj[headers[index] || `column_${index}`] = cell.textContent?.trim();
- return obj;
- }, {});
- } else {
- return cells.map(cell => cell.textContent?.trim());
- }
- });
-
- return { headers, rows };
- }, tableSelector);
-}
-
-/**
- * Wait for and dismiss cookie banners
- * @param {Object} page - Playwright page
- * @param {number} timeout - Max time to wait
- */
-async function handleCookieBanner(page, timeout = 3000) {
- const commonSelectors = [
- 'button:has-text("Accept")',
- 'button:has-text("Accept all")',
- 'button:has-text("OK")',
- 'button:has-text("Got it")',
- 'button:has-text("I agree")',
- '.cookie-accept',
- '#cookie-accept',
- '[data-testid="cookie-accept"]'
- ];
-
- for (const selector of commonSelectors) {
- try {
- const element = await page.waitForSelector(selector, {
- timeout: timeout / commonSelectors.length,
- state: 'visible'
- });
- if (element) {
- await element.click();
- console.log('Cookie banner dismissed');
- return true;
- }
- } catch (e) {
- // Continue to next selector
- }
- }
-
- return false;
-}
-
-/**
- * Retry a function with exponential backoff
- * @param {Function} fn - Function to retry
- * @param {number} maxRetries - Maximum retry attempts
- * @param {number} initialDelay - Initial delay in ms
- */
-async function retryWithBackoff(fn, maxRetries = 3, initialDelay = 1000) {
- let lastError;
-
- for (let i = 0; i < maxRetries; i++) {
- try {
- return await fn();
- } catch (error) {
- lastError = error;
- const delay = initialDelay * Math.pow(2, i);
- console.log(`Attempt ${i + 1} failed, retrying in ${delay}ms...`);
- await new Promise(resolve => setTimeout(resolve, delay));
- }
- }
-
- throw lastError;
-}
-
-/**
- * Create browser context with common settings
- * @param {Object} browser - Browser instance
- * @param {Object} options - Context options
- */
-async function createContext(browser, options = {}) {
- const envHeaders = getExtraHeadersFromEnv();
-
- // Merge environment headers with any passed in options
- const mergedHeaders = {
- ...envHeaders,
- ...options.extraHTTPHeaders
- };
-
- const defaultOptions = {
- viewport: { width: 1280, height: 720 },
- userAgent: options.mobile
- ? 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Mobile/15E148 Safari/604.1'
- : undefined,
- permissions: options.permissions || [],
- geolocation: options.geolocation,
- locale: options.locale || 'en-US',
- timezoneId: options.timezoneId || 'America/New_York',
- // Only include extraHTTPHeaders if we have any
- ...(Object.keys(mergedHeaders).length > 0 && { extraHTTPHeaders: mergedHeaders })
- };
-
- return await browser.newContext({ ...defaultOptions, ...options });
-}
-
-/**
- * Detect running dev servers on common ports
- * @param {Array<number>} customPorts - Additional ports to check
- * @returns {Promise<Array>} Array of detected server URLs
- */
-async function detectDevServers(customPorts = []) {
- const http = require('http');
-
- // Common dev server ports
- const commonPorts = [3000, 3001, 3002, 5173, 8080, 8000, 4200, 5000, 9000, 1234];
- const allPorts = [...new Set([...commonPorts, ...customPorts])];
-
- const detectedServers = [];
-
- console.log('🔍 Checking for running dev servers...');
-
- for (const port of allPorts) {
- try {
- await new Promise((resolve, reject) => {
- const req = http.request({
- hostname: 'localhost',
- port: port,
- path: '/',
- method: 'HEAD',
- timeout: 500
- }, (res) => {
- if (res.statusCode < 500) {
- detectedServers.push(`http://localhost:${port}`);
- console.log(` ✅ Found server on port ${port}`);
- }
- resolve();
- });
-
- req.on('error', () => resolve());
- req.on('timeout', () => {
- req.destroy();
- resolve();
- });
-
- req.end();
- });
- } catch (e) {
- // Port not available, continue
- }
- }
-
- if (detectedServers.length === 0) {
- console.log(' ❌ No dev servers detected');
- }
-
- return detectedServers;
-}
-
-module.exports = {
- launchBrowser,
- createPage,
- waitForPageReady,
- safeClick,
- safeType,
- extractTexts,
- takeScreenshot,
- authenticate,
- scrollPage,
- extractTableData,
- handleCookieBanner,
- retryWithBackoff,
- createContext,
- detectDevServers,
- getExtraHeadersFromEnv
-};