Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,18 @@ next-env.d.ts
.env.local

# include package-lock.json
!/package-lock.json
!/package-lock.json

# Playwright artifacts
playwright-report/
test-results/
screenshots/
**/*.webm
**/*.zip

# MCP artifacts
mcp-results.json

# MCP dynamically generated tests (not committed)
tests/e2e/generated/
tests/e2e/healed/
1 change: 1 addition & 0 deletions jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const customJestConfig: Config = {
testPathIgnorePatterns: [
"<rootDir>/.next/",
"<rootDir>/node_modules/",
"<rootDir>/tests/e2e/"
],
};

Expand Down
26 changes: 26 additions & 0 deletions mcp/crawler/crawler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { chromium } from 'playwright';

const INVALID_EXTENSIONS = ['.pdf', '.zip', '.png', '.jpg', '.jpeg'];

export async function discoverRoutes(baseURL: string): Promise<string[]> {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto(baseURL);

const links = await page.$$eval('a[href]', a =>
a.map(x => x.getAttribute('href') || '')
);

await browser.close();

return Array.from(
new Set(
links.filter(
href =>
href.startsWith('/') &&
!href.includes('...') &&
!INVALID_EXTENSIONS.some(ext => href.endsWith(ext))
)
)
);
}
52 changes: 52 additions & 0 deletions mcp/generator/generator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import fs from 'fs';
import path from 'path';
import { Scenario } from '../planner/planner';

function selectorToCode(selector: string): string {
if (selector === 'body') return `page.locator('body')`;

if (selector.includes('>>')) {
const [scope, inner] = selector.split('>>').map(s => s.trim());
return `page.locator('${scope}').${selectorToCode(inner).replace('page.', '')}`;
}

if (selector.startsWith('role=')) {
const m = selector.match(/role=(\w+)\[name="(.+)"\]/);
return `page.getByRole('${m![1]}', { name: '${m![2]}' })`;
}

return `page.locator('${selector}')`;
}

export function generateTest(scenario: Scenario) {
const slug = scenario.name.toLowerCase().replace(/[^a-z0-9]+/g, '-');
const outDir = path.join(process.cwd(), 'tests/e2e/generated');
fs.mkdirSync(outDir, { recursive: true });

const file = path.join(outDir, `${slug}.spec.ts`);

let code = `
import { test, expect } from '@playwright/test';

test('${scenario.name}', async ({ page }) => {
`;

for (const step of scenario.steps) {
if (step.action === 'goto') {
code += ` await page.goto('${step.value}');\n`;
}
if (step.action === 'assertVisible') {
code += ` await expect(${selectorToCode(step.value)}).toBeVisible();\n`;
}
}

code += `
await page.screenshot({
path: 'screenshots/${slug}.png',
fullPage: true
});
});
`;

fs.writeFileSync(file, code);
}
34 changes: 34 additions & 0 deletions mcp/planner/planner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import fs from 'fs';
import path from 'path';

export interface Step {
action: 'goto' | 'assertVisible';
value: string;
}

export interface Scenario {
name: string;
steps: Step[];
}

export function loadManualScenarios(): Scenario[] {
const dir = path.join(process.cwd(), 'tests/specs');
if (!fs.existsSync(dir)) return [];

return fs
.readdirSync(dir)
.filter(f => f.endsWith('.spec.json'))
.flatMap(f =>
JSON.parse(fs.readFileSync(path.join(dir, f), 'utf-8')).scenarios
);
}

export function autoScenarioForRoute(route: string): Scenario {
return {
name: `Page loads: ${route}`,
steps: [
{ action: 'goto', value: route },
{ action: 'assertVisible', value: 'body' }
]
};
}
74 changes: 74 additions & 0 deletions mcp/runner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import { discoverRoutes } from './crawler/crawler';
import { loadManualScenarios, autoScenarioForRoute } from './planner/planner';
import { generateTest } from './generator/generator';


if (!process.env.ENABLE_MCP) {
console.log('ℹ MCP disabled. Set ENABLE_MCP=true to run.');
process.exit(0);
}


function isValidRoute(route: string) {
return route.startsWith('/') && !route.includes('...') && !route.includes('.pdf');
}

async function runMCP() {
const baseURL = 'http://localhost:3000';

// cleaning old generated tests
const genDir = path.join(process.cwd(), 'tests/e2e/generated');
if (fs.existsSync(genDir)) {
fs.rmSync(genDir, { recursive: true, force: true });
}
fs.mkdirSync(genDir, { recursive: true });

// cleaning old screenshots
const screenshotsDir = path.join(process.cwd(), 'test-results/screenshots');
if (fs.existsSync(screenshotsDir)) {
fs.rmSync(screenshotsDir, { recursive: true, force: true });
}
fs.mkdirSync(screenshotsDir, { recursive: true });

console.log(' Discovering routes...');
const discovered = await discoverRoutes(baseURL);
const routes = Array.from(new Set(discovered.filter(isValidRoute)));

const manual = loadManualScenarios();
const auto = routes.map(autoScenarioForRoute);
const scenarios = [...manual, ...auto];

console.log(`Generating ${scenarios.length} tests...`);
scenarios.forEach(generateTest);

console.log('Running Playwright...');
let status = 'SUCCESS';

try {
execSync('npx playwright test', { stdio: 'inherit' });
} catch {
status = 'PARTIAL_FAILURE';
console.warn(' Some tests failed.');
}

fs.writeFileSync(
'mcp-results.json',
JSON.stringify(
{
timestamp: new Date().toISOString(),
routesTested: routes.length,
manualScenarios: manual.length,
autoScenarios: auto.length,
totalTests: scenarios.length,
status
},
null,
2
)
);
}

runMCP();
Loading