by thetestingacademy
Natural language test automation methodology where tests are written as plain English instructions, leveraging AI agents to interpret intent, generate executable tests, and maintain test suites without traditional code-based selectors or assertions.
npx @qaskills/cli add vibe-testingAuto-detects your AI agent and installs the skill. Works with Claude Code, Cursor, Copilot, and more.
You are an expert in vibe testing, the methodology where tests are expressed as natural language instructions that AI agents interpret and execute. When the user asks you to implement vibe testing workflows, create natural language test specifications, or build intent-based test automation systems, follow these detailed instructions.
vibe-tests/
specs/
auth/
login.vibe.md
registration.vibe.md
password-reset.vibe.md
checkout/
add-to-cart.vibe.md
payment.vibe.md
order-confirmation.vibe.md
dashboard/
navigation.vibe.md
settings.vibe.md
engine/
interpreter.ts
action-resolver.ts
assertion-resolver.ts
element-finder.ts
step-executor.ts
runners/
vibe-runner.ts
parallel-runner.ts
ci-runner.ts
reporters/
execution-log.ts
step-trace.ts
failure-analyzer.ts
config/
vibe-config.ts
ai-config.ts
fixtures/
test-data.ts
environment.ts
<!-- specs/auth/login.vibe.md -->
# Login Flow
## Setup
- Navigate to the login page
- Ensure the page has loaded completely
## Test: Successful login with valid credentials
1. Enter "testuser@example.com" in the email field
2. Enter "SecurePassword123" in the password field
3. Click the login button
4. Verify you are redirected to the dashboard
5. Verify the welcome message contains "testuser"
## Test: Failed login with wrong password
1. Enter "testuser@example.com" in the email field
2. Enter "WrongPassword" in the password field
3. Click the login button
4. Verify an error message appears
5. Verify the error mentions invalid credentials
6. Verify you remain on the login page
## Test: Login form validation
1. Click the login button without filling any fields
2. Verify the email field shows a validation error
3. Enter "not-an-email" in the email field
4. Verify the email field shows an invalid format error
5. Clear the email field
6. Enter "valid@email.com" in the email field
7. Verify the email validation error disappears
## Cleanup
- If logged in, click the logout button
// vibe-tests/engine/interpreter.ts
import Anthropic from '@anthropic-ai/sdk';
export interface VibeStep {
raw: string;
action: 'navigate' | 'click' | 'fill' | 'select' | 'assert' | 'wait' | 'clear' | 'hover' | 'scroll';
target?: string;
value?: string;
assertion?: {
type: 'visible' | 'text' | 'url' | 'hidden' | 'enabled' | 'disabled' | 'contains';
expected?: string;
};
confidence: number;
}
export interface VibeTest {
name: string;
setup: string[];
steps: string[];
cleanup: string[];
}
export class VibeInterpreter {
private client: Anthropic;
constructor() {
this.client = new Anthropic();
}
parseSpecFile(content: string): VibeTest[] {
const tests: VibeTest[] = [];
const sections = content.split(/^## /m).filter(Boolean);
let setup: string[] = [];
let cleanup: string[] = [];
for (const section of sections) {
const lines = section.trim().split('\n');
const heading = lines[0].trim();
if (heading.toLowerCase().startsWith('setup')) {
setup = this.extractSteps(lines.slice(1));
} else if (heading.toLowerCase().startsWith('cleanup')) {
cleanup = this.extractSteps(lines.slice(1));
} else if (heading.toLowerCase().startsWith('test:')) {
const testName = heading.replace(/^test:\s*/i, '').trim();
const steps = this.extractSteps(lines.slice(1));
tests.push({ name: testName, setup: [...setup], steps, cleanup: [...cleanup] });
}
}
return tests;
}
async interpretStep(step: string, pageContext?: string): Promise<VibeStep> {
const prompt = `Interpret this natural language test step into a structured action:
Step: "${step}"
${pageContext ? `Page context: ${pageContext}` : ''}
Return JSON: {"action": "navigate|click|fill|select|assert|wait|clear|hover|scroll", "target": "description of element", "value": "value if applicable", "assertion": {"type": "visible|text|url|hidden|contains", "expected": "value"}, "confidence": 0-1}`;
const response = await this.client.messages.create({
model: 'claude-haiku-35-20241022',
max_tokens: 256,
temperature: 0,
messages: [{ role: 'user', content: prompt }],
});
const text = response.content[0].type === 'text' ? response.content[0].text : '';
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
return this.fallbackInterpret(step);
}
const parsed = JSON.parse(jsonMatch[0]);
return { raw: step, ...parsed };
}
private extractSteps(lines: string[]): string[] {
return lines
.map((line) => line.replace(/^[\d]+\.\s*/, '').replace(/^-\s*/, '').trim())
.filter((line) => line.length > 0);
}
private fallbackInterpret(step: string): VibeStep {
const lower = step.toLowerCase();
if (lower.startsWith('navigate') || lower.startsWith('go to') || lower.startsWith('open')) {
return { raw: step, action: 'navigate', target: step, confidence: 0.7 };
}
if (lower.startsWith('click') || lower.startsWith('press') || lower.startsWith('tap')) {
return { raw: step, action: 'click', target: step.replace(/^(click|press|tap)\s+(on\s+)?/i, ''), confidence: 0.7 };
}
if (lower.startsWith('enter') || lower.startsWith('type') || lower.startsWith('fill')) {
const match = step.match(/["']([^"']+)["']\s+(?:in|into)\s+(.+)/i);
return { raw: step, action: 'fill', target: match?.[2] || '', value: match?.[1] || '', confidence: 0.6 };
}
if (lower.startsWith('verify') || lower.startsWith('check') || lower.startsWith('ensure') || lower.startsWith('confirm')) {
return { raw: step, action: 'assert', target: step, assertion: { type: 'visible' }, confidence: 0.6 };
}
if (lower.startsWith('wait')) {
return { raw: step, action: 'wait', target: step, confidence: 0.7 };
}
if (lower.startsWith('clear')) {
return { raw: step, action: 'clear', target: step.replace(/^clear\s+(the\s+)?/i, ''), confidence: 0.7 };
}
return { raw: step, action: 'assert', target: step, confidence: 0.3 };
}
}
// vibe-tests/engine/element-finder.ts
import { Page, Locator } from '@playwright/test';
export interface FoundElement {
locator: Locator;
selector: string;
confidence: number;
method: 'role' | 'testid' | 'label' | 'text' | 'placeholder' | 'css';
}
export class ElementFinder {
async findElement(page: Page, description: string): Promise<FoundElement> {
const strategies: Array<() => Promise<FoundElement | null>> = [
() => this.findByRole(page, description),
() => this.findByTestId(page, description),
() => this.findByLabel(page, description),
() => this.findByText(page, description),
() => this.findByPlaceholder(page, description),
() => this.findByAccessibilityTree(page, description),
];
for (const strategy of strategies) {
const result = await strategy();
if (result && result.confidence > 0.5) {
return result;
}
}
throw new Error(`Could not find element matching: "${description}"`);
}
private async findByRole(page: Page, description: string): Promise<FoundElement | null> {
const roleMap: Record<string, string> = {
button: 'button', link: 'link', input: 'textbox', field: 'textbox',
checkbox: 'checkbox', radio: 'radio', dropdown: 'combobox', select: 'combobox',
heading: 'heading', tab: 'tab', menu: 'menu', dialog: 'dialog',
alert: 'alert', navigation: 'navigation', search: 'searchbox',
};
for (const [keyword, role] of Object.entries(roleMap)) {
if (description.toLowerCase().includes(keyword)) {
const nameMatch = description.match(/["']([^"']+)["']/);
const name = nameMatch ? nameMatch[1] : undefined;
const locator = name
? page.getByRole(role as any, { name: new RegExp(name, 'i') })
: page.getByRole(role as any);
try {
const count = await locator.count();
if (count === 1) {
return { locator, selector: `getByRole('${role}', { name: '${name || ''}' })`, confidence: 0.9, method: 'role' };
}
} catch {}
}
}
return null;
}
private async findByTestId(page: Page, description: string): Promise<FoundElement | null> {
const words = description.toLowerCase().split(/\s+/);
const possibleIds = [
words.join('-'),
words.join('_'),
words.filter((w) => !['the', 'a', 'an', 'in', 'on', 'at', 'to', 'for'].includes(w)).join('-'),
];
for (const id of possibleIds) {
const locator = page.getByTestId(id);
try {
const count = await locator.count();
if (count === 1) {
return { locator, selector: `getByTestId('${id}')`, confidence: 0.85, method: 'testid' };
}
} catch {}
}
return null;
}
private async findByLabel(page: Page, description: string): Promise<FoundElement | null> {
const labelMatch = description.match(/(?:the\s+)?["']?([^"']+?)["']?\s+(?:field|input|box)/i);
if (labelMatch) {
const locator = page.getByLabel(new RegExp(labelMatch[1], 'i'));
try {
const count = await locator.count();
if (count === 1) {
return { locator, selector: `getByLabel('${labelMatch[1]}')`, confidence: 0.8, method: 'label' };
}
} catch {}
}
return null;
}
private async findByText(page: Page, description: string): Promise<FoundElement | null> {
const textMatch = description.match(/["']([^"']+)["']/);
if (textMatch) {
const locator = page.getByText(textMatch[1], { exact: false });
try {
const count = await locator.count();
if (count === 1) {
return { locator, selector: `getByText('${textMatch[1]}')`, confidence: 0.7, method: 'text' };
}
} catch {}
}
return null;
}
private async findByPlaceholder(page: Page, description: string): Promise<FoundElement | null> {
const keywords = description.toLowerCase();
const placeholderGuesses = [
keywords.includes('email') ? 'email' : null,
keywords.includes('password') ? 'password' : null,
keywords.includes('search') ? 'search' : null,
keywords.includes('name') ? 'name' : null,
].filter(Boolean);
for (const guess of placeholderGuesses) {
const locator = page.getByPlaceholder(new RegExp(guess!, 'i'));
try {
const count = await locator.count();
if (count === 1) {
return { locator, selector: `getByPlaceholder('${guess}')`, confidence: 0.65, method: 'placeholder' };
}
} catch {}
}
return null;
}
private async findByAccessibilityTree(page: Page, description: string): Promise<FoundElement | null> {
const snapshot = await page.accessibility.snapshot();
if (!snapshot) return null;
const matches = this.searchTree(snapshot, description.toLowerCase());
if (matches.length > 0) {
const bestMatch = matches[0];
const locator = page.getByRole(bestMatch.role as any, { name: bestMatch.name });
return {
locator,
selector: `getByRole('${bestMatch.role}', { name: '${bestMatch.name}' })`,
confidence: 0.6,
method: 'role',
};
}
return null;
}
private searchTree(node: any, query: string): any[] {
const matches: any[] = [];
if (node.name && node.name.toLowerCase().includes(query)) {
matches.push(node);
}
if (node.children) {
for (const child of node.children) {
matches.push(...this.searchTree(child, query));
}
}
return matches;
}
}
// vibe-tests/runners/vibe-runner.ts
import { test, expect, Page } from '@playwright/test';
import { VibeInterpreter, VibeTest, VibeStep } from '../engine/interpreter';
import { ElementFinder } from '../engine/element-finder';
import { readFileSync } from 'fs';
export class VibeTestRunner {
private interpreter: VibeInterpreter;
private finder: ElementFinder;
private executionLog: Array<{ step: string; result: string; selector?: string; duration: number }> = [];
constructor() {
this.interpreter = new VibeInterpreter();
this.finder = new ElementFinder();
}
registerTests(specFile: string): void {
const content = readFileSync(specFile, 'utf-8');
const tests = this.interpreter.parseSpecFile(content);
for (const vibeTest of tests) {
test(vibeTest.name, async ({ page }) => {
// Execute setup steps
for (const step of vibeTest.setup) {
await this.executeStep(page, step);
}
// Execute test steps
for (const step of vibeTest.steps) {
await this.executeStep(page, step);
}
// Execute cleanup steps
for (const step of vibeTest.cleanup) {
try {
await this.executeStep(page, step);
} catch {
// Cleanup failures should not fail the test
}
}
});
}
}
private async executeStep(page: Page, step: string): Promise<void> {
const startTime = Date.now();
const interpreted = await this.interpreter.interpretStep(step);
try {
switch (interpreted.action) {
case 'navigate':
await this.executeNavigate(page, interpreted);
break;
case 'click':
await this.executeClick(page, interpreted);
break;
case 'fill':
await this.executeFill(page, interpreted);
break;
case 'assert':
await this.executeAssert(page, interpreted);
break;
case 'wait':
await this.executeWait(page, interpreted);
break;
case 'clear':
await this.executeClear(page, interpreted);
break;
case 'hover':
await this.executeHover(page, interpreted);
break;
default:
throw new Error(`Unknown action: ${interpreted.action}`);
}
this.executionLog.push({
step,
result: 'passed',
duration: Date.now() - startTime,
});
} catch (error: any) {
this.executionLog.push({
step,
result: `failed: ${error.message}`,
duration: Date.now() - startTime,
});
throw error;
}
}
private async executeNavigate(page: Page, step: VibeStep): Promise<void> {
const urlMatch = step.target?.match(/(https?:\/\/[^\s]+|\/[^\s]*)/);
if (urlMatch) {
await page.goto(urlMatch[1]);
} else if (step.target?.toLowerCase().includes('login')) {
await page.goto('/login');
} else if (step.target?.toLowerCase().includes('dashboard')) {
await page.goto('/dashboard');
} else {
await page.goto('/');
}
await page.waitForLoadState('networkidle');
}
private async executeClick(page: Page, step: VibeStep): Promise<void> {
const element = await this.finder.findElement(page, step.target || step.raw);
await element.locator.click();
}
private async executeFill(page: Page, step: VibeStep): Promise<void> {
const element = await this.finder.findElement(page, step.target || step.raw);
await element.locator.fill(step.value || '');
}
private async executeAssert(page: Page, step: VibeStep): Promise<void> {
if (step.assertion?.type === 'url') {
await expect(page).toHaveURL(new RegExp(step.assertion.expected || ''));
} else if (step.assertion?.type === 'visible' && step.target) {
const element = await this.finder.findElement(page, step.target);
await expect(element.locator).toBeVisible();
} else if (step.assertion?.type === 'hidden' && step.target) {
const element = await this.finder.findElement(page, step.target);
await expect(element.locator).toBeHidden();
} else if (step.assertion?.type === 'contains' && step.target) {
const element = await this.finder.findElement(page, step.target);
await expect(element.locator).toContainText(step.assertion.expected || '');
} else if (step.assertion?.type === 'text' && step.target) {
const element = await this.finder.findElement(page, step.target);
await expect(element.locator).toHaveText(step.assertion.expected || '');
}
}
private async executeWait(page: Page, step: VibeStep): Promise<void> {
const timeMatch = step.raw.match(/(\d+)\s*(seconds?|s|ms|milliseconds?)/i);
if (timeMatch) {
const ms = timeMatch[2].startsWith('s') ? parseInt(timeMatch[1]) * 1000 : parseInt(timeMatch[1]);
await page.waitForTimeout(ms);
} else {
await page.waitForLoadState('networkidle');
}
}
private async executeClear(page: Page, step: VibeStep): Promise<void> {
const element = await this.finder.findElement(page, step.target || step.raw);
await element.locator.clear();
}
private async executeHover(page: Page, step: VibeStep): Promise<void> {
const element = await this.finder.findElement(page, step.target || step.raw);
await element.locator.hover();
}
getExecutionLog() {
return [...this.executionLog];
}
}
- name: Install QA Skills
run: npx @qaskills/cli add vibe-testing12 of 29 agents supported