
Web Qa Bot
- 1 installs
- Updated January 30, 2026
- nextfrontierbuilds/web-qa-bot
Runs accessibility-tree-based QA automation on web apps for smoke tests, test suites, and PDF reports, and can fail CI on regressions.
About
Automates web application QA using accessibility-tree-based testing to run smoke tests and defined test suites and generate PDF reports. A developer uses it for site health checks, pre-deployment QA, and CI regression gating.
- Quick smoke test plus YAML-defined test suites
- Integrates with agent-browser sessions and can fail CI on issues
Web Qa Bot by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,750 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/nextfrontierbuilds/web-qa-bot --skill web-qa-botAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| Last updated | January 30, 2026 |
| Repository | nextfrontierbuilds/web-qa-bot ↗ |
What it does
Runs accessibility-tree-based QA automation on web apps for smoke tests, test suites, and PDF reports, and can fail CI on regressions.
Files
web-qa-bot
AI-powered web application QA automation using accessibility-tree based testing.
Overview
This skill provides tools for automated QA testing of web applications. It uses browser accessibility trees for reliable element detection instead of fragile CSS selectors.
Installation
npm install -g web-qa-bot agent-browser
agent-browser installCommands
Quick Smoke Test
web-qa-bot smoke https://example.comRuns basic health checks:
- Page loads successfully
- No console errors
- Navigation elements present
- Images have alt text
Run Test Suite
web-qa-bot run ./tests/suite.yaml --output report.mdGenerate PDF Report
web-qa-bot report ./results.json -o report.pdf -f pdfUse Cases
1. Quick Site Health Check
# Smoke test a production URL
web-qa-bot smoke https://app.example.com --checks pageLoad,consoleErrors,navigation2. Pre-deployment QA
Create a test suite and run before each deployment:
# tests/critical-paths.yaml
name: Critical Paths
baseUrl: https://staging.example.com
tests:
- name: Login flow
steps:
- goto: /login
- type: { ref: Email, text: test@example.com }
- type: { ref: Password, text: testpass }
- click: Sign In
- expectVisible: Dashboard
- expectNoErrors: trueweb-qa-bot run ./tests/critical-paths.yaml --output qa-report.pdf -f pdf3. Monitor for Regressions
# Run tests and fail CI if issues found
web-qa-bot run ./tests/smoke.yaml || exit 14. Programmatic Testing
import { QABot } from 'web-qa-bot'
const qa = new QABot({
baseUrl: 'https://example.com',
headless: true
})
await qa.goto('/')
await qa.click('Get Started')
await qa.snapshot()
qa.expectVisible('Sign Up')
await qa.close()Integration with agent-browser
This tool wraps agent-browser CLI for browser automation:
# Connect to existing browser session
web-qa-bot smoke https://example.com --cdp 18800
# Run headed for debugging
web-qa-bot run ./tests/suite.yaml --no-headlessTest Results Format
Results are returned as structured JSON:
{
"name": "Smoke Test",
"url": "https://example.com",
"summary": {
"total": 4,
"passed": 3,
"failed": 0,
"warnings": 1
},
"tests": [
{
"name": "Page Load",
"status": "pass",
"duration": 1234
}
]
}Tips
1. Use role-based selectors - More reliable than CSS classes 2. Check console errors - Often reveals hidden issues 3. Test both navigation methods - Direct URL and in-app routing 4. Screenshot on failure - Automatic in test suites 5. Monitor for modals - Can block interactions
Report Formats
- Markdown - Default, human-readable
- PDF - Professional reports via ai-pdf-builder
- JSON - Machine-readable for CI/CD
Troubleshooting
"agent-browser not found"
npm install -g agent-browser
agent-browser install"Element not found"
Take a snapshot first to see available refs:
agent-browser snapshot"Timeout waiting for element"
Increase timeout or check if element is behind a loading state:
steps:
- waitMs: 2000
- waitFor: "Loading" # Wait for loading to appear
- waitFor: "Content" # Then wait for contentLinks
# Dependencies
node_modules/
# Build output
dist/
# Screenshots
screenshots/
# Test results
*.results.json
# OS files
.DS_Store
Thumbs.db
# IDE
.vscode/
.idea/
*.swp
*.swo
# Logs
*.log
npm-debug.log*
# Coverage
coverage/
# Environment
.env
.env.local
#!/usr/bin/env node
import '../dist/cli.js'
{
"name": "web-qa-bot",
"version": "0.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "web-qa-bot",
"version": "0.1.2",
"license": "MIT",
"dependencies": {
"yaml": "^2.3.4"
},
"bin": {
"web-qa-bot": "bin/web-qa-bot.js"
},
"devDependencies": {
"@types/node": "^20.10.0",
"typescript": "^5.3.0"
},
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"agent-browser": ">=0.7.0"
},
"peerDependenciesMeta": {
"agent-browser": {
"optional": false
}
}
},
"node_modules/@types/node": {
"version": "20.19.30",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz",
"integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/agent-browser": {
"version": "0.8.4",
"resolved": "https://registry.npmjs.org/agent-browser/-/agent-browser-0.8.4.tgz",
"integrity": "sha512-zs2Lt8dmawlEtiBSaAzxxij0lTo1R9rSViKnGtA14CebAC3rr0dOu7Em+j2tSmLrvUsxXs42xpPJ1395b4WJ6w==",
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"playwright-core": "^1.57.0",
"ws": "^8.19.0",
"zod": "^3.22.4"
},
"bin": {
"agent-browser": "bin/agent-browser.js"
}
},
"node_modules/playwright-core": {
"version": "1.58.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz",
"integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==",
"license": "Apache-2.0",
"peer": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
{
"name": "web-qa-bot",
"version": "0.1.2",
"description": "Automated QA for web apps using AI. Smoke tests, accessibility checks, visual regression. Drop-in replacement for manual QA. Works with Playwright, Cursor, Claude. QA without the QA team.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"bin": {
"web-qa-bot": "./bin/web-qa-bot.js"
},
"scripts": {
"build": "tsc",
"watch": "tsc --watch",
"test": "node --test dist/**/*.test.js",
"prepublishOnly": "npm run build"
},
"keywords": [
"automated-qa",
"ai-testing",
"smoke-test",
"accessibility-testing",
"visual-regression",
"ci-testing",
"playwright-alternative",
"selenium-alternative",
"e2e-testing",
"qa",
"testing",
"automation",
"browser",
"web-testing",
"ai",
"ai-agent",
"vibe-coding",
"cursor",
"claude",
"gpt",
"copilot",
"mcp",
"langchain",
"llm",
"devops",
"ci-cd",
"github-actions",
"clawdbot",
"moltbot"
],
"author": "NextFrontierBuilds",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/NextFrontierBuilds/web-qa-bot.git"
},
"engines": {
"node": ">=18.0.0"
},
"dependencies": {
"yaml": "^2.3.4"
},
"devDependencies": {
"@types/node": "^20.10.0",
"typescript": "^5.3.0"
},
"peerDependencies": {
"agent-browser": ">=0.7.0"
},
"peerDependenciesMeta": {
"agent-browser": {
"optional": false
}
},
"files": [
"dist",
"bin",
"templates",
"README.md",
"SKILL.md"
]
}
Web QA Bot - Project Plan
Overview
Two related projects: 1. web-qa-bot - Generic web app QA automation (npm + skill) 2. strykr-qa-bot - Strykr-specific QA extension (skill only)
---
1. web-qa-bot (Generic)
Distribution
- npm:
web-qa-bot - GitHub:
NextFrontierBuilds/web-qa-bot - ClawdHub:
NextFrontierBuilds/web-qa-bot
Core Features
Browser Automation
- Built on agent-browser CLI (fast, accessibility-tree based)
- Fallback to Playwright for complex scenarios
- CDP connection for existing browser sessions
- Headless and headed modes
Smart Element Detection
- Role-based locators (accessibility tree refs)
- Auto-wait for elements with retry logic
- Stale ref detection and re-snapshot
- Console log monitoring for hidden interactions
Test Primitives
// Navigation
await qa.goto(url)
await qa.waitForLoad()
await qa.waitForSelector(selector, { timeout: 5000 })
// Interactions
await qa.click(ref)
await qa.type(ref, text)
await qa.select(ref, value)
await qa.hover(ref)
// Assertions
await qa.expectVisible(ref)
await qa.expectText(ref, text)
await qa.expectCount(selector, count)
await qa.expectConsoleEvent(pattern)
await qa.expectNoErrors()
// Snapshots
await qa.snapshot()
await qa.screenshot(name)
await qa.getConsole()Test Suites
const suite = qa.suite('Homepage', {
url: 'https://example.com',
tests: [
{
name: 'Navigation links work',
steps: [
{ action: 'click', ref: 'nav-about' },
{ expect: 'url', contains: '/about' }
]
}
]
})Reporting
- Markdown report generation
- PDF export via ai-pdf-builder
- Screenshots embedded in reports
- Pass/Fail/Warn status
- Console errors captured
- Timing metrics
Learnings Integrated
1. Async Data Handling
- Auto-detect loading states
- Configurable wait strategies
- Retry on stale refs
2. Modal Detection
- Monitor DOM for dialog elements
- Console event detection (analytics, warnings)
- Screenshot before/after interactions
3. Route Testing
- Direct URL navigation tests
- In-app navigation tests
- Compare behavior differences
4. Audio/Media Verification
- UI state change detection (play → pause)
- Media element state checking
5. Error Capture
- Console errors/warnings
- Network failures
- Unhandled exceptions
Best Practices Built-in
1. User-visible testing - Test what users see, not implementation 2. Role-based locators - Accessibility refs, not CSS classes 3. Auto-wait assertions - No manual timeouts 4. Isolated tests - Each test starts fresh 5. No third-party deps - Mock external services
CLI Usage
# Run test suite
npx web-qa-bot run ./tests/suite.yaml --url https://example.com
# Quick smoke test
npx web-qa-bot smoke https://example.com
# Generate report
npx web-qa-bot report ./results --output report.pdf
# Interactive mode (for building tests)
npx web-qa-bot interactive https://example.comProgrammatic API
import { QABot } from 'web-qa-bot'
const qa = new QABot({
baseUrl: 'https://example.com',
browser: 'chromium',
headless: true,
screenshotDir: './screenshots'
})
await qa.run([
{ goto: '/' },
{ click: 'button[name="Login"]' },
{ expectVisible: 'form[name="login"]' }
])
await qa.generateReport('./report.pdf')---
2. strykr-qa-bot (Strykr-Specific)
Distribution
- GitHub:
NextFrontierBuilds/strykr-qa-bot - ClawdHub:
NextFrontierBuilds/strykr-qa-bot
Features
- Extends web-qa-bot
- Pre-built test suites for all Strykr pages
- PRISM API endpoint validation
- Strykr component patterns
Pre-built Test Suites
Homepage Suite
- Market status indicator
- AI chat widget
- News carousel
- Signals preview
- Events widget
- Navigation links
Crypto Signals Suite
- Page load with data
- Long/Short filters
- Chain filters (EVM/Solana)
- Signal sorting
- Listen button state
- Ask Strykr AI button
- Details modal (with issue detection)
- Sparkline charts
Stock Signals Suite
- Asset type filters
- Signal data display
- Action buttons
News Suite
- Direct URL vs nav routing
- Category filters
- Sentiment filters
- Listen/Share/Ask buttons
Events Suite
- Direct URL routing
- Event cards
- Impact indicators
AI Chat Suite
- Direct input
- Pre-filled queries
- Response quality checks
- Loading states
Strykr-Specific Assertions
// Check signal card renders correctly
await strykr.expectSignalCard({
symbol: 'JUP',
hasPrice: true,
hasChart: true,
hasActions: true
})
// Check AI response quality
await strykr.expectAIResponse({
hasPrice: true,
hasTechnicals: true,
minLength: 200
})
// Check PRISM API health
await strykr.checkPrismEndpoints()Configuration
# strykr-qa.yaml
baseUrl: https://app.strykr.ai
suites:
- homepage
- crypto-signals
- stock-signals
- news
- events
- ai-chat
knownIssues:
- id: details-modal-empty
description: Details modal opens but content fails to load
severity: high
skipTest: false # Still test, but mark as known issue
- id: direct-url-blank
description: /news and /economic-events blank on direct nav
severity: medium
affectedRoutes:
- /news
- /economic-events---
File Structure
web-qa-bot
web-qa-bot/
├── src/
│ ├── index.ts # Main exports
│ ├── bot.ts # QABot class
│ ├── browser.ts # Browser automation
│ ├── assertions.ts # Test assertions
│ ├── reporter.ts # Report generation
│ ├── cli.ts # CLI entry point
│ └── utils/
│ ├── wait.ts # Wait strategies
│ ├── console.ts # Console monitoring
│ └── snapshot.ts # Screenshot helpers
├── templates/
│ └── report.md # Report template
├── bin/
│ └── web-qa-bot # CLI binary
├── package.json
├── tsconfig.json
├── README.md
└── SKILL.md # ClawdHub skillstrykr-qa-bot
strykr-qa-bot/
├── suites/
│ ├── homepage.yaml
│ ├── crypto-signals.yaml
│ ├── stock-signals.yaml
│ ├── news.yaml
│ ├── events.yaml
│ └── ai-chat.yaml
├── src/
│ ├── strykr-bot.ts # Strykr extensions
│ └── assertions.ts # Strykr-specific assertions
├── config/
│ └── known-issues.yaml
├── README.md
└── SKILL.md # ClawdHub skill---
Implementation Order
1. Phase 1: Core web-qa-bot
- Browser automation wrapper
- Basic assertions
- Markdown reporting
- CLI scaffolding
2. Phase 2: Advanced Features
- Console monitoring
- Modal detection
- Route comparison
- PDF reporting
3. Phase 3: strykr-qa-bot
- Strykr test suites
- Known issue tracking
- PRISM API checks
4. Phase 4: Polish
- ClawdHub skills
- Documentation
- npm publish
---
Success Criteria
- [ ]
npx web-qa-bot smoke https://example.comruns basic checks - [ ] Generates professional PDF report via ai-pdf-builder
- [ ] Detects common issues (console errors, broken links, missing elements)
- [ ] strykr-qa-bot catches the issues we found today
- [ ] Both published to npm/ClawdHub
web-qa-bot
🤖 AI-Powered QA Automation — QA without the QA team. Smoke tests, accessibility checks, and visual regression in one command.
  
---
Works With
<p align="center"> <img src="https://img.shields.io/badge/GitHub_Actions-CI/CD-2088FF?style=for-the-badge&logo=github-actions" alt="GitHub Actions" /> <img src="https://img.shields.io/badge/Playwright-Testing-45ba4b?style=for-the-badge&logo=playwright" alt="Playwright" /> <img src="https://img.shields.io/badge/Claude-AI-orange?style=for-the-badge&logo=anthropic" alt="Claude AI" /> <img src="https://img.shields.io/badge/Cursor-IDE-000000?style=for-the-badge" alt="Cursor" /> <img src="https://img.shields.io/badge/Vercel-Deploy-000000?style=for-the-badge&logo=vercel" alt="Vercel" /> </p>
<p align="center"> <strong>Built for:</strong> Clawdbot • Moltbot • CI/CD Pipelines • AI Agents </p>
---
Why web-qa-bot?
- No selectors — Uses accessibility tree, not brittle CSS selectors
- One command —
npx web-qa-bot smoke https://your-app.com - AI-powered — Smart element detection with auto-retry
- CI-ready — Exit codes for GitHub Actions, GitLab CI, etc.
- PDF reports — Professional reports via ai-pdf-builder
---
Features
- Accessibility-first testing - Uses browser accessibility tree instead of CSS selectors
- Smart element detection - Role-based locators with auto-wait and retry logic
- Console monitoring - Captures errors, warnings, and custom events
- Modal detection - Automatically detects dialogs and popups
- Stale ref handling - Re-snapshots when elements become stale
- Professional reports - Markdown and PDF output via ai-pdf-builder
- Smoke tests - Quick health checks for any URL
- Built on agent-browser - Fast, reliable browser automation
Installation
npm install -g web-qa-bot
# Peer dependency
npm install -g agent-browser
agent-browser installQuick Start
Smoke Test
Run quick health checks on any URL:
web-qa-bot smoke https://example.comOutput:
=== Smoke Test Results ===
URL: https://example.com
Duration: 2.34s
✓ Page Load: PASS
✓ Console Errors: PASS
✓ Navigation: PASS
✓ Images: PASS
Total: 4 | Pass: 4 | Fail: 0 | Warn: 0Test Suite
Create a test suite file (tests/homepage.yaml):
name: Homepage Tests
baseUrl: https://example.com
tests:
- name: Page loads with title
steps:
- goto: /
- expectVisible: heading
- expectTitle: "Example Domain"
- name: Navigation works
steps:
- goto: /
- click: "More information"
- expectUrl:
contains: /about
- name: No console errors
steps:
- goto: /
- expectNoErrors: trueRun the suite:
web-qa-bot run ./tests/homepage.yaml --output report.mdGenerate PDF Report
web-qa-bot report ./results.json -o report.pdf -f pdf --company "Acme Corp"CLI Commands
smoke <url>
Run smoke tests on a URL.
web-qa-bot smoke https://example.com [options]
Options:
--checks <list> Comma-separated checks: pageLoad,consoleErrors,navigation,images,forms,accessibility,performance
--timeout <ms> Timeout in milliseconds (default: 30000)
-o, --output Output report pathrun <suite>
Run a test suite from a YAML or JSON file.
web-qa-bot run ./tests/suite.yaml [options]
Options:
--cdp <port> Connect to existing browser on CDP port
--no-headless Run in headed mode (visible browser)
--timeout <ms> Default timeout
-o, --output Output report path
-f, --format Report format: markdown, pdf, json
--company Company name for PDF header
--verbose Enable verbose loggingreport <results>
Generate a report from test results.
web-qa-bot report ./results.json -o report.pdf
Options:
-o, --output Output file path
-f, --format Report format: markdown, pdf, json
--company Company name for PDF headerProgrammatic API
import { QABot, smokeTest } from 'web-qa-bot'
// Quick smoke test
const result = await smokeTest({
url: 'https://example.com',
checks: ['pageLoad', 'consoleErrors', 'navigation']
})
console.log(result.summary) // { total: 3, passed: 3, failed: 0, ... }
// Full test suite
const qa = new QABot({
baseUrl: 'https://example.com',
headless: true,
screenshotDir: './screenshots'
})
try {
// Navigate
await qa.goto('/')
// Interact
await qa.click('Login')
await qa.type('Email', 'user@example.com')
await qa.type('Password', 'secret')
await qa.click('Submit')
// Assert
await qa.snapshot()
qa.expectVisible('Dashboard')
qa.expectUrl({ contains: '/dashboard' })
await qa.expectNoErrors()
// Generate report
await qa.generateReport('./report.pdf', { format: 'pdf' })
} finally {
await qa.close()
}Test Suite Format
Test suites can be written in YAML or JSON:
name: My Test Suite
baseUrl: https://example.com
# Run before all tests
beforeAll:
- goto: /login
- type: { ref: Email, text: admin@example.com }
- type: { ref: Password, text: secret }
- click: Submit
- waitFor: Dashboard
# Run before each test
beforeEach:
- goto: /
# Test cases
tests:
- name: Homepage loads
steps:
- goto: /
- expectVisible: Welcome
- name: Search works
steps:
- type: { ref: Search, text: hello }
- press: Enter
- waitFor: Results
- expectCount: { selector: article, min: 1 }
- name: Known issue (still runs, marked as warning)
knownIssue: JIRA-123
steps:
- click: Broken Button
- expectVisible: Success
- name: Skip this test
skip: true
steps:
- goto: /disabled-feature
# Run after each test
afterEach:
- screenshot: after-test
# Run after all tests
afterAll:
- click: LogoutAvailable Assertions
| Assertion | Description |
|---|---|
expectVisible(selector) | Element exists in accessibility tree |
expectNotVisible(selector) | Element does not exist |
expectText(selector, text) | Element has matching text |
expectUrl({ contains }) | URL matches pattern |
expectCount(role, count) | Count of elements with role |
expectNoErrors() | No console errors |
expectConsoleEvent(pattern) | Console event matches pattern |
expectModal(present) | Modal is present/absent |
expectTitle(text) | Page title matches |
expectClickable(selector) | Element is interactive and enabled |
Available Actions
| Action | Description |
|---|---|
goto(url) | Navigate to URL |
click(selector) | Click element |
type(selector, text) | Type text into element |
press(key) | Press keyboard key |
hover(selector) | Hover over element |
select(selector, value) | Select dropdown option |
waitFor(selector) | Wait for element to appear |
waitForLoad() | Wait for page load |
waitForUrl(pattern) | Wait for URL to match |
screenshot(name) | Take screenshot |
snapshot() | Take accessibility tree snapshot |
Element Selectors
web-qa-bot uses accessibility-tree based selectors:
// By ref ID (from snapshot output)
await qa.click('@e42')
// By text content
await qa.click('Submit')
// By role:name
await qa.click('button:Submit')
// By role with partial name
await qa.waitFor('heading') // Any headingBest Practices
Based on Playwright testing best practices:
1. Test user-visible behavior - Not implementation details 2. Use role-based locators - Accessibility refs over CSS classes 3. Web-first assertions - Built-in auto-wait, no manual timeouts 4. Isolated tests - Each test starts fresh 5. No third-party dependencies - Mock external services
Learnings Integrated
This tool incorporates learnings from real-world QA sessions:
- Async data handling - Configurable wait strategies for dynamic content
- Stale ref detection - Automatic re-snapshot when elements change
- Modal detection - Via DOM and console event monitoring
- Route comparison - Test direct URL vs in-app navigation
- Audio/media state - UI state verification for media elements
Requirements
- Node.js 18+
- agent-browser CLI (peer dependency)
- For PDF reports: ai-pdf-builder and LaTeX
License
MIT
Contributing
PRs welcome! Please read the contributing guidelines first.
---
Built by NextFrontierBuilds
/**
* Test assertions for web QA
*/
import type { Snapshot, ConsoleEvent } from './types.js'
import { findByRole, findByText, elementExists, detectModals } from './utils/snapshot.js'
export class AssertionError extends Error {
constructor(message: string, public actual?: unknown, public expected?: unknown) {
super(message)
this.name = 'AssertionError'
}
}
/**
* Assert element is visible in snapshot
*/
export function expectVisible(snapshot: Snapshot, selector: string): void {
if (!elementExists(snapshot, selector)) {
throw new AssertionError(
`Element not visible: ${selector}`,
'not found',
'visible'
)
}
}
/**
* Assert element is NOT visible
*/
export function expectNotVisible(snapshot: Snapshot, selector: string): void {
if (elementExists(snapshot, selector)) {
throw new AssertionError(
`Element should not be visible: ${selector}`,
'visible',
'not visible'
)
}
}
/**
* Assert element has text
*/
export function expectText(
snapshot: Snapshot,
selector: string,
text: string,
options: { contains?: boolean } = {}
): void {
let found: { name: string } | undefined
if (selector.startsWith('@')) {
found = snapshot.refs.get(selector)
} else {
found = findByText(snapshot, selector)
}
if (!found) {
throw new AssertionError(
`Element not found: ${selector}`,
'not found',
text
)
}
const actual = found.name
const matches = options.contains
? actual.toLowerCase().includes(text.toLowerCase())
: actual.toLowerCase() === text.toLowerCase()
if (!matches) {
throw new AssertionError(
`Text mismatch for ${selector}`,
actual,
text
)
}
}
/**
* Assert URL matches
*/
export function expectUrl(
snapshot: Snapshot,
expected: string | { contains?: string; matches?: RegExp }
): void {
const actual = snapshot.url
if (typeof expected === 'string') {
if (actual !== expected) {
throw new AssertionError('URL mismatch', actual, expected)
}
} else if (expected.contains) {
if (!actual.includes(expected.contains)) {
throw new AssertionError(
`URL does not contain expected string`,
actual,
expected.contains
)
}
} else if (expected.matches) {
if (!expected.matches.test(actual)) {
throw new AssertionError(
`URL does not match pattern`,
actual,
expected.matches.toString()
)
}
}
}
/**
* Assert element count
*/
export function expectCount(
snapshot: Snapshot,
role: string,
expected: number | { min?: number; max?: number }
): void {
let count = 0
for (const [key, ref] of snapshot.refs) {
if (key.startsWith('@') && ref.role.toLowerCase() === role.toLowerCase()) {
count++
}
}
if (typeof expected === 'number') {
if (count !== expected) {
throw new AssertionError(
`Element count mismatch for role "${role}"`,
count,
expected
)
}
} else {
if (expected.min !== undefined && count < expected.min) {
throw new AssertionError(
`Too few elements with role "${role}"`,
count,
`at least ${expected.min}`
)
}
if (expected.max !== undefined && count > expected.max) {
throw new AssertionError(
`Too many elements with role "${role}"`,
count,
`at most ${expected.max}`
)
}
}
}
/**
* Assert no console errors
*/
export function expectNoErrors(events: ConsoleEvent[]): void {
const errors = events.filter(e => e.type === 'error')
if (errors.length > 0) {
const messages = errors.map(e => e.text).join('\n')
throw new AssertionError(
`Console errors detected:\n${messages}`,
errors.length,
0
)
}
}
/**
* Assert console event matches pattern
*/
export function expectConsoleEvent(
events: ConsoleEvent[],
pattern: string | RegExp
): void {
const regex = typeof pattern === 'string' ? new RegExp(pattern, 'i') : pattern
const found = events.some(e => regex.test(e.text))
if (!found) {
throw new AssertionError(
`Console event not found: ${pattern}`,
'not found',
pattern.toString()
)
}
}
/**
* Assert element has state
*/
export function expectState(
snapshot: Snapshot,
selector: string,
state: 'disabled' | 'checked' | 'selected' | 'expanded' | 'pressed',
expected: boolean = true
): void {
const ref = selector.startsWith('@')
? snapshot.refs.get(selector)
: findByText(snapshot, selector)
if (!ref) {
throw new AssertionError(
`Element not found: ${selector}`,
'not found',
state
)
}
const actual = ref.state?.[state] ?? false
if (actual !== expected) {
throw new AssertionError(
`Element ${selector} state "${state}" mismatch`,
actual,
expected
)
}
}
/**
* Assert modal is present/absent
*/
export function expectModal(snapshot: Snapshot, present: boolean = true): void {
const modals = detectModals(snapshot)
if (present && modals.length === 0) {
throw new AssertionError(
'Expected modal to be present',
'no modal',
'modal visible'
)
}
if (!present && modals.length > 0) {
throw new AssertionError(
'Expected no modal to be present',
`${modals.length} modal(s)`,
'no modal'
)
}
}
/**
* Assert page title
*/
export function expectTitle(snapshot: Snapshot, title: string, options: { contains?: boolean } = {}): void {
const actual = snapshot.title
const matches = options.contains
? actual.toLowerCase().includes(title.toLowerCase())
: actual.toLowerCase() === title.toLowerCase()
if (!matches) {
throw new AssertionError(
'Page title mismatch',
actual,
title
)
}
}
/**
* Assert element is interactive (clickable)
*/
export function expectClickable(snapshot: Snapshot, selector: string): void {
const ref = selector.startsWith('@')
? snapshot.refs.get(selector)
: findByText(snapshot, selector)
if (!ref) {
throw new AssertionError(
`Element not found: ${selector}`,
'not found',
'clickable'
)
}
const clickableRoles = ['button', 'link', 'menuitem', 'tab', 'checkbox', 'radio', 'switch']
if (!clickableRoles.includes(ref.role.toLowerCase())) {
throw new AssertionError(
`Element ${selector} is not clickable`,
ref.role,
'clickable role'
)
}
if (ref.state?.disabled) {
throw new AssertionError(
`Element ${selector} is disabled`,
'disabled',
'enabled'
)
}
}
/**
* Soft assertion (returns result instead of throwing)
*/
export function softExpect<T>(
fn: () => T
): { ok: true; value: T } | { ok: false; error: Error } {
try {
const value = fn()
return { ok: true, value }
} catch (error) {
return { ok: false, error: error instanceof Error ? error : new Error(String(error)) }
}
}
/**
* QABot - Main class for web QA automation
*/
import type {
QABotConfig,
TestStep,
TestCase,
TestSuite,
TestResult,
SuiteResult,
Snapshot,
StepResult,
ConsoleEvent
} from './types.js'
import { Browser } from './browser.js'
import { Reporter } from './reporter.js'
import * as assertions from './assertions.js'
import { sleep, retry, waitFor } from './utils/wait.js'
import { resolveRef, detectModals, diffSnapshots } from './utils/snapshot.js'
export class QABot {
private config: QABotConfig
private browser: Browser
private reporter: Reporter
private currentSnapshot: Snapshot | null = null
constructor(config: QABotConfig) {
this.config = {
timeout: 30000,
retries: 3,
waitStrategy: 'auto',
monitorConsole: true,
verbose: false,
...config
}
this.browser = new Browser({
cdpPort: config.cdpPort,
headless: config.headless ?? true,
timeout: this.config.timeout,
screenshotDir: config.screenshotDir || './screenshots',
waitStrategy: this.config.waitStrategy,
verbose: this.config.verbose
})
this.reporter = new Reporter()
}
/**
* Navigate to URL
*/
async goto(url: string): Promise<Snapshot> {
const fullUrl = url.startsWith('http') ? url : `${this.config.baseUrl}${url}`
this.currentSnapshot = await this.browser.goto(fullUrl)
return this.currentSnapshot
}
/**
* Take snapshot
*/
async snapshot(): Promise<Snapshot> {
this.currentSnapshot = await this.browser.snapshot()
return this.currentSnapshot
}
/**
* Take screenshot
*/
async screenshot(name?: string): Promise<string> {
return this.browser.screenshot(name)
}
/**
* Click element
*/
async click(selector: string): Promise<void> {
const snapshot = this.currentSnapshot || await this.snapshot()
const ref = resolveRef(snapshot, selector)
if (!ref) {
throw new Error(`Element not found: ${selector}`)
}
await this.browser.click(ref)
// Re-snapshot after interaction
await sleep(100)
this.currentSnapshot = await this.browser.snapshot()
}
/**
* Type text
*/
async type(selector: string, text: string): Promise<void> {
const snapshot = this.currentSnapshot || await this.snapshot()
const ref = resolveRef(snapshot, selector)
if (!ref) {
throw new Error(`Element not found: ${selector}`)
}
await this.browser.type(ref, text)
this.currentSnapshot = await this.browser.snapshot()
}
/**
* Press key
*/
async press(key: string): Promise<void> {
await this.browser.press(key)
this.currentSnapshot = await this.browser.snapshot()
}
/**
* Hover over element
*/
async hover(selector: string): Promise<void> {
const snapshot = this.currentSnapshot || await this.snapshot()
const ref = resolveRef(snapshot, selector)
if (!ref) {
throw new Error(`Element not found: ${selector}`)
}
await this.browser.hover(ref)
}
/**
* Select option
*/
async select(selector: string, value: string): Promise<void> {
const snapshot = this.currentSnapshot || await this.snapshot()
const ref = resolveRef(snapshot, selector)
if (!ref) {
throw new Error(`Element not found: ${selector}`)
}
await this.browser.select(ref, value)
this.currentSnapshot = await this.browser.snapshot()
}
/**
* Wait for element
*/
async waitFor(selector: string, options?: { timeout?: number }): Promise<Snapshot> {
this.currentSnapshot = await this.browser.waitFor(selector, options)
return this.currentSnapshot
}
/**
* Wait for load state
*/
async waitForLoad(): Promise<void> {
await sleep(500)
this.currentSnapshot = await this.browser.snapshot()
}
/**
* Wait for URL
*/
async waitForUrl(pattern: string | RegExp, options?: { timeout?: number }): Promise<void> {
await this.browser.waitForUrl(pattern, options)
this.currentSnapshot = await this.browser.snapshot()
}
/**
* Get console events
*/
async getConsole(): Promise<ConsoleEvent[]> {
return this.browser.getConsole()
}
/**
* Expect element visible
*/
expectVisible(selector: string): void {
if (!this.currentSnapshot) {
throw new Error('No snapshot available. Call snapshot() first.')
}
assertions.expectVisible(this.currentSnapshot, selector)
}
/**
* Expect element text
*/
expectText(selector: string, text: string, options?: { contains?: boolean }): void {
if (!this.currentSnapshot) {
throw new Error('No snapshot available. Call snapshot() first.')
}
assertions.expectText(this.currentSnapshot, selector, text, options)
}
/**
* Expect URL
*/
expectUrl(expected: string | { contains?: string }): void {
if (!this.currentSnapshot) {
throw new Error('No snapshot available. Call snapshot() first.')
}
assertions.expectUrl(this.currentSnapshot, expected)
}
/**
* Expect element count
*/
expectCount(role: string, count: number | { min?: number; max?: number }): void {
if (!this.currentSnapshot) {
throw new Error('No snapshot available. Call snapshot() first.')
}
assertions.expectCount(this.currentSnapshot, role, count)
}
/**
* Expect no console errors
*/
async expectNoErrors(): Promise<void> {
const events = await this.getConsole()
assertions.expectNoErrors(events)
}
/**
* Expect console event
*/
async expectConsoleEvent(pattern: string | RegExp): Promise<void> {
const events = await this.getConsole()
assertions.expectConsoleEvent(events, pattern)
}
/**
* Expect modal present/absent
*/
expectModal(present: boolean = true): void {
if (!this.currentSnapshot) {
throw new Error('No snapshot available. Call snapshot() first.')
}
assertions.expectModal(this.currentSnapshot, present)
}
/**
* Run test steps
*/
async run(steps: TestStep[]): Promise<StepResult[]> {
const results: StepResult[] = []
for (const step of steps) {
const start = Date.now()
const stepName = step.name || this.stepToName(step)
try {
await this.executeStep(step)
results.push({
name: stepName,
status: 'pass',
duration: Date.now() - start
})
} catch (err) {
const error = err instanceof Error ? err.message : String(err)
results.push({
name: stepName,
status: 'fail',
duration: Date.now() - start,
error
})
throw err // Stop on first failure
}
}
return results
}
/**
* Execute a single step
*/
private async executeStep(step: TestStep): Promise<void> {
if (step.goto) {
await this.goto(step.goto)
}
if (step.waitForLoad) {
await this.waitForLoad()
}
if (step.waitFor) {
await this.waitFor(step.waitFor)
}
if (step.waitMs) {
await sleep(step.waitMs)
}
if (step.click) {
await this.click(step.click)
}
if (step.type) {
await this.type(step.type.ref, step.type.text)
}
if (step.select) {
await this.select(step.select.ref, step.select.value)
}
if (step.hover) {
await this.hover(step.hover)
}
if (step.press) {
await this.press(step.press)
}
if (step.screenshot) {
await this.screenshot(step.screenshot)
}
if (step.expectVisible) {
await this.snapshot()
this.expectVisible(step.expectVisible)
}
if (step.expectText) {
await this.snapshot()
this.expectText(step.expectText.ref, step.expectText.text, { contains: step.expectText.contains })
}
if (step.expectUrl) {
await this.snapshot()
this.expectUrl(step.expectUrl)
}
if (step.expectCount) {
await this.snapshot()
this.expectCount(step.expectCount.selector, {
min: step.expectCount.min,
max: step.expectCount.max
})
}
if (step.expectNoErrors) {
await this.expectNoErrors()
}
if (step.expectConsoleEvent) {
await this.expectConsoleEvent(step.expectConsoleEvent)
}
if (step.assert) {
const snapshot = await this.snapshot()
const result = await step.assert(snapshot)
if (!result) {
throw new Error('Custom assertion failed')
}
}
}
/**
* Convert step to readable name
*/
private stepToName(step: TestStep): string {
if (step.goto) return `Navigate to ${step.goto}`
if (step.click) return `Click ${step.click}`
if (step.type) return `Type "${step.type.text}" into ${step.type.ref}`
if (step.select) return `Select "${step.select.value}" in ${step.select.ref}`
if (step.hover) return `Hover over ${step.hover}`
if (step.press) return `Press ${step.press}`
if (step.waitFor) return `Wait for ${step.waitFor}`
if (step.waitMs) return `Wait ${step.waitMs}ms`
if (step.screenshot) return `Screenshot: ${step.screenshot}`
if (step.expectVisible) return `Expect visible: ${step.expectVisible}`
if (step.expectText) return `Expect text: ${step.expectText.text}`
if (step.expectUrl) return `Expect URL`
if (step.expectNoErrors) return `Expect no console errors`
return 'Unknown step'
}
/**
* Run a test case
*/
async runTest(test: TestCase): Promise<TestResult> {
const start = Date.now()
const screenshots: string[] = []
let consoleEvents: ConsoleEvent[] = []
let steps: StepResult[] = []
let error: string | undefined
if (test.skip) {
return {
name: test.name,
status: 'skip',
duration: 0,
screenshots: [],
consoleEvents: [],
steps: []
}
}
try {
// Clear console before test
this.browser.clearConsole()
// Run test steps
steps = await this.run(test.steps)
// Collect console events
consoleEvents = await this.getConsole()
// Check for console errors if test passed
const errors = consoleEvents.filter(e => e.type === 'error')
if (errors.length > 0 && !test.knownIssue) {
return {
name: test.name,
status: 'warn',
duration: Date.now() - start,
error: `${errors.length} console error(s)`,
screenshots,
consoleEvents,
knownIssue: test.knownIssue,
steps
}
}
return {
name: test.name,
status: test.knownIssue ? 'warn' : 'pass',
duration: Date.now() - start,
screenshots,
consoleEvents,
knownIssue: test.knownIssue,
steps
}
} catch (err) {
error = err instanceof Error ? err.message : String(err)
// Take failure screenshot
try {
const screenshotPath = await this.screenshot(`failure-${test.name.replace(/\s+/g, '-')}.png`)
screenshots.push(screenshotPath)
} catch {
// Ignore screenshot errors
}
return {
name: test.name,
status: test.knownIssue ? 'warn' : 'fail',
duration: Date.now() - start,
error,
screenshots,
consoleEvents: await this.getConsole(),
knownIssue: test.knownIssue,
steps
}
}
}
/**
* Run a test suite
*/
async runSuite(suite: TestSuite): Promise<SuiteResult> {
const start = Date.now()
const baseUrl = suite.baseUrl || this.config.baseUrl
const results: TestResult[] = []
// Run beforeAll
if (suite.beforeAll) {
try {
await this.run(suite.beforeAll)
} catch (err) {
// beforeAll failed, skip all tests
for (const test of suite.tests) {
results.push({
name: test.name,
status: 'skip',
duration: 0,
error: `beforeAll failed: ${err instanceof Error ? err.message : String(err)}`,
screenshots: [],
consoleEvents: [],
steps: []
})
}
return this.createSuiteResult(suite.name, baseUrl, results, start)
}
}
// Run tests
for (const test of suite.tests) {
// Run beforeEach
if (suite.beforeEach) {
try {
await this.run(suite.beforeEach)
} catch (err) {
results.push({
name: test.name,
status: 'skip',
duration: 0,
error: `beforeEach failed: ${err instanceof Error ? err.message : String(err)}`,
screenshots: [],
consoleEvents: [],
steps: []
})
continue
}
}
// Run test
const result = await this.runTest(test)
results.push(result)
// Run afterEach
if (suite.afterEach) {
try {
await this.run(suite.afterEach)
} catch {
// Ignore afterEach errors
}
}
}
// Run afterAll
if (suite.afterAll) {
try {
await this.run(suite.afterAll)
} catch {
// Ignore afterAll errors
}
}
const suiteResult = this.createSuiteResult(suite.name, baseUrl, results, start)
this.reporter.addResult(suiteResult)
return suiteResult
}
/**
* Create suite result object
*/
private createSuiteResult(
name: string,
url: string,
tests: TestResult[],
startTime: number
): SuiteResult {
const summary = {
total: tests.length,
passed: tests.filter(t => t.status === 'pass').length,
failed: tests.filter(t => t.status === 'fail').length,
skipped: tests.filter(t => t.status === 'skip').length,
warnings: tests.filter(t => t.status === 'warn').length
}
return {
name,
url,
tests,
duration: Date.now() - startTime,
summary,
timestamp: Date.now()
}
}
/**
* Generate report
*/
async generateReport(output: string, options: { format?: 'markdown' | 'pdf' | 'json'; company?: string } = {}): Promise<string> {
return this.reporter.generate({
output,
format: options.format,
company: options.company,
includeScreenshots: true
})
}
/**
* Get reporter
*/
getReporter(): Reporter {
return this.reporter
}
/**
* Close browser
*/
async close(): Promise<void> {
await this.browser.close()
}
/**
* Get browser instance
*/
getBrowser(): Browser {
return this.browser
}
/**
* Get current snapshot
*/
getCurrentSnapshot(): Snapshot | null {
return this.currentSnapshot
}
}
/**
* Browser automation wrapper for agent-browser CLI
*/
import { spawn, execSync } from 'node:child_process'
import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import type { Snapshot, ConsoleEvent, WaitStrategy } from './types.js'
import { parseSnapshot } from './utils/snapshot.js'
import { ConsoleMonitor } from './utils/console.js'
import { sleep, retry, waitStrategyToArgs } from './utils/wait.js'
export interface BrowserOptions {
cdpPort?: number
headless?: boolean
timeout?: number
screenshotDir?: string
waitStrategy?: WaitStrategy
verbose?: boolean
}
export class Browser {
private cdpPort: number
private headless: boolean
private timeout: number
private screenshotDir: string
private waitStrategy: WaitStrategy
private verbose: boolean
private consoleMonitor: ConsoleMonitor
private currentUrl: string = ''
private launched: boolean = false
constructor(options: BrowserOptions = {}) {
this.cdpPort = options.cdpPort || 0 // 0 = auto-assign
this.headless = options.headless ?? true
this.timeout = options.timeout || 30000
this.screenshotDir = options.screenshotDir || './screenshots'
this.waitStrategy = options.waitStrategy || 'auto'
this.verbose = options.verbose || false
this.consoleMonitor = new ConsoleMonitor()
}
/**
* Execute agent-browser command
*/
private exec(args: string[], timeout?: number): string {
const cdpArgs = this.cdpPort ? ['--cdp', String(this.cdpPort)] : []
const cmd = ['agent-browser', ...cdpArgs, ...args].join(' ')
if (this.verbose) {
console.log(`[browser] ${cmd}`)
}
try {
const result = execSync(cmd, {
encoding: 'utf-8',
timeout: timeout || this.timeout,
stdio: ['pipe', 'pipe', 'pipe']
})
return result.trim()
} catch (err: any) {
const stderr = err.stderr?.toString() || ''
const stdout = err.stdout?.toString() || ''
throw new Error(`Browser command failed: ${cmd}\n${stderr || stdout}`)
}
}
/**
* Launch browser if not connected
*/
async launch(): Promise<void> {
if (this.launched || this.cdpPort) {
return
}
// Check if agent-browser is installed
try {
execSync('which agent-browser', { stdio: 'pipe' })
} catch {
throw new Error(
'agent-browser CLI not found. Install it with: npm install -g agent-browser'
)
}
// Launch browser and get CDP port
const args = this.headless ? ['--headless'] : []
const output = this.exec(['launch', ...args])
// Parse CDP port from output
const portMatch = output.match(/CDP port[:\s]+(\d+)/i)
if (portMatch) {
this.cdpPort = parseInt(portMatch[1], 10)
} else {
// Default port if not found
this.cdpPort = 9222
}
this.launched = true
await sleep(1000) // Wait for browser to fully start
}
/**
* Connect to existing browser
*/
connect(cdpPort: number): void {
this.cdpPort = cdpPort
}
/**
* Navigate to URL
*/
async goto(url: string): Promise<Snapshot> {
await this.launch()
const waitArgs = waitStrategyToArgs(this.waitStrategy)
this.exec(['navigate', url, ...waitArgs])
this.currentUrl = url
// Take snapshot after navigation
return this.snapshot()
}
/**
* Take accessibility tree snapshot
*/
async snapshot(): Promise<Snapshot> {
await this.launch()
const output = await retry(() => Promise.resolve(this.exec(['snapshot'])))
return parseSnapshot(output, this.currentUrl)
}
/**
* Take screenshot
*/
async screenshot(name?: string): Promise<string> {
await this.launch()
// Ensure screenshot directory exists
if (!existsSync(this.screenshotDir)) {
mkdirSync(this.screenshotDir, { recursive: true })
}
const filename = name || `screenshot-${Date.now()}.png`
const filepath = join(this.screenshotDir, filename)
this.exec(['screenshot', filepath])
return filepath
}
/**
* Click element by ref
*/
async click(ref: string): Promise<void> {
await this.launch()
await retry(() => {
this.exec(['click', ref])
return Promise.resolve()
})
// Brief wait for UI to update
await sleep(100)
}
/**
* Type text into element
*/
async type(ref: string, text: string): Promise<void> {
await this.launch()
// Click to focus first
await this.click(ref)
// Type text
this.exec(['type', `"${text.replace(/"/g, '\\"')}"`])
}
/**
* Press key
*/
async press(key: string): Promise<void> {
await this.launch()
this.exec(['press', key])
}
/**
* Hover over element
*/
async hover(ref: string): Promise<void> {
await this.launch()
this.exec(['hover', ref])
}
/**
* Select option from dropdown
*/
async select(ref: string, value: string): Promise<void> {
await this.launch()
await this.click(ref)
await sleep(200)
// Take snapshot to find option
const snapshot = await this.snapshot()
// Look for option with matching text
for (const [key, elem] of snapshot.refs) {
if (key.startsWith('@') && elem.name.toLowerCase().includes(value.toLowerCase())) {
if (elem.role === 'option' || elem.role === 'menuitem' || elem.role === 'listitem') {
await this.click(elem.id)
return
}
}
}
throw new Error(`Option "${value}" not found in dropdown`)
}
/**
* Wait for element to appear
*/
async waitFor(selector: string, options: { timeout?: number } = {}): Promise<Snapshot> {
const timeout = options.timeout || this.timeout
const start = Date.now()
while (Date.now() - start < timeout) {
const snapshot = await this.snapshot()
// Check if element exists
if (selector.startsWith('@')) {
if (snapshot.refs.has(selector)) {
return snapshot
}
} else {
// Search by text/role
for (const [, ref] of snapshot.refs) {
if (ref.name.toLowerCase().includes(selector.toLowerCase())) {
return snapshot
}
}
}
await sleep(200)
}
throw new Error(`Timeout waiting for element: ${selector}`)
}
/**
* Wait for URL to match
*/
async waitForUrl(pattern: string | RegExp, options: { timeout?: number } = {}): Promise<void> {
const timeout = options.timeout || this.timeout
const start = Date.now()
while (Date.now() - start < timeout) {
const snapshot = await this.snapshot()
const url = snapshot.url
if (typeof pattern === 'string') {
if (url.includes(pattern)) return
} else {
if (pattern.test(url)) return
}
await sleep(200)
}
throw new Error(`Timeout waiting for URL: ${pattern}`)
}
/**
* Get console events
*/
async getConsole(): Promise<ConsoleEvent[]> {
await this.launch()
try {
const output = this.exec(['console'])
const events = this.consoleMonitor.parseAgentBrowserConsole(output)
for (const event of events) {
this.consoleMonitor.addEvent(event)
}
return this.consoleMonitor.getEvents()
} catch {
return []
}
}
/**
* Get console monitor
*/
getConsoleMonitor(): ConsoleMonitor {
return this.consoleMonitor
}
/**
* Clear console events
*/
clearConsole(): void {
this.consoleMonitor.clear()
}
/**
* Close browser
*/
async close(): Promise<void> {
if (this.launched) {
try {
this.exec(['close'])
} catch {
// Ignore close errors
}
this.launched = false
}
}
/**
* Get current URL
*/
getUrl(): string {
return this.currentUrl
}
/**
* Check if connected
*/
isConnected(): boolean {
return this.launched || this.cdpPort > 0
}
/**
* Evaluate JavaScript in page
*/
async evaluate<T>(script: string): Promise<T> {
await this.launch()
const output = this.exec(['evaluate', `"${script.replace(/"/g, '\\"')}"`])
try {
return JSON.parse(output) as T
} catch {
return output as unknown as T
}
}
}
#!/usr/bin/env node
/**
* web-qa-bot CLI
*/
import { parseArgs } from 'node:util'
import { readFileSync, existsSync } from 'node:fs'
import { resolve, extname } from 'node:path'
import { parse as parseYaml } from 'yaml'
import { QABot } from './bot.js'
import { smokeTest } from './smoke.js'
import { generateReportFromFile } from './reporter.js'
import type { TestSuite, SmokeCheck } from './types.js'
const VERSION = '0.1.0'
const HELP = `
web-qa-bot v${VERSION}
AI-powered web application QA automation
USAGE:
web-qa-bot <command> [options]
COMMANDS:
smoke <url> Run smoke tests on a URL
run <suite> Run test suite from file
report <results> Generate report from results
OPTIONS:
-o, --output <path> Output file path
-f, --format <format> Report format: markdown, pdf, json
--cdp <port> Connect to existing browser CDP port
--headless Run in headless mode (default: true)
--no-headless Run in headed mode
--timeout <ms> Default timeout in milliseconds
--verbose Enable verbose logging
-h, --help Show this help
-v, --version Show version
EXAMPLES:
# Quick smoke test
web-qa-bot smoke https://example.com
# Run test suite
web-qa-bot run ./tests/suite.yaml --output results.json
# Generate PDF report
web-qa-bot report ./results.json -o report.pdf -f pdf
# Connect to existing browser
web-qa-bot smoke https://example.com --cdp 9222
`
async function main() {
const { values, positionals } = parseArgs({
allowPositionals: true,
options: {
output: { type: 'string', short: 'o' },
format: { type: 'string', short: 'f' },
cdp: { type: 'string' },
headless: { type: 'boolean', default: true },
'no-headless': { type: 'boolean' },
timeout: { type: 'string' },
verbose: { type: 'boolean' },
help: { type: 'boolean', short: 'h' },
version: { type: 'boolean', short: 'v' },
checks: { type: 'string' },
company: { type: 'string' }
}
})
if (values.help || positionals.length === 0) {
console.log(HELP)
process.exit(0)
}
if (values.version) {
console.log(VERSION)
process.exit(0)
}
const command = positionals[0]
const target = positionals[1]
try {
switch (command) {
case 'smoke':
await runSmoke(target, values)
break
case 'run':
await runSuite(target, values)
break
case 'report':
await generateReport(target, values)
break
default:
console.error(`Unknown command: ${command}`)
console.log(HELP)
process.exit(1)
}
} catch (err) {
console.error('Error:', err instanceof Error ? err.message : err)
if (values.verbose) {
console.error(err)
}
process.exit(1)
}
}
async function runSmoke(url: string, options: Record<string, any>) {
if (!url) {
throw new Error('URL required for smoke test')
}
console.log(`Running smoke test on ${url}...`)
const checks = options.checks
? options.checks.split(',').map((c: string) => c.trim() as SmokeCheck)
: undefined
const result = await smokeTest({
url,
checks,
timeout: options.timeout ? parseInt(options.timeout, 10) : undefined,
report: !!options.output,
output: options.output
})
// Print summary
console.log('\n=== Smoke Test Results ===\n')
console.log(`URL: ${result.url}`)
console.log(`Duration: ${(result.duration / 1000).toFixed(2)}s`)
console.log()
for (const test of result.tests) {
const icon = test.status === 'pass' ? '✓' : test.status === 'fail' ? '✗' : '○'
const status = test.status.toUpperCase()
console.log(`${icon} ${test.name}: ${status}`)
if (test.error) {
console.log(` → ${test.error}`)
}
}
console.log()
console.log(`Total: ${result.summary.total} | Pass: ${result.summary.passed} | Fail: ${result.summary.failed} | Warn: ${result.summary.warnings}`)
if (options.output) {
console.log(`\nReport saved to: ${options.output}`)
}
// Exit with error if any tests failed
if (result.summary.failed > 0) {
process.exit(1)
}
}
async function runSuite(suitePath: string, options: Record<string, any>) {
if (!suitePath) {
throw new Error('Suite file path required')
}
const fullPath = resolve(suitePath)
if (!existsSync(fullPath)) {
throw new Error(`Suite file not found: ${fullPath}`)
}
console.log(`Running test suite: ${suitePath}`)
// Load suite file
const content = readFileSync(fullPath, 'utf-8')
const ext = extname(fullPath).toLowerCase()
let suite: TestSuite
if (ext === '.yaml' || ext === '.yml') {
suite = parseYaml(content) as TestSuite
} else if (ext === '.json') {
suite = JSON.parse(content) as TestSuite
} else {
throw new Error(`Unsupported file format: ${ext}`)
}
// Create QA bot
const qa = new QABot({
baseUrl: suite.baseUrl || 'http://localhost',
cdpPort: options.cdp ? parseInt(options.cdp, 10) : undefined,
headless: !options['no-headless'],
timeout: options.timeout ? parseInt(options.timeout, 10) : undefined,
verbose: options.verbose
})
try {
const result = await qa.runSuite(suite)
// Print results
console.log('\n=== Test Results ===\n')
console.log(`Suite: ${result.name}`)
console.log(`Duration: ${(result.duration / 1000).toFixed(2)}s`)
console.log()
for (const test of result.tests) {
const icon = test.status === 'pass' ? '✓' : test.status === 'fail' ? '✗' : test.status === 'warn' ? '⚠' : '○'
console.log(`${icon} ${test.name}`)
if (test.error) {
console.log(` → ${test.error}`)
}
}
console.log()
console.log(`Total: ${result.summary.total} | Pass: ${result.summary.passed} | Fail: ${result.summary.failed} | Warn: ${result.summary.warnings}`)
// Generate report if requested
if (options.output) {
const format = options.format || (options.output.endsWith('.pdf') ? 'pdf' : 'markdown')
await qa.generateReport(options.output, { format, company: options.company })
console.log(`\nReport saved to: ${options.output}`)
}
if (result.summary.failed > 0) {
process.exit(1)
}
} finally {
await qa.close()
}
}
async function generateReport(resultsPath: string, options: Record<string, any>) {
if (!resultsPath) {
throw new Error('Results file path required')
}
const output = options.output || resultsPath.replace(/\.json$/, '.md')
const format = options.format || (output.endsWith('.pdf') ? 'pdf' : output.endsWith('.json') ? 'json' : 'markdown')
console.log(`Generating ${format} report...`)
const reportPath = await generateReportFromFile(resultsPath, {
output,
format: format as any,
company: options.company,
includeScreenshots: true
})
console.log(`Report saved to: ${reportPath}`)
}
main().catch(err => {
console.error('Fatal error:', err)
process.exit(1)
})
/**
* web-qa-bot - AI-powered web application QA automation
*
* @packageDocumentation
*/
// Main exports
export { QABot } from './bot.js'
export { Browser } from './browser.js'
export { Reporter, generateReportFromFile, formatDuration } from './reporter.js'
export { smokeTest } from './smoke.js'
// Assertions
export {
AssertionError,
expectVisible,
expectNotVisible,
expectText,
expectUrl,
expectCount,
expectNoErrors,
expectConsoleEvent,
expectState,
expectModal,
expectTitle,
expectClickable,
softExpect
} from './assertions.js'
// Utilities
export {
waitFor,
retry,
sleep,
waitStrategyToArgs,
isPageLoading,
waitForStableSnapshot
} from './utils/wait.js'
export {
ConsoleMonitor,
filterBySeverity,
categorizeError
} from './utils/console.js'
export {
parseSnapshot,
findByRole,
findAllByRole,
findByText,
elementExists,
resolveRef,
detectStaleRefs,
detectModals,
getInteractiveElements,
diffSnapshots
} from './utils/snapshot.js'
// Types
export type {
QABotConfig,
WaitStrategy,
TestStep,
TestCase,
TestSuite,
Snapshot,
ElementRef,
ConsoleEvent,
TestStatus,
TestResult,
StepResult,
SuiteResult,
ReportOptions,
SmokeTestOptions,
SmokeCheck
} from './types.js'
/**
* Report generation for QA results
*/
import { execSync } from 'node:child_process'
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { join, dirname } from 'node:path'
import type { SuiteResult, TestResult, ReportOptions, ConsoleEvent } from './types.js'
export class Reporter {
private results: SuiteResult[] = []
/**
* Add suite result
*/
addResult(result: SuiteResult): void {
this.results.push(result)
}
/**
* Generate report in specified format
*/
async generate(options: ReportOptions): Promise<string> {
const format = options.format || this.inferFormat(options.output)
switch (format) {
case 'markdown':
return this.generateMarkdown(options)
case 'pdf':
return this.generatePdf(options)
case 'json':
return this.generateJson(options)
default:
throw new Error(`Unknown format: ${format}`)
}
}
/**
* Infer format from file extension
*/
private inferFormat(output: string): ReportOptions['format'] {
if (output.endsWith('.pdf')) return 'pdf'
if (output.endsWith('.json')) return 'json'
return 'markdown'
}
/**
* Generate markdown report
*/
private generateMarkdown(options: ReportOptions): string {
const title = options.title || 'QA Test Report'
const timestamp = new Date().toISOString()
let md = `# ${title}\n\n`
md += `**Generated:** ${timestamp}\n\n`
// Overall summary
const totals = this.calculateTotals()
md += `## Summary\n\n`
md += `| Metric | Value |\n`
md += `|--------|-------|\n`
md += `| Total Tests | ${totals.total} |\n`
md += `| Passed | ${totals.passed} |\n`
md += `| Failed | ${totals.failed} |\n`
md += `| Skipped | ${totals.skipped} |\n`
md += `| Warnings | ${totals.warnings} |\n`
md += `| Pass Rate | ${totals.total > 0 ? ((totals.passed / totals.total) * 100).toFixed(1) : 0}% |\n\n`
// Suite results
for (const suite of this.results) {
md += `## ${suite.name}\n\n`
md += `**URL:** ${suite.url}\n`
md += `**Duration:** ${(suite.duration / 1000).toFixed(2)}s\n\n`
// Test results table
md += `| Test | Status | Duration | Notes |\n`
md += `|------|--------|----------|-------|\n`
for (const test of suite.tests) {
const status = this.statusEmoji(test.status)
const duration = `${test.duration}ms`
const notes = test.knownIssue
? `Known issue: ${test.knownIssue}`
: test.error
? test.error.slice(0, 50)
: ''
md += `| ${test.name} | ${status} | ${duration} | ${notes} |\n`
}
md += '\n'
// Failed tests details
const failed = suite.tests.filter(t => t.status === 'fail')
if (failed.length > 0) {
md += `### Failed Tests\n\n`
for (const test of failed) {
md += `#### ${test.name}\n\n`
if (test.error) {
md += `**Error:** ${test.error}\n\n`
}
// Step details
if (test.steps.length > 0) {
md += `**Steps:**\n\n`
for (const step of test.steps) {
const icon = step.status === 'pass' ? '✓' : step.status === 'fail' ? '✗' : '○'
md += `${icon} ${step.name}`
if (step.error) {
md += ` - ${step.error}`
}
md += '\n'
}
md += '\n'
}
// Console errors
const errors = test.consoleEvents.filter(e => e.type === 'error')
if (errors.length > 0) {
md += `**Console Errors:**\n\n`
for (const error of errors.slice(0, 5)) {
md += `- \`${error.text}\`\n`
}
if (errors.length > 5) {
md += `- ... and ${errors.length - 5} more\n`
}
md += '\n'
}
// Screenshots
if (options.includeScreenshots && test.screenshots.length > 0) {
md += `**Screenshots:**\n\n`
for (const screenshot of test.screenshots) {
md += `\n`
}
md += '\n'
}
}
}
// Console summary
const allErrors = suite.tests.flatMap(t => t.consoleEvents.filter(e => e.type === 'error'))
if (allErrors.length > 0) {
md += `### Console Errors Summary\n\n`
const unique = [...new Set(allErrors.map(e => e.text))]
for (const error of unique.slice(0, 10)) {
md += `- \`${error}\`\n`
}
if (unique.length > 10) {
md += `- ... and ${unique.length - 10} more unique errors\n`
}
md += '\n'
}
}
// Write to file
writeFileSync(options.output, md)
return options.output
}
/**
* Generate PDF report using ai-pdf-builder
*/
private generatePdf(options: ReportOptions): string {
// First generate markdown
const mdPath = options.output.replace('.pdf', '.md')
this.generateMarkdown({ ...options, output: mdPath })
// Convert to PDF using ai-pdf-builder
const company = options.company || 'QA Report'
try {
execSync(
`npx ai-pdf-builder generate report "${mdPath}" -o "${options.output}" --company "${company}"`,
{ stdio: 'pipe' }
)
return options.output
} catch (err: any) {
// Fallback: just return the markdown
console.warn('PDF generation failed, falling back to markdown:', err.message)
return mdPath
}
}
/**
* Generate JSON report
*/
private generateJson(options: ReportOptions): string {
const report = {
title: options.title || 'QA Test Report',
timestamp: new Date().toISOString(),
summary: this.calculateTotals(),
suites: this.results
}
writeFileSync(options.output, JSON.stringify(report, null, 2))
return options.output
}
/**
* Calculate totals across all suites
*/
private calculateTotals(): {
total: number
passed: number
failed: number
skipped: number
warnings: number
} {
return this.results.reduce(
(acc, suite) => ({
total: acc.total + suite.summary.total,
passed: acc.passed + suite.summary.passed,
failed: acc.failed + suite.summary.failed,
skipped: acc.skipped + suite.summary.skipped,
warnings: acc.warnings + suite.summary.warnings
}),
{ total: 0, passed: 0, failed: 0, skipped: 0, warnings: 0 }
)
}
/**
* Get status emoji
*/
private statusEmoji(status: string): string {
switch (status) {
case 'pass': return 'PASS'
case 'fail': return 'FAIL'
case 'skip': return 'SKIP'
case 'warn': return 'WARN'
default: return status
}
}
/**
* Get results
*/
getResults(): SuiteResult[] {
return this.results
}
/**
* Clear results
*/
clear(): void {
this.results = []
}
}
/**
* Format duration for display
*/
export function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`
if (ms < 60000) return `${(ms / 1000).toFixed(2)}s`
return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`
}
/**
* Quick report from results file
*/
export async function generateReportFromFile(
resultsPath: string,
options: ReportOptions
): Promise<string> {
const data = JSON.parse(readFileSync(resultsPath, 'utf-8'))
const reporter = new Reporter()
for (const suite of data.suites || [data]) {
reporter.addResult(suite)
}
return reporter.generate(options)
}
/**
* Smoke test functionality
*/
import { QABot } from './bot.js'
import type { SmokeTestOptions, SmokeCheck, TestResult, SuiteResult } from './types.js'
import { findAllByRole, getInteractiveElements } from './utils/snapshot.js'
const DEFAULT_CHECKS: SmokeCheck[] = [
'pageLoad',
'consoleErrors',
'navigation',
'images'
]
/**
* Run smoke tests on a URL
*/
export async function smokeTest(options: SmokeTestOptions): Promise<SuiteResult> {
const checks = options.checks || DEFAULT_CHECKS
const qa = new QABot({
baseUrl: options.url,
timeout: options.timeout || 30000,
headless: true
})
const results: TestResult[] = []
const start = Date.now()
try {
// Navigate to URL
await qa.goto(options.url)
// Run each check
for (const check of checks) {
const result = await runCheck(qa, check)
results.push(result)
}
} catch (err) {
results.push({
name: 'Initial Load',
status: 'fail',
duration: Date.now() - start,
error: err instanceof Error ? err.message : String(err),
screenshots: [],
consoleEvents: [],
steps: []
})
} finally {
await qa.close()
}
const summary = {
total: results.length,
passed: results.filter(r => r.status === 'pass').length,
failed: results.filter(r => r.status === 'fail').length,
skipped: results.filter(r => r.status === 'skip').length,
warnings: results.filter(r => r.status === 'warn').length
}
const suiteResult: SuiteResult = {
name: 'Smoke Test',
url: options.url,
tests: results,
duration: Date.now() - start,
summary,
timestamp: Date.now()
}
// Generate report if requested
if (options.report && options.output) {
qa.getReporter().addResult(suiteResult)
await qa.generateReport(options.output)
}
return suiteResult
}
/**
* Run individual check
*/
async function runCheck(qa: QABot, check: SmokeCheck): Promise<TestResult> {
const start = Date.now()
switch (check) {
case 'pageLoad':
return checkPageLoad(qa, start)
case 'consoleErrors':
return checkConsoleErrors(qa, start)
case 'navigation':
return checkNavigation(qa, start)
case 'images':
return checkImages(qa, start)
case 'forms':
return checkForms(qa, start)
case 'brokenLinks':
return checkBrokenLinks(qa, start)
case 'accessibility':
return checkAccessibility(qa, start)
case 'performance':
return checkPerformance(qa, start)
default:
return {
name: check,
status: 'skip',
duration: Date.now() - start,
error: `Unknown check: ${check}`,
screenshots: [],
consoleEvents: [],
steps: []
}
}
}
/**
* Check page loads successfully
*/
async function checkPageLoad(qa: QABot, start: number): Promise<TestResult> {
try {
const snapshot = await qa.snapshot()
// Check for basic page content
if (snapshot.tree.length < 100) {
return {
name: 'Page Load',
status: 'warn',
duration: Date.now() - start,
error: 'Page appears to have minimal content',
screenshots: [],
consoleEvents: [],
steps: []
}
}
// Check title exists
if (!snapshot.title) {
return {
name: 'Page Load',
status: 'warn',
duration: Date.now() - start,
error: 'Page has no title',
screenshots: [],
consoleEvents: [],
steps: []
}
}
return {
name: 'Page Load',
status: 'pass',
duration: Date.now() - start,
screenshots: [],
consoleEvents: [],
steps: [{ name: `Title: ${snapshot.title}`, status: 'pass', duration: 0 }]
}
} catch (err) {
return {
name: 'Page Load',
status: 'fail',
duration: Date.now() - start,
error: err instanceof Error ? err.message : String(err),
screenshots: [],
consoleEvents: [],
steps: []
}
}
}
/**
* Check for console errors
*/
async function checkConsoleErrors(qa: QABot, start: number): Promise<TestResult> {
try {
const events = await qa.getConsole()
const errors = events.filter(e => e.type === 'error')
const warnings = events.filter(e => e.type === 'warn')
if (errors.length > 0) {
return {
name: 'Console Errors',
status: 'fail',
duration: Date.now() - start,
error: `${errors.length} error(s): ${errors[0]?.text}`,
screenshots: [],
consoleEvents: events,
steps: errors.map(e => ({ name: e.text, status: 'fail' as const, duration: 0 }))
}
}
if (warnings.length > 5) {
return {
name: 'Console Errors',
status: 'warn',
duration: Date.now() - start,
error: `${warnings.length} warnings`,
screenshots: [],
consoleEvents: events,
steps: []
}
}
return {
name: 'Console Errors',
status: 'pass',
duration: Date.now() - start,
screenshots: [],
consoleEvents: events,
steps: []
}
} catch (err) {
return {
name: 'Console Errors',
status: 'warn',
duration: Date.now() - start,
error: 'Could not check console',
screenshots: [],
consoleEvents: [],
steps: []
}
}
}
/**
* Check navigation elements exist
*/
async function checkNavigation(qa: QABot, start: number): Promise<TestResult> {
try {
const snapshot = await qa.snapshot()
const links = findAllByRole(snapshot, 'link')
const buttons = findAllByRole(snapshot, 'button')
const navElements = links.length + buttons.length
if (navElements === 0) {
return {
name: 'Navigation',
status: 'warn',
duration: Date.now() - start,
error: 'No navigation elements found',
screenshots: [],
consoleEvents: [],
steps: []
}
}
return {
name: 'Navigation',
status: 'pass',
duration: Date.now() - start,
screenshots: [],
consoleEvents: [],
steps: [
{ name: `Found ${links.length} links`, status: 'pass', duration: 0 },
{ name: `Found ${buttons.length} buttons`, status: 'pass', duration: 0 }
]
}
} catch (err) {
return {
name: 'Navigation',
status: 'fail',
duration: Date.now() - start,
error: err instanceof Error ? err.message : String(err),
screenshots: [],
consoleEvents: [],
steps: []
}
}
}
/**
* Check images have alt text
*/
async function checkImages(qa: QABot, start: number): Promise<TestResult> {
try {
const snapshot = await qa.snapshot()
const images = findAllByRole(snapshot, 'img')
if (images.length === 0) {
return {
name: 'Images',
status: 'pass',
duration: Date.now() - start,
screenshots: [],
consoleEvents: [],
steps: [{ name: 'No images found', status: 'pass', duration: 0 }]
}
}
const missingAlt = images.filter(img => !img.name || img.name === 'image')
if (missingAlt.length > 0) {
return {
name: 'Images',
status: 'warn',
duration: Date.now() - start,
error: `${missingAlt.length} image(s) missing alt text`,
screenshots: [],
consoleEvents: [],
steps: []
}
}
return {
name: 'Images',
status: 'pass',
duration: Date.now() - start,
screenshots: [],
consoleEvents: [],
steps: [{ name: `All ${images.length} images have alt text`, status: 'pass', duration: 0 }]
}
} catch (err) {
return {
name: 'Images',
status: 'fail',
duration: Date.now() - start,
error: err instanceof Error ? err.message : String(err),
screenshots: [],
consoleEvents: [],
steps: []
}
}
}
/**
* Check forms are accessible
*/
async function checkForms(qa: QABot, start: number): Promise<TestResult> {
try {
const snapshot = await qa.snapshot()
const textboxes = findAllByRole(snapshot, 'textbox')
const checkboxes = findAllByRole(snapshot, 'checkbox')
const radios = findAllByRole(snapshot, 'radio')
const buttons = findAllByRole(snapshot, 'button')
const formElements = textboxes.length + checkboxes.length + radios.length
if (formElements === 0) {
return {
name: 'Forms',
status: 'pass',
duration: Date.now() - start,
screenshots: [],
consoleEvents: [],
steps: [{ name: 'No form elements found', status: 'pass', duration: 0 }]
}
}
// Check for labels
const unlabeled = [...textboxes, ...checkboxes, ...radios].filter(el => !el.name)
if (unlabeled.length > 0) {
return {
name: 'Forms',
status: 'warn',
duration: Date.now() - start,
error: `${unlabeled.length} form element(s) missing labels`,
screenshots: [],
consoleEvents: [],
steps: []
}
}
return {
name: 'Forms',
status: 'pass',
duration: Date.now() - start,
screenshots: [],
consoleEvents: [],
steps: [{ name: `${formElements} form elements properly labeled`, status: 'pass', duration: 0 }]
}
} catch (err) {
return {
name: 'Forms',
status: 'fail',
duration: Date.now() - start,
error: err instanceof Error ? err.message : String(err),
screenshots: [],
consoleEvents: [],
steps: []
}
}
}
/**
* Check for broken links (basic check)
*/
async function checkBrokenLinks(qa: QABot, start: number): Promise<TestResult> {
// This is a basic check - would need network monitoring for full implementation
return {
name: 'Broken Links',
status: 'skip',
duration: Date.now() - start,
error: 'Full link checking requires network monitoring',
screenshots: [],
consoleEvents: [],
steps: []
}
}
/**
* Check basic accessibility
*/
async function checkAccessibility(qa: QABot, start: number): Promise<TestResult> {
try {
const snapshot = await qa.snapshot()
const issues: string[] = []
// Check for heading hierarchy
const headings = [
findAllByRole(snapshot, 'heading'),
].flat()
if (headings.length === 0) {
issues.push('No headings found')
}
// Check for landmarks
const landmarks = [
findAllByRole(snapshot, 'main'),
findAllByRole(snapshot, 'navigation'),
findAllByRole(snapshot, 'banner'),
findAllByRole(snapshot, 'contentinfo')
].flat()
if (landmarks.length === 0) {
issues.push('No landmark regions found')
}
// Check interactive elements
const interactive = getInteractiveElements(snapshot)
const unlabeled = interactive.filter(el => !el.name)
if (unlabeled.length > 0) {
issues.push(`${unlabeled.length} unlabeled interactive element(s)`)
}
if (issues.length > 0) {
return {
name: 'Accessibility',
status: 'warn',
duration: Date.now() - start,
error: issues.join('; '),
screenshots: [],
consoleEvents: [],
steps: issues.map(i => ({ name: i, status: 'warn' as const, duration: 0 }))
}
}
return {
name: 'Accessibility',
status: 'pass',
duration: Date.now() - start,
screenshots: [],
consoleEvents: [],
steps: [
{ name: `${headings.length} headings`, status: 'pass', duration: 0 },
{ name: `${landmarks.length} landmarks`, status: 'pass', duration: 0 },
{ name: `${interactive.length} interactive elements labeled`, status: 'pass', duration: 0 }
]
}
} catch (err) {
return {
name: 'Accessibility',
status: 'fail',
duration: Date.now() - start,
error: err instanceof Error ? err.message : String(err),
screenshots: [],
consoleEvents: [],
steps: []
}
}
}
/**
* Check basic performance metrics
*/
async function checkPerformance(qa: QABot, start: number): Promise<TestResult> {
const loadTime = Date.now() - start
if (loadTime > 10000) {
return {
name: 'Performance',
status: 'fail',
duration: loadTime,
error: `Page load took ${loadTime}ms (>10s)`,
screenshots: [],
consoleEvents: [],
steps: []
}
}
if (loadTime > 5000) {
return {
name: 'Performance',
status: 'warn',
duration: loadTime,
error: `Page load took ${loadTime}ms (>5s)`,
screenshots: [],
consoleEvents: [],
steps: []
}
}
return {
name: 'Performance',
status: 'pass',
duration: loadTime,
screenshots: [],
consoleEvents: [],
steps: [{ name: `Load time: ${loadTime}ms`, status: 'pass', duration: 0 }]
}
}
/**
* web-qa-bot - Type definitions
*/
export interface QABotConfig {
/** Base URL for the application under test */
baseUrl: string
/** CDP port for browser connection (default: auto-detect or launch) */
cdpPort?: number
/** Run in headless mode (default: true) */
headless?: boolean
/** Directory for screenshots (default: ./screenshots) */
screenshotDir?: string
/** Default timeout in ms (default: 30000) */
timeout?: number
/** Retry attempts for flaky operations (default: 3) */
retries?: number
/** Wait strategy: 'auto' | 'networkidle' | 'domcontentloaded' | 'none' */
waitStrategy?: WaitStrategy
/** Enable console monitoring (default: true) */
monitorConsole?: boolean
/** Verbose logging (default: false) */
verbose?: boolean
}
export type WaitStrategy = 'auto' | 'networkidle' | 'domcontentloaded' | 'none'
export interface TestStep {
/** Step description */
name?: string
/** Navigation */
goto?: string
/** Wait for load state */
waitForLoad?: boolean
/** Wait for selector */
waitFor?: string
/** Wait for timeout in ms */
waitMs?: number
/** Click element by ref */
click?: string
/** Type text into element */
type?: { ref: string; text: string }
/** Select option */
select?: { ref: string; value: string }
/** Hover over element */
hover?: string
/** Press key */
press?: string
/** Take screenshot */
screenshot?: string
/** Expect element visible */
expectVisible?: string
/** Expect element text */
expectText?: { ref: string; text: string; contains?: boolean }
/** Expect URL */
expectUrl?: { url?: string; contains?: string }
/** Expect element count */
expectCount?: { selector: string; count: number; min?: number; max?: number }
/** Expect no console errors */
expectNoErrors?: boolean
/** Expect console event pattern */
expectConsoleEvent?: string | RegExp
/** Custom assertion function */
assert?: (snapshot: Snapshot) => boolean | Promise<boolean>
}
export interface TestCase {
/** Test name */
name: string
/** Test steps */
steps: TestStep[]
/** Skip this test */
skip?: boolean
/** Only run this test */
only?: boolean
/** Known issue ID (still runs, but marked) */
knownIssue?: string
/** Tags for filtering */
tags?: string[]
}
export interface TestSuite {
/** Suite name */
name: string
/** Base URL (overrides config) */
baseUrl?: string
/** Setup steps before each test */
beforeEach?: TestStep[]
/** Teardown steps after each test */
afterEach?: TestStep[]
/** Setup steps before all tests */
beforeAll?: TestStep[]
/** Teardown steps after all tests */
afterAll?: TestStep[]
/** Test cases */
tests: TestCase[]
}
export interface Snapshot {
/** Accessibility tree snapshot */
tree: string
/** Element refs map */
refs: Map<string, ElementRef>
/** Current URL */
url: string
/** Page title */
title: string
/** Console events since last snapshot */
consoleEvents: ConsoleEvent[]
/** Timestamp */
timestamp: number
}
export interface ElementRef {
/** Element ref ID (e.g., @e42) */
id: string
/** Element role */
role: string
/** Element name/label */
name: string
/** Element text content */
text?: string
/** Element state */
state?: {
disabled?: boolean
checked?: boolean
selected?: boolean
expanded?: boolean
pressed?: boolean
}
}
export interface ConsoleEvent {
/** Event type */
type: 'log' | 'warn' | 'error' | 'info' | 'debug'
/** Message text */
text: string
/** Timestamp */
timestamp: number
/** Source URL */
source?: string
/** Line number */
line?: number
}
export type TestStatus = 'pass' | 'fail' | 'skip' | 'warn'
export interface TestResult {
/** Test name */
name: string
/** Test status */
status: TestStatus
/** Duration in ms */
duration: number
/** Error message if failed */
error?: string
/** Screenshots taken */
screenshots: string[]
/** Console events during test */
consoleEvents: ConsoleEvent[]
/** Known issue ID if applicable */
knownIssue?: string
/** Step results */
steps: StepResult[]
}
export interface StepResult {
/** Step name or action */
name: string
/** Step status */
status: TestStatus
/** Duration in ms */
duration: number
/** Error message if failed */
error?: string
/** Screenshot if taken */
screenshot?: string
}
export interface SuiteResult {
/** Suite name */
name: string
/** URL tested */
url: string
/** Test results */
tests: TestResult[]
/** Total duration in ms */
duration: number
/** Summary stats */
summary: {
total: number
passed: number
failed: number
skipped: number
warnings: number
}
/** Timestamp */
timestamp: number
}
export interface ReportOptions {
/** Output file path */
output: string
/** Report format: 'markdown' | 'pdf' | 'json' */
format?: 'markdown' | 'pdf' | 'json'
/** Include screenshots in report */
includeScreenshots?: boolean
/** Company name for PDF header */
company?: string
/** Report title */
title?: string
}
export interface SmokeTestOptions {
/** URL to test */
url: string
/** Checks to perform */
checks?: SmokeCheck[]
/** Timeout in ms */
timeout?: number
/** Generate report */
report?: boolean
/** Output path */
output?: string
}
export type SmokeCheck =
| 'pageLoad'
| 'consoleErrors'
| 'brokenLinks'
| 'images'
| 'forms'
| 'navigation'
| 'accessibility'
| 'performance'
/**
* Console monitoring utilities
*/
import type { ConsoleEvent } from '../types.js'
export class ConsoleMonitor {
private events: ConsoleEvent[] = []
private patterns: Map<string, RegExp> = new Map()
private matchedPatterns: Set<string> = new Set()
/**
* Add a console event
*/
addEvent(event: ConsoleEvent): void {
this.events.push(event)
// Check against registered patterns
for (const [name, pattern] of this.patterns) {
if (pattern.test(event.text)) {
this.matchedPatterns.add(name)
}
}
}
/**
* Parse console output from agent-browser
*/
parseAgentBrowserConsole(output: string): ConsoleEvent[] {
const events: ConsoleEvent[] = []
const lines = output.split('\n')
for (const line of lines) {
// Format: [type] message (source:line)
const match = line.match(/^\[(\w+)\]\s+(.+?)(?:\s+\((.+?):(\d+)\))?$/)
if (match) {
events.push({
type: match[1].toLowerCase() as ConsoleEvent['type'],
text: match[2],
timestamp: Date.now(),
source: match[3],
line: match[4] ? parseInt(match[4], 10) : undefined
})
} else if (line.trim()) {
// Unknown format, treat as log
events.push({
type: 'log',
text: line.trim(),
timestamp: Date.now()
})
}
}
return events
}
/**
* Register a pattern to watch for
*/
watchFor(name: string, pattern: string | RegExp): void {
this.patterns.set(name, typeof pattern === 'string' ? new RegExp(pattern) : pattern)
}
/**
* Check if a pattern was matched
*/
wasMatched(name: string): boolean {
return this.matchedPatterns.has(name)
}
/**
* Get all events
*/
getEvents(): ConsoleEvent[] {
return [...this.events]
}
/**
* Get events since a timestamp
*/
getEventsSince(timestamp: number): ConsoleEvent[] {
return this.events.filter(e => e.timestamp >= timestamp)
}
/**
* Get errors only
*/
getErrors(): ConsoleEvent[] {
return this.events.filter(e => e.type === 'error')
}
/**
* Get warnings only
*/
getWarnings(): ConsoleEvent[] {
return this.events.filter(e => e.type === 'warn')
}
/**
* Check if there are any errors
*/
hasErrors(): boolean {
return this.events.some(e => e.type === 'error')
}
/**
* Clear all events
*/
clear(): void {
this.events = []
this.matchedPatterns.clear()
}
/**
* Format events for report
*/
formatForReport(): string {
if (this.events.length === 0) {
return 'No console events captured.'
}
const grouped = {
error: this.getErrors(),
warn: this.getWarnings(),
log: this.events.filter(e => e.type === 'log' || e.type === 'info')
}
let output = ''
if (grouped.error.length > 0) {
output += `### Errors (${grouped.error.length})\n\n`
for (const event of grouped.error) {
output += `- \`${event.text}\``
if (event.source) {
output += ` (${event.source}:${event.line})`
}
output += '\n'
}
output += '\n'
}
if (grouped.warn.length > 0) {
output += `### Warnings (${grouped.warn.length})\n\n`
for (const event of grouped.warn) {
output += `- \`${event.text}\`\n`
}
output += '\n'
}
return output
}
}
/**
* Filter console events by severity
*/
export function filterBySeverity(
events: ConsoleEvent[],
minSeverity: 'error' | 'warn' | 'info' | 'log' | 'debug'
): ConsoleEvent[] {
const severityOrder = ['debug', 'log', 'info', 'warn', 'error']
const minIndex = severityOrder.indexOf(minSeverity)
return events.filter(e => severityOrder.indexOf(e.type) >= minIndex)
}
/**
* Detect common error patterns
*/
export function categorizeError(text: string): string {
const patterns: [RegExp, string][] = [
[/CORS/i, 'cors'],
[/404|not found/i, 'not-found'],
[/500|internal server/i, 'server-error'],
[/network|fetch|xhr/i, 'network'],
[/undefined|null|TypeError/i, 'runtime'],
[/hydration/i, 'hydration'],
[/chunk|module/i, 'loading'],
[/websocket|socket/i, 'websocket']
]
for (const [pattern, category] of patterns) {
if (pattern.test(text)) {
return category
}
}
return 'unknown'
}
/**
* Snapshot and element detection utilities
*/
import type { ElementRef, Snapshot } from '../types.js'
/**
* Parse accessibility tree snapshot from agent-browser
*/
export function parseSnapshot(output: string, url: string): Snapshot {
const refs = new Map<string, ElementRef>()
const lines = output.split('\n')
// Parse element refs from accessibility tree
// Format: @e42 button "Submit"
const refPattern = /(@e\d+)\s+(\w+)\s+"([^"]*)"(?:\s+(.+))?/
for (const line of lines) {
const match = line.match(refPattern)
if (match) {
const [, id, role, name, stateStr] = match
const ref: ElementRef = { id, role, name }
if (stateStr) {
ref.state = parseState(stateStr)
}
refs.set(id, ref)
// Also index by role:name for easier lookup
refs.set(`${role.toLowerCase()}:${name.toLowerCase()}`, ref)
}
}
// Extract title if present
let title = ''
const titleMatch = output.match(/document\s+"([^"]+)"/)
if (titleMatch) {
title = titleMatch[1]
}
return {
tree: output,
refs,
url,
title,
consoleEvents: [],
timestamp: Date.now()
}
}
/**
* Parse element state from snapshot
*/
function parseState(stateStr: string): ElementRef['state'] {
const state: ElementRef['state'] = {}
if (stateStr.includes('disabled')) state.disabled = true
if (stateStr.includes('checked')) state.checked = true
if (stateStr.includes('selected')) state.selected = true
if (stateStr.includes('expanded')) state.expanded = true
if (stateStr.includes('pressed')) state.pressed = true
return state
}
/**
* Find element by role and name
*/
export function findByRole(
snapshot: Snapshot,
role: string,
name?: string
): ElementRef | undefined {
for (const [, ref] of snapshot.refs) {
if (ref.role.toLowerCase() === role.toLowerCase()) {
if (!name || ref.name.toLowerCase().includes(name.toLowerCase())) {
return ref
}
}
}
return undefined
}
/**
* Find all elements matching role
*/
export function findAllByRole(snapshot: Snapshot, role: string): ElementRef[] {
const results: ElementRef[] = []
for (const [key, ref] of snapshot.refs) {
// Only include direct refs, not the role:name aliases
if (key.startsWith('@') && ref.role.toLowerCase() === role.toLowerCase()) {
results.push(ref)
}
}
return results
}
/**
* Find element by text content
*/
export function findByText(
snapshot: Snapshot,
text: string,
options: { exact?: boolean } = {}
): ElementRef | undefined {
const searchText = text.toLowerCase()
for (const [key, ref] of snapshot.refs) {
if (!key.startsWith('@')) continue
const refText = ref.name.toLowerCase()
if (options.exact ? refText === searchText : refText.includes(searchText)) {
return ref
}
}
return undefined
}
/**
* Check if element exists in snapshot
*/
export function elementExists(snapshot: Snapshot, refOrSelector: string): boolean {
// Direct ref check
if (refOrSelector.startsWith('@')) {
return snapshot.refs.has(refOrSelector)
}
// Role:name format
if (refOrSelector.includes(':')) {
return snapshot.refs.has(refOrSelector.toLowerCase())
}
// Search by text
return findByText(snapshot, refOrSelector) !== undefined
}
/**
* Get ref ID from selector or return as-is if already a ref
*/
export function resolveRef(snapshot: Snapshot, selector: string): string | undefined {
// Already a ref
if (selector.startsWith('@')) {
return snapshot.refs.has(selector) ? selector : undefined
}
// Role:name format
if (selector.includes(':')) {
const ref = snapshot.refs.get(selector.toLowerCase())
return ref?.id
}
// Search by text
const found = findByText(snapshot, selector)
return found?.id
}
/**
* Detect stale refs by comparing snapshots
*/
export function detectStaleRefs(oldSnapshot: Snapshot, newSnapshot: Snapshot): string[] {
const stale: string[] = []
for (const [key] of oldSnapshot.refs) {
if (key.startsWith('@') && !newSnapshot.refs.has(key)) {
stale.push(key)
}
}
return stale
}
/**
* Detect modals/dialogs in snapshot
*/
export function detectModals(snapshot: Snapshot): ElementRef[] {
const modals: ElementRef[] = []
for (const [key, ref] of snapshot.refs) {
if (!key.startsWith('@')) continue
const isModal =
ref.role === 'dialog' ||
ref.role === 'alertdialog' ||
ref.name.toLowerCase().includes('modal') ||
ref.name.toLowerCase().includes('popup')
if (isModal) {
modals.push(ref)
}
}
return modals
}
/**
* Extract interactive elements
*/
export function getInteractiveElements(snapshot: Snapshot): ElementRef[] {
const interactive: ElementRef[] = []
const interactiveRoles = [
'button', 'link', 'textbox', 'checkbox', 'radio',
'combobox', 'listbox', 'menuitem', 'tab', 'switch'
]
for (const [key, ref] of snapshot.refs) {
if (!key.startsWith('@')) continue
if (interactiveRoles.includes(ref.role.toLowerCase())) {
interactive.push(ref)
}
}
return interactive
}
/**
* Diff two snapshots to detect changes
*/
export function diffSnapshots(
before: Snapshot,
after: Snapshot
): { added: ElementRef[]; removed: string[]; changed: string[] } {
const added: ElementRef[] = []
const removed: string[] = []
const changed: string[] = []
// Find removed and changed
for (const [key, oldRef] of before.refs) {
if (!key.startsWith('@')) continue
const newRef = after.refs.get(key)
if (!newRef) {
removed.push(key)
} else if (
oldRef.name !== newRef.name ||
JSON.stringify(oldRef.state) !== JSON.stringify(newRef.state)
) {
changed.push(key)
}
}
// Find added
for (const [key, ref] of after.refs) {
if (!key.startsWith('@')) continue
if (!before.refs.has(key)) {
added.push(ref)
}
}
return { added, removed, changed }
}
/**
* Wait utilities with retry logic
*/
import type { WaitStrategy } from '../types.js'
export interface WaitOptions {
timeout?: number
interval?: number
message?: string
}
/**
* Wait for a condition to be true
*/
export async function waitFor(
condition: () => boolean | Promise<boolean>,
options: WaitOptions = {}
): Promise<void> {
const { timeout = 30000, interval = 100, message = 'Condition not met' } = options
const start = Date.now()
while (Date.now() - start < timeout) {
try {
if (await condition()) {
return
}
} catch {
// Condition threw, keep trying
}
await sleep(interval)
}
throw new Error(`Timeout: ${message} (waited ${timeout}ms)`)
}
/**
* Wait with exponential backoff retry
*/
export async function retry<T>(
fn: () => Promise<T>,
options: { retries?: number; delay?: number; backoff?: number } = {}
): Promise<T> {
const { retries = 3, delay = 100, backoff = 2 } = options
let lastError: Error | undefined
let currentDelay = delay
for (let i = 0; i <= retries; i++) {
try {
return await fn()
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err))
if (i < retries) {
await sleep(currentDelay)
currentDelay *= backoff
}
}
}
throw lastError
}
/**
* Sleep for specified milliseconds
*/
export function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
/**
* Convert wait strategy to agent-browser args
*/
export function waitStrategyToArgs(strategy: WaitStrategy): string[] {
switch (strategy) {
case 'networkidle':
return ['--wait', 'networkidle']
case 'domcontentloaded':
return ['--wait', 'domcontentloaded']
case 'none':
return []
case 'auto':
default:
return ['--wait', 'load']
}
}
/**
* Detect if page is still loading based on snapshot
*/
export function isPageLoading(snapshotText: string): boolean {
const loadingPatterns = [
/loading/i,
/spinner/i,
/skeleton/i,
/please wait/i,
/fetching/i
]
return loadingPatterns.some(pattern => pattern.test(snapshotText))
}
/**
* Wait for page to stop loading
*/
export async function waitForStableSnapshot(
getSnapshot: () => Promise<string>,
options: { timeout?: number; stableTime?: number } = {}
): Promise<string> {
const { timeout = 10000, stableTime = 500 } = options
const start = Date.now()
let lastSnapshot = ''
let stableSince = 0
while (Date.now() - start < timeout) {
const snapshot = await getSnapshot()
if (snapshot === lastSnapshot) {
if (stableSince === 0) {
stableSince = Date.now()
} else if (Date.now() - stableSince >= stableTime) {
return snapshot
}
} else {
lastSnapshot = snapshot
stableSince = 0
}
await sleep(100)
}
return lastSnapshot
}
{{title}}
Generated: {{timestamp}} URL: {{url}}
Summary
| Metric | Value |
|---|---|
| Total Tests | {{total}} |
| Passed | {{passed}} |
| Failed | {{failed}} |
| Skipped | {{skipped}} |
| Warnings | {{warnings}} |
| Pass Rate | {{passRate}}% |
| Duration | {{duration}} |
Test Results
{{#each tests}}
{{name}}
Status: {{status}} Duration: {{duration}}ms
{{#if error}} Error: {{error}} {{/if}}
{{#if steps.length}}
Steps
{{#each steps}}
- {{#if (eq status "pass")}}✓{{else}}✗{{/if}} {{name}}{{#if error}} - {{error}}{{/if}}
{{/each}} {{/if}}
{{#if consoleEvents.length}}
Console Events
{{#each consoleEvents}}
- [{{type}}] {{text}}
{{/each}} {{/if}}
---
{{/each}}
Console Errors Summary
{{#if hasErrors}} {{#each uniqueErrors}}
{{this}}
{{/each}} {{else}} No console errors detected. {{/if}}
---
Report generated by [web-qa-bot](https://github.com/NextFrontierBuilds/web-qa-bot)
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}