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 };
}
}
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;
}
}
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 }) => {
for (const step of vibeTest.setup) {
await this.executeStep(page, step);
}
for (const step of vibeTest.steps) {
await this.executeStep(page, step);
}
for (const step of vibeTest.cleanup) {
try {
await this.executeStep(page, step);
} catch {
}
}
});
}
}
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];
}
}