aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--playwright-js/API_REFERENCE.md29
-rw-r--r--playwright-js/SKILL.md59
-rw-r--r--playwright-js/lib/helpers.js54
-rwxr-xr-xplaywright-js/run.js28
-rw-r--r--playwright-py/SKILL.md15
-rw-r--r--playwright-py/examples/broken_links.py6
-rw-r--r--playwright-py/examples/login_flow.py2
-rw-r--r--playwright-py/examples/responsive_sweep.py2
8 files changed, 119 insertions, 76 deletions
diff --git a/playwright-js/API_REFERENCE.md b/playwright-js/API_REFERENCE.md
index 9ee2975..7307cd2 100644
--- a/playwright-js/API_REFERENCE.md
+++ b/playwright-js/API_REFERENCE.md
@@ -95,11 +95,15 @@ const { chromium } = require('playwright');
const page = await context.newPage();
- // Navigate
+ // Navigate. 'load' (or 'domcontentloaded') is the default-safe wait; prefer
+ // asserting on a visible landmark afterward for readiness (see below).
await page.goto('https://example.com', {
- waitUntil: 'networkidle' // Wait for network to be idle
+ waitUntil: 'load'
});
+ // Wait for a real app landmark instead of network state:
+ // await expect(page.getByRole('main')).toBeVisible();
+
// Your automation here
await browser.close();
@@ -259,6 +263,18 @@ await page.keyboard.press('ArrowDown');
### Smart Waiting
+**Prefer web assertions and locator waits for readiness.** Web assertions
+(`expect(locator).toBeVisible()`, `toHaveText()`, etc.) and `locator.waitFor()`
+auto-wait for a user-visible, app-specific condition — which is what "the page is
+ready" actually means. Lead with these:
+
+```javascript
+// PREFERRED: assert on a visible landmark (auto-waits)
+await expect(page.getByRole('main')).toBeVisible();
+await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
+await page.getByText('Welcome back').waitFor();
+```
+
```javascript
// Wait for element states
await page.locator('button').waitFor({ state: 'visible' });
@@ -270,8 +286,11 @@ await page.locator('button').waitFor({ state: 'detached' });
await page.waitForURL('**/success');
await page.waitForURL(url => url.pathname === '/dashboard');
-// Wait for network
-await page.waitForLoadState('networkidle');
+// Load-state waits. 'load' / 'domcontentloaded' are fine for "page navigated".
+// AVOID 'networkidle' as a readiness signal — Playwright discourages it; a page
+// can be interactive long before the network quiets (or never quiet at all).
+// Wait on a visible landmark instead (see PREFERRED block above).
+await page.waitForLoadState('load');
await page.waitForLoadState('domcontentloaded');
// Wait for function
@@ -576,7 +595,7 @@ jobs:
1. **Test Organization** - Use descriptive test names, group related tests
2. **Selector Strategy** - Prefer data-testid attributes, use role-based selectors
-3. **Waiting** - Use Playwright's auto-waiting, avoid hard-coded delays
+3. **Waiting** - Lead with web assertions (`expect(locator).toBeVisible()`) and locator waits; they auto-wait for a real, visible condition. Avoid `networkidle` as a readiness signal (Playwright discourages it) and avoid hard-coded delays.
4. **Error Handling** - Add proper error messages, take screenshots on failure
5. **Performance** - Run tests in parallel, reuse authentication state
diff --git a/playwright-js/SKILL.md b/playwright-js/SKILL.md
index b4b037b..40427c3 100644
--- a/playwright-js/SKILL.md
+++ b/playwright-js/SKILL.md
@@ -82,7 +82,7 @@ const TARGET_URL = 'http://localhost:3001'; // <-- Auto-detected or from user
console.log('Page loaded:', await page.title());
await page.screenshot({ path: '/tmp/screenshot.png', fullPage: true });
- console.log('šŸ“ø Screenshot saved to /tmp/screenshot.png');
+ console.log('[screenshot] Saved to /tmp/screenshot.png');
await browser.close();
})();
@@ -127,6 +127,7 @@ const TARGET_URL = 'http://localhost:3001'; // Auto-detected
```javascript
// /tmp/playwright-test-login.js
const { chromium } = require('playwright');
+const { expect } = require('@playwright/test');
const TARGET_URL = 'http://localhost:3001'; // Auto-detected
@@ -136,13 +137,15 @@ const TARGET_URL = 'http://localhost:3001'; // Auto-detected
await page.goto(`${TARGET_URL}/login`);
- await page.fill('input[name="email"]', 'test@example.com');
- await page.fill('input[name="password"]', 'password123');
- await page.click('button[type="submit"]');
+ // Prefer user-visible locators; they auto-wait for the field to be ready.
+ await page.getByLabel('Email').fill('test@example.com');
+ await page.getByLabel('Password').fill('password123');
+ await page.getByRole('button', { name: /sign in|log in/i }).click();
- // Wait for redirect
+ // Wait for redirect, then assert on a landmark of the destination.
await page.waitForURL('**/dashboard');
- console.log('āœ… Login successful, redirected to dashboard');
+ await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
+ console.log('[ok] Login successful, redirected to dashboard');
await browser.close();
})();
@@ -153,6 +156,7 @@ const TARGET_URL = 'http://localhost:3001'; // Auto-detected
```javascript
// /tmp/playwright-test-form.js
const { chromium } = require('playwright');
+const { expect } = require('@playwright/test');
const TARGET_URL = 'http://localhost:3001'; // Auto-detected
@@ -162,14 +166,14 @@ const TARGET_URL = 'http://localhost:3001'; // Auto-detected
await page.goto(`${TARGET_URL}/contact`);
- await page.fill('input[name="name"]', 'John Doe');
- await page.fill('input[name="email"]', 'john@example.com');
- await page.fill('textarea[name="message"]', 'Test message');
- await page.click('button[type="submit"]');
+ await page.getByLabel('Name').fill('John Doe');
+ await page.getByLabel('Email').fill('john@example.com');
+ await page.getByLabel('Message').fill('Test message');
+ await page.getByRole('button', { name: /submit|send/i }).click();
- // Verify submission
- await page.waitForSelector('.success-message');
- console.log('āœ… Form submitted successfully');
+ // Verify submission via a web assertion (auto-waits for the message to appear).
+ await expect(page.getByText(/thank you|message sent|success/i)).toBeVisible();
+ console.log('[ok] Form submitted successfully');
await browser.close();
})();
@@ -203,8 +207,8 @@ const { chromium } = require('playwright');
}
}
- console.log(`āœ… Working links: ${results.working}`);
- console.log(`āŒ Broken links:`, results.broken);
+ console.log(`[ok] Working links: ${results.working}`);
+ console.log(`[fail] Broken links:`, results.broken);
await browser.close();
})();
@@ -221,7 +225,7 @@ const { chromium } = require('playwright');
try {
await page.goto('http://localhost:3000', {
- waitUntil: 'networkidle',
+ waitUntil: 'load',
timeout: 10000,
});
@@ -230,9 +234,9 @@ const { chromium } = require('playwright');
fullPage: true,
});
- console.log('šŸ“ø Screenshot saved to /tmp/screenshot.png');
+ console.log('[screenshot] Saved to /tmp/screenshot.png');
} catch (error) {
- console.error('āŒ Error:', error.message);
+ console.error('[error]', error.message);
} finally {
await browser.close();
}
@@ -276,7 +280,7 @@ const TARGET_URL = 'http://localhost:3001'; // Auto-detected
});
}
- console.log('āœ… All viewports tested');
+ console.log('[ok] All viewports tested');
await browser.close();
})();
```
@@ -395,7 +399,7 @@ For comprehensive Playwright API documentation, see [API_REFERENCE.md](API_REFER
- **DEFAULT: Visible browser** - Always use `headless: false` unless user explicitly asks for headless mode
- **Headless mode** - Only use `headless: true` when user specifically requests "headless" or "background" execution
- **Slow down:** Use `slowMo: 100` to make actions visible and easier to follow
-- **Wait strategies:** Use `waitForURL`, `waitForSelector`, `waitForLoadState` instead of fixed timeouts
+- **Wait strategies:** Lead with web assertions and locators — `await expect(locator).toBeVisible()`, `await locator.waitFor()` — and `waitForURL`. They auto-wait for the real condition. Reach for `waitForSelector` only when a locator won't express the wait. Avoid `waitForLoadState('networkidle')` as a readiness check; Playwright discourages it. Don't use fixed timeouts.
- **Error handling:** Always use try-catch for robust automation
- **Console output:** Use `console.log()` to track progress and show what's happening
@@ -414,7 +418,7 @@ Ensure running from skill directory via `run.js` wrapper
Check `headless: false` and ensure display available
**Element not found:**
-Add wait: `await page.waitForSelector('.element', { timeout: 10000 })`
+Wait on a locator: `await page.getByRole('button', { name: 'Save' }).waitFor({ timeout: 10000 })` (or `await expect(locator).toBeVisible()`). These auto-wait for a visible, app-specific element rather than network state.
## Example Usage
@@ -445,7 +449,7 @@ User: "Use 3001"
[Writes login automation to /tmp/playwright-test-login.js]
[Runs: cd $SKILL_DIR && node run.js /tmp/playwright-test-login.js]
-[Reports: āœ… Login successful, redirected to /dashboard]
+[Reports: [ok] Login successful, redirected to /dashboard]
```
## Notes
@@ -470,27 +474,30 @@ User task → Is it static HTML (file:// or plain server-rendered)?
│
└─ No (dynamic webapp) →
1. Navigate to the page
- 2. Wait for networkidle: await page.waitForLoadState('networkidle');
+ 2. Wait for a visible app landmark: await expect(page.getByRole('main')).toBeVisible();
+ (or: await page.getByText('Dashboard').waitFor();)
3. Inspect rendered DOM (screenshot, page.content(), or locator().all())
4. Identify selectors from the rendered state, not the source
5. Execute actions with those selectors
```
-**Common pitfall:** inspecting the DOM before `networkidle` on a dynamic app returns stale content or an empty skeleton. Every "element not found" bug on dynamic pages should trigger a "did I wait for networkidle?" check first.
+**Common pitfall:** inspecting the DOM before the app has rendered returns stale content or an empty skeleton. The fix is to wait for a *visible, app-specific landmark* — a heading, a `role=main` region, a known piece of text — via a web assertion (`expect(locator).toBeVisible()`) or `locator.waitFor()`. These auto-wait for the element to appear, which is what "ready" actually means. Don't wait on `networkidle` for this; Playwright explicitly discourages it as a readiness signal, since a page can be interactive long before the network goes quiet (or never go quiet at all, with polling or analytics). Every "element not found" bug on dynamic pages should trigger a "did I wait for a visible landmark?" check first.
## Added: Reconnaissance-Then-Action Pattern
For any non-trivial interaction on a dynamic page:
-1. **Reconnoiter.** Navigate, wait for load, capture state:
+1. **Reconnoiter.** Navigate, wait for a visible landmark, capture state:
```javascript
await page.goto(TARGET_URL);
- await page.waitForLoadState('networkidle');
+ await expect(page.getByRole('main')).toBeVisible(); // wait for a real app landmark, not network quiet
await page.screenshot({ path: '/tmp/inspect.png', fullPage: true });
const html = await page.content();
const buttons = await page.locator('button').all();
```
+ `expect` comes from `const { expect } = require('@playwright/test');`. If the landmark isn't known yet, `await page.getByText('<some text you expect>').waitFor()` works the same way. Both auto-wait; neither relies on `networkidle`.
+
2. **Decide.** From the screenshot + content + locator list, pick the selectors you'll use. Don't guess from source.
3. **Act.** Execute the interaction with the discovered selectors.
diff --git a/playwright-js/lib/helpers.js b/playwright-js/lib/helpers.js
index 0920d68..42aeedd 100644
--- a/playwright-js/lib/helpers.js
+++ b/playwright-js/lib/helpers.js
@@ -82,30 +82,45 @@ async function createPage(context, options = {}) {
}
/**
- * Smart wait for page to be ready
+ * Smart wait for page to be ready.
+ *
+ * Readiness defaults to the 'load' event. Pass `options.waitForSelector` with an
+ * app-specific landmark (a heading, a role=main region, known text) to wait on
+ * something the app actually rendered — that's the most reliable readiness signal
+ * and the one to prefer. `networkidle` is intentionally NOT the default: Playwright
+ * discourages it for readiness, since a page can be interactive long before the
+ * network goes quiet (and may never go quiet with polling or analytics). Callers
+ * can still pass `waitUntil: 'networkidle'` explicitly if they really need it.
* @param {Object} page - Playwright page
- * @param {Object} options - Wait options
+ * @param {Object} options - Wait options (waitUntil, timeout, waitForSelector)
*/
async function waitForPageReady(page, options = {}) {
const waitOptions = {
- waitUntil: options.waitUntil || 'networkidle',
+ waitUntil: options.waitUntil || 'load',
timeout: options.timeout || 30000
};
-
+
+ // Prefer waiting on a caller-supplied landmark selector when given; it's a
+ // far stronger readiness signal than any load-state event.
+ if (options.waitForSelector) {
+ try {
+ await page.waitForSelector(options.waitForSelector, {
+ state: 'visible',
+ timeout: waitOptions.timeout
+ });
+ return;
+ } catch (e) {
+ console.warn(`Landmark "${options.waitForSelector}" not visible in time, falling back to load state...`);
+ }
+ }
+
try {
- await page.waitForLoadState(waitOptions.waitUntil, {
- timeout: waitOptions.timeout
+ 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
- });
- }
}
/**
@@ -215,10 +230,11 @@ async function authenticate(page, credentials, selectors = {}) {
await safeType(page, finalSelectors.password, credentials.password);
await safeClick(page, finalSelectors.submit);
- // Wait for navigation or success indicator
+ // Wait for a post-login landmark to become visible (preferred), or for a
+ // navigation to settle. networkidle is avoided here as a readiness signal.
await Promise.race([
- page.waitForNavigation({ waitUntil: 'networkidle' }),
- page.waitForSelector(selectors.successIndicator || '.dashboard, .user-menu, .logout', { timeout: 10000 })
+ page.waitForSelector(selectors.successIndicator || '.dashboard, .user-menu, .logout', { state: 'visible', timeout: 10000 }),
+ page.waitForNavigation({ waitUntil: 'load' })
]).catch(() => {
console.log('Login might have completed without navigation');
});
@@ -383,7 +399,7 @@ async function detectDevServers(customPorts = []) {
const detectedServers = [];
- console.log('šŸ” Checking for running dev servers...');
+ console.log('[scan] Checking for running dev servers...');
for (const port of allPorts) {
try {
@@ -397,7 +413,7 @@ async function detectDevServers(customPorts = []) {
}, (res) => {
if (res.statusCode < 500) {
detectedServers.push(`http://localhost:${port}`);
- console.log(` āœ… Found server on port ${port}`);
+ console.log(` [ok] Found server on port ${port}`);
}
resolve();
});
@@ -416,7 +432,7 @@ async function detectDevServers(customPorts = []) {
}
if (detectedServers.length === 0) {
- console.log(' āŒ No dev servers detected');
+ console.log(' [none] No dev servers detected');
}
return detectedServers;
diff --git a/playwright-js/run.js b/playwright-js/run.js
index 10f2616..ade36cb 100755
--- a/playwright-js/run.js
+++ b/playwright-js/run.js
@@ -33,14 +33,14 @@ function checkPlaywrightInstalled() {
* Install Playwright if missing
*/
function installPlaywright() {
- console.log('šŸ“¦ Playwright not found. Installing...');
+ console.log('[setup] Playwright not found. Installing...');
try {
execSync('npm install', { stdio: 'inherit', cwd: __dirname });
execSync('npx playwright install chromium', { stdio: 'inherit', cwd: __dirname });
- console.log('āœ… Playwright installed successfully');
+ console.log('[setup] Playwright installed successfully');
return true;
} catch (e) {
- console.error('āŒ Failed to install Playwright:', e.message);
+ console.error('[error] Failed to install Playwright:', e.message);
console.error('Please run manually: cd', __dirname, '&& npm run setup');
return false;
}
@@ -55,24 +55,24 @@ function getCodeToExecute() {
// Case 1: File path provided
if (args.length > 0 && fs.existsSync(args[0])) {
const filePath = path.resolve(args[0]);
- console.log(`šŸ“„ Executing file: ${filePath}`);
+ console.log(`[file] Executing file: ${filePath}`);
return fs.readFileSync(filePath, 'utf8');
}
// Case 2: Inline code provided as argument
if (args.length > 0) {
- console.log('⚔ Executing inline code');
+ console.log('[inline] Executing inline code');
return args.join(' ');
}
// Case 3: Code from stdin
if (!process.stdin.isTTY) {
- console.log('šŸ“„ Reading from stdin');
+ console.log('[stdin] Reading from stdin');
return fs.readFileSync(0, 'utf8');
}
// No input
- console.error('āŒ No code to execute');
+ console.error('[error] No code to execute');
console.error('Usage:');
console.error(' node run.js script.js # Execute file');
console.error(' node run.js "code here" # Execute inline');
@@ -146,7 +146,7 @@ function getContextOptionsWithHeaders(options = {}) {
try {
${code}
} catch (error) {
- console.error('āŒ Automation error:', error.message);
+ console.error('[error] Automation error:', error.message);
if (error.stack) {
console.error(error.stack);
}
@@ -163,7 +163,7 @@ function getContextOptionsWithHeaders(options = {}) {
try {
${code}
} catch (error) {
- console.error('āŒ Automation error:', error.message);
+ console.error('[error] Automation error:', error.message);
if (error.stack) {
console.error(error.stack);
}
@@ -180,7 +180,7 @@ function getContextOptionsWithHeaders(options = {}) {
* Main execution
*/
async function main() {
- console.log('šŸŽ­ Playwright Skill - Universal Executor\n');
+ console.log('Playwright Skill - Universal Executor\n');
// Clean up old temp files from previous runs
cleanupOldTempFiles();
@@ -205,16 +205,16 @@ async function main() {
fs.writeFileSync(tempFile, code, 'utf8');
// Execute the code
- console.log('šŸš€ Starting automation...\n');
+ console.log('[run] Starting automation...\n');
require(tempFile);
// Note: Temp file will be cleaned up on next run
// This allows long-running async operations to complete safely
} catch (error) {
- console.error('āŒ Execution failed:', error.message);
+ console.error('[error] Execution failed:', error.message);
if (error.stack) {
- console.error('\nšŸ“‹ Stack trace:');
+ console.error('\n[trace] Stack trace:');
console.error(error.stack);
}
process.exit(1);
@@ -223,6 +223,6 @@ async function main() {
// Run main function
main().catch(error => {
- console.error('āŒ Fatal error:', error.message);
+ console.error('[error] Fatal error:', error.message);
process.exit(1);
});
diff --git a/playwright-py/SKILL.md b/playwright-py/SKILL.md
index 0ed912b..54e1cb7 100644
--- a/playwright-py/SKILL.md
+++ b/playwright-py/SKILL.md
@@ -26,7 +26,8 @@ User task → Is it static HTML?
│ Then use the helper + write simplified Playwright script
│
└─ Yes → Reconnaissance-then-action:
- 1. Navigate and wait for networkidle
+ 1. Navigate and wait for a visible app landmark
+ (expect(page.get_by_role('main')).to_be_visible())
2. Take screenshot or inspect DOM
3. Identify selectors from rendered state
4. Execute actions with discovered selectors
@@ -51,13 +52,13 @@ python scripts/with_server.py \
To create an automation script, include only Playwright logic (servers are managed automatically):
```python
-from playwright.sync_api import sync_playwright
+from playwright.sync_api import sync_playwright, expect
with sync_playwright() as p:
browser = p.chromium.launch(headless=True) # headless for CI/pytest; headless=False for interactive debugging
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
+ expect(page.get_by_role('main')).to_be_visible() # wait for a visible app landmark, not network quiet
# ... your automation logic
browser.close()
```
@@ -77,16 +78,16 @@ with sync_playwright() as p:
## 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
+**Don't** inspect the DOM before the app has rendered on a dynamic page — you get stale content or an empty skeleton.
+**Do** wait for a visible, app-specific landmark before inspecting: `expect(page.get_by_role('main')).to_be_visible()` or `page.get_by_text('Dashboard').wait_for()`. These auto-wait for the element to appear, which is what "ready" means. Avoid `page.wait_for_load_state('networkidle')` as the readiness check — Playwright discourages it, since a page can be interactive long before the network quiets (or never quiet at all, with polling or analytics).
## 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()`
+- Prefer user-visible locators: `page.get_by_role(...)`, `page.get_by_label(...)`, `page.get_by_text(...)`. Fall back to CSS/`text=` selectors only when those don't fit.
+- For readiness, lead with web assertions and locator waits — `expect(locator).to_be_visible()`, `locator.wait_for()` — which auto-wait for a real, visible condition. Reach for `page.wait_for_selector()` only when a locator won't express the wait. Avoid `wait_for_load_state('networkidle')` as a readiness check (Playwright discourages it) and avoid fixed `page.wait_for_timeout()` delays.
- **Choose headed vs headless by purpose, not habit.** This skill defaults to *headless* (`headless=True`) because it targets CI and pytest. The companion `/playwright-js` defaults to *headed* for interactive visual debugging. Pick by what you're doing, and only override when the purpose flips:
| Purpose | Mode |
diff --git a/playwright-py/examples/broken_links.py b/playwright-py/examples/broken_links.py
index c78520f..a292f74 100644
--- a/playwright-py/examples/broken_links.py
+++ b/playwright-py/examples/broken_links.py
@@ -41,13 +41,13 @@ def main() -> int:
status = resp.status
if status < 400:
ok += 1
- print(f"āœ“ {status} {url}")
+ print(f"[ok] {status} {url}")
else:
bad += 1
- print(f"āœ— {status} {url}")
+ print(f"[fail] {status} {url}")
except Exception as ex:
err += 1
- print(f"āœ— ERR {url} ({type(ex).__name__}: {ex})")
+ print(f"[fail] ERR {url} ({type(ex).__name__}: {ex})")
print(f"\n{ok} ok, {bad} broken, {err} errored out of {len(urls)} total")
browser.close()
diff --git a/playwright-py/examples/login_flow.py b/playwright-py/examples/login_flow.py
index d114ac6..6d2fa45 100644
--- a/playwright-py/examples/login_flow.py
+++ b/playwright-py/examples/login_flow.py
@@ -45,7 +45,7 @@ def main() -> int:
safe_click(page, 'button[type="submit"]')
page.wait_for_url("**/dashboard", timeout=5000)
- print(f"āœ“ Logged in; redirected to {page.url}")
+ print(f"[ok] Logged in; redirected to {page.url}")
browser.close()
return 0
diff --git a/playwright-py/examples/responsive_sweep.py b/playwright-py/examples/responsive_sweep.py
index d890d5b..eb6e216 100644
--- a/playwright-py/examples/responsive_sweep.py
+++ b/playwright-py/examples/responsive_sweep.py
@@ -41,7 +41,7 @@ def main() -> int:
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}")
+ print(f"[ok] {name:<8} ({width:>4}x{height:<4}) -> {path}")
context.close()
browser.close()
return 0