Playwright Browser Automation
Write and run a small, purpose-built Playwright script for the task at hand rather than reaching for a generic pre-built test. Most browser-verification tasks in this repo are one-off: does this page render, does this form submit, does this flow redirect correctly.
This Repo's Setup
client/— Angular app, served vianpm start(ng serve --host 0.0.0.0 --port 4200) →http://localhost:4200agent/— Express API the client talks to, vianpm run dev→http://localhost:3000- Neither package currently depends on Playwright. Install it once per machine as a devDependency before first use:
(Or install globally in a scratch directory if you'd rather not touchnpm install -D @playwright/test --prefix client npx --prefix client playwright install chromiumclient/package.jsonfor an ad-hoc check.)
Workflow
- Confirm the dev server is actually running before writing a script — don't assume port 4200 (and 3000, if the flow touches the API) is up. Start it if it isn't (
npm startinclient/,npm run devinagent/), or ask for the URL if testing something already deployed. - Write the script to a scratch path (this session's scratchpad directory, or
/tmp), never intoclient/— these are throwaway verification scripts, not part of the app. - Parameterize the URL as a constant at the top of the script so it's obvious what's being tested.
- Run with a visible browser by default (
headless: false) when a human is watching / debugging; useheadless: truefor a quick unattended check or when running from a non-interactive context. - Always wait on real signals (
waitForSelector,waitForURL,waitForLoadState('networkidle')), never a fixedwaitForTimeoutas the only guard — Angular's change detection and API round-trips make fixed sleeps flaky.
Minimal Script Template
// scratch/playwright-check.js
const { chromium } = require('playwright');
const TARGET_URL = 'http://localhost:4200'; // parameterize, don't hardcode inline below
(async () => {
const browser = await chromium.launch({ headless: false });
const page = await browser.newPage();
try {
await page.goto(TARGET_URL, { waitUntil: 'networkidle' });
console.log('Title:', await page.title());
// ... actions specific to the task ...
await page.screenshot({ path: 'scratch/screenshot.png', fullPage: true });
} finally {
await browser.close();
}
})();
Run with: node scratch/playwright-check.js
Common Patterns
Fill and submit a form (e.g. a job-request form in client/):
await page.goto(`${TARGET_URL}/requests/new`);
await page.fill('input[formcontrolname="name"]', 'Test Job');
await page.selectOption('select[formcontrolname="type"]', 'http');
await page.click('button[type="submit"]');
await page.waitForSelector('.success-message, [role="alert"]');
Check a list/status page renders data from the API:
await page.goto(`${TARGET_URL}/requests`);
await page.waitForLoadState('networkidle');
const rowCount = await page.locator('table tbody tr').count();
console.log('Rows rendered:', rowCount);
Responsive check across viewports:
const viewports = [
{ name: 'desktop', width: 1440, height: 900 },
{ name: 'tablet', width: 768, height: 1024 },
{ name: 'mobile', width: 375, height: 667 },
];
for (const v of viewports) {
await page.setViewportSize({ width: v.width, height: v.height });
await page.goto(TARGET_URL, { waitUntil: 'networkidle' });
await page.screenshot({ path: `scratch/${v.name}.png`, fullPage: true });
}
Multi-tenant check (this app is multi-tenant — verify tenant isolation when relevant):
// Set whatever header/localStorage/cookie this app uses to select tenant context
await page.goto(TARGET_URL);
await page.evaluate((tenantId) => localStorage.setItem('tenantId', tenantId), 'tenant-a');
await page.reload();
Confirm the actual mechanism (header vs. localStorage vs. route param) by reading how client/src sets tenant context before assuming this pattern.
Selectors
Prefer, in order: role= / accessible name, text=, a stable data-testid if present, Angular formcontrolname/component selectors, then CSS as a last resort. Avoid brittle nth-child selectors.
Tips
- Use
slowMo: 100when a human needs to actually watch the run happen - Always wrap the page interaction in
try/finallysobrowser.close()runs even on failure console.logprogress as you go — output is the only feedback while headless- Don't commit scratch scripts or screenshots to the repo
Troubleshooting
| Symptom | Likely cause |
|---|---|
browserType.launch: Executable doesn't exist |
Run npx playwright install chromium |
| Element not found immediately after navigation | Angular hasn't finished rendering — add waitForSelector or waitForLoadState('networkidle') |
| Works locally, flaky in CI | Fixed timeouts instead of condition-based waits — replace waitForTimeout with a real wait condition |