aboutsummaryrefslogtreecommitdiff
path: root/playwright-skill/lib/helpers.js
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-04-19 15:16:46 -0500
committerCraig Jennings <c@cjennings.net>2026-04-19 15:16:46 -0500
commit11f5f003eef12bff9633ca8190e3c43c7dab6708 (patch)
treec74cb8c5cbb189a9b5aa8154ae4c898e9992b771 /playwright-skill/lib/helpers.js
parentb3247d0b1aaf73cae6068e42e3df26b256d9008e (diff)
downloadrulesets-11f5f003eef12bff9633ca8190e3c43c7dab6708.tar.gz
rulesets-11f5f003eef12bff9633ca8190e3c43c7dab6708.zip
feat: adopt lackeyjb/playwright-skill (MIT verbatim fork) + deps target
Browser automation + UI testing skill forked verbatim from github.com/lackeyjb/playwright-skill (MIT, 2458 stars, active through Dec 2025). LICENSE preserved in skill dir with attribution footer added to SKILL.md. Bundle contents (from upstream): playwright-skill/SKILL.md playwright-skill/API_REFERENCE.md playwright-skill/run.js (universal executor with module resolution) playwright-skill/package.json playwright-skill/lib/helpers.js (detectDevServers, safeClick, safeType, takeScreenshot, handleCookieBanner, extractTableData, createContext with env-driven header injection) playwright-skill/LICENSE (MIT, lackeyjb) Makefile updates: - SKILLS extended with playwright-skill; make install symlinks it globally into ~/.claude/skills/ - deps target extended to check node + npm, and to run the skill's own `npm run setup` (installs Playwright + Chromium ~300 MB on first run). Idempotent: skipped if node_modules/playwright already exists. Stack fit: JavaScript Playwright aligns with Craig's TypeScript/React frontend work. Python-side (Django) browser tests would be better served by Anthropic's official webapp-testing skill (Python Playwright bindings), noted in the evaluation memory but not adopted here — minimal overlap, easy to add later if the need arises.
Diffstat (limited to 'playwright-skill/lib/helpers.js')
-rw-r--r--playwright-skill/lib/helpers.js441
1 files changed, 441 insertions, 0 deletions
diff --git a/playwright-skill/lib/helpers.js b/playwright-skill/lib/helpers.js
new file mode 100644
index 0000000..0920d68
--- /dev/null
+++ b/playwright-skill/lib/helpers.js
@@ -0,0 +1,441 @@
+// 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
+};