init
This commit is contained in:
280
.opencode/skills/web-testing/scripts/analyze-test-results.js
Executable file
280
.opencode/skills/web-testing/scripts/analyze-test-results.js
Executable file
@@ -0,0 +1,280 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Analyze and summarize test results from multiple formats
|
||||
* Usage: node analyze-test-results.js [options]
|
||||
*
|
||||
* Options:
|
||||
* --playwright <path> Path to Playwright JSON results
|
||||
* --vitest <path> Path to Vitest JSON results
|
||||
* --junit <path> Path to JUnit XML results
|
||||
* --output <format> Output format: text, json, markdown (default: text)
|
||||
* --fail-threshold <n> Exit with code 1 if pass rate below n% (default: 0)
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Parse arguments
|
||||
const args = process.argv.slice(2);
|
||||
function getArg(name) {
|
||||
const index = args.indexOf(`--${name}`);
|
||||
return index !== -1 ? args[index + 1] : null;
|
||||
}
|
||||
|
||||
const playwrightPath = getArg('playwright');
|
||||
const vitestPath = getArg('vitest');
|
||||
const junitPath = getArg('junit');
|
||||
const outputFormat = getArg('output') || 'text';
|
||||
const failThreshold = parseInt(getArg('fail-threshold') || '0', 10);
|
||||
|
||||
// Result aggregator
|
||||
const summary = {
|
||||
total: 0,
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
flaky: 0,
|
||||
duration: 0,
|
||||
suites: [],
|
||||
failures: [],
|
||||
};
|
||||
|
||||
// Parse Playwright JSON results
|
||||
function parsePlaywright(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error(`Playwright results not found: ${filePath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
|
||||
for (const suite of data.suites || []) {
|
||||
parseSuite(suite, 'playwright');
|
||||
}
|
||||
|
||||
summary.duration += data.stats?.duration || 0;
|
||||
}
|
||||
|
||||
function parseSuite(suite, source) {
|
||||
const suiteSummary = {
|
||||
name: suite.title || suite.file || 'Unknown',
|
||||
source,
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
};
|
||||
|
||||
for (const spec of suite.specs || []) {
|
||||
for (const test of spec.tests || []) {
|
||||
summary.total++;
|
||||
suiteSummary[test.status === 'expected' ? 'passed' : test.status]++;
|
||||
|
||||
if (test.status === 'expected') {
|
||||
summary.passed++;
|
||||
} else if (test.status === 'unexpected') {
|
||||
summary.failed++;
|
||||
summary.failures.push({
|
||||
name: `${suite.title} > ${spec.title}`,
|
||||
source,
|
||||
error: test.results?.[0]?.error?.message || 'Unknown error',
|
||||
});
|
||||
} else if (test.status === 'skipped') {
|
||||
summary.skipped++;
|
||||
} else if (test.status === 'flaky') {
|
||||
summary.flaky++;
|
||||
summary.passed++; // Flaky tests that eventually passed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse into nested suites
|
||||
for (const child of suite.suites || []) {
|
||||
parseSuite(child, source);
|
||||
}
|
||||
|
||||
if (suiteSummary.passed + suiteSummary.failed + suiteSummary.skipped > 0) {
|
||||
summary.suites.push(suiteSummary);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse Vitest JSON results
|
||||
function parseVitest(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error(`Vitest results not found: ${filePath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
|
||||
for (const file of data.testResults || []) {
|
||||
const suiteSummary = {
|
||||
name: path.basename(file.name),
|
||||
source: 'vitest',
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
};
|
||||
|
||||
for (const test of file.assertionResults || []) {
|
||||
summary.total++;
|
||||
|
||||
if (test.status === 'passed') {
|
||||
summary.passed++;
|
||||
suiteSummary.passed++;
|
||||
} else if (test.status === 'failed') {
|
||||
summary.failed++;
|
||||
suiteSummary.failed++;
|
||||
summary.failures.push({
|
||||
name: test.fullName || test.title,
|
||||
source: 'vitest',
|
||||
error: test.failureMessages?.[0] || 'Unknown error',
|
||||
});
|
||||
} else if (test.status === 'skipped' || test.status === 'pending') {
|
||||
summary.skipped++;
|
||||
suiteSummary.skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
summary.suites.push(suiteSummary);
|
||||
}
|
||||
|
||||
summary.duration += data.startTime
|
||||
? Date.now() - data.startTime
|
||||
: 0;
|
||||
}
|
||||
|
||||
// Parse JUnit XML results
|
||||
function parseJunit(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error(`JUnit results not found: ${filePath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const xml = fs.readFileSync(filePath, 'utf-8');
|
||||
|
||||
// Simple XML parsing (avoid external dependencies)
|
||||
const testsuites = xml.match(/<testsuite[^>]*>/g) || [];
|
||||
|
||||
for (const testsuite of testsuites) {
|
||||
const name = testsuite.match(/name="([^"]+)"/)?.[1] || 'Unknown';
|
||||
const tests = parseInt(testsuite.match(/tests="(\d+)"/)?.[1] || '0', 10);
|
||||
const failures = parseInt(testsuite.match(/failures="(\d+)"/)?.[1] || '0', 10);
|
||||
const skipped = parseInt(testsuite.match(/skipped="(\d+)"/)?.[1] || '0', 10);
|
||||
const time = parseFloat(testsuite.match(/time="([\d.]+)"/)?.[1] || '0');
|
||||
|
||||
summary.total += tests;
|
||||
summary.passed += tests - failures - skipped;
|
||||
summary.failed += failures;
|
||||
summary.skipped += skipped;
|
||||
summary.duration += time * 1000;
|
||||
|
||||
summary.suites.push({
|
||||
name,
|
||||
source: 'junit',
|
||||
passed: tests - failures - skipped,
|
||||
failed: failures,
|
||||
skipped,
|
||||
});
|
||||
}
|
||||
|
||||
// Extract failure details
|
||||
const failureMatches = xml.matchAll(/<testcase[^>]*name="([^"]+)"[^>]*>[\s\S]*?<failure[^>]*>([\s\S]*?)<\/failure>/g);
|
||||
for (const match of failureMatches) {
|
||||
summary.failures.push({
|
||||
name: match[1],
|
||||
source: 'junit',
|
||||
error: match[2].trim().slice(0, 200),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Output formatters
|
||||
function outputText() {
|
||||
const passRate = summary.total > 0
|
||||
? ((summary.passed / summary.total) * 100).toFixed(1)
|
||||
: 0;
|
||||
|
||||
console.log('\n📊 Test Results Summary');
|
||||
console.log('='.repeat(50));
|
||||
console.log(`Total: ${summary.total}`);
|
||||
console.log(`Passed: ${summary.passed} ✅`);
|
||||
console.log(`Failed: ${summary.failed} ❌`);
|
||||
console.log(`Skipped: ${summary.skipped} ⏭️`);
|
||||
if (summary.flaky > 0) {
|
||||
console.log(`Flaky: ${summary.flaky} ⚠️`);
|
||||
}
|
||||
console.log(`Pass Rate: ${passRate}%`);
|
||||
console.log(`Duration: ${(summary.duration / 1000).toFixed(2)}s`);
|
||||
|
||||
if (summary.failures.length > 0) {
|
||||
console.log('\n❌ Failures:');
|
||||
for (const failure of summary.failures.slice(0, 10)) {
|
||||
console.log(` - [${failure.source}] ${failure.name}`);
|
||||
console.log(` ${failure.error.slice(0, 100)}...`);
|
||||
}
|
||||
if (summary.failures.length > 10) {
|
||||
console.log(` ... and ${summary.failures.length - 10} more`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
}
|
||||
|
||||
function outputJson() {
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
}
|
||||
|
||||
function outputMarkdown() {
|
||||
const passRate = summary.total > 0
|
||||
? ((summary.passed / summary.total) * 100).toFixed(1)
|
||||
: 0;
|
||||
|
||||
console.log('## Test Results Summary\n');
|
||||
console.log('| Metric | Value |');
|
||||
console.log('|--------|-------|');
|
||||
console.log(`| Total | ${summary.total} |`);
|
||||
console.log(`| Passed | ${summary.passed} ✅ |`);
|
||||
console.log(`| Failed | ${summary.failed} ❌ |`);
|
||||
console.log(`| Skipped | ${summary.skipped} |`);
|
||||
console.log(`| Pass Rate | ${passRate}% |`);
|
||||
console.log(`| Duration | ${(summary.duration / 1000).toFixed(2)}s |`);
|
||||
|
||||
if (summary.failures.length > 0) {
|
||||
console.log('\n### Failures\n');
|
||||
for (const failure of summary.failures.slice(0, 10)) {
|
||||
console.log(`- **[${failure.source}]** ${failure.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Main execution
|
||||
if (playwrightPath) parsePlaywright(playwrightPath);
|
||||
if (vitestPath) parseVitest(vitestPath);
|
||||
if (junitPath) parseJunit(junitPath);
|
||||
|
||||
if (summary.total === 0) {
|
||||
console.log('No test results found. Specify at least one input:');
|
||||
console.log(' --playwright <path> Playwright JSON results');
|
||||
console.log(' --vitest <path> Vitest JSON results');
|
||||
console.log(' --junit <path> JUnit XML results');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Output results
|
||||
switch (outputFormat) {
|
||||
case 'json':
|
||||
outputJson();
|
||||
break;
|
||||
case 'markdown':
|
||||
outputMarkdown();
|
||||
break;
|
||||
default:
|
||||
outputText();
|
||||
}
|
||||
|
||||
// Check threshold
|
||||
const passRate = (summary.passed / summary.total) * 100;
|
||||
if (failThreshold > 0 && passRate < failThreshold) {
|
||||
console.error(`\n❌ Pass rate ${passRate.toFixed(1)}% below threshold ${failThreshold}%`);
|
||||
process.exit(1);
|
||||
}
|
||||
233
.opencode/skills/web-testing/scripts/init-playwright.js
Executable file
233
.opencode/skills/web-testing/scripts/init-playwright.js
Executable file
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Initialize Playwright with best-practice configuration
|
||||
* Usage: node init-playwright.js [--ct] [--dir <path>]
|
||||
*
|
||||
* Options:
|
||||
* --ct Include component testing setup
|
||||
* --dir Target directory (default: current directory)
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Parse arguments
|
||||
const args = process.argv.slice(2);
|
||||
const includeComponentTesting = args.includes('--ct');
|
||||
const dirIndex = args.indexOf('--dir');
|
||||
const targetDir = dirIndex !== -1 ? args[dirIndex + 1] : process.cwd();
|
||||
|
||||
// Configuration templates
|
||||
const playwrightConfig = `import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: [
|
||||
['html', { open: 'never' }],
|
||||
['json', { outputFile: 'test-results/results.json' }],
|
||||
],
|
||||
use: {
|
||||
baseURL: process.env.BASE_URL || 'http://localhost:3000',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
|
||||
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
|
||||
{ name: 'mobile-chrome', use: { ...devices['Pixel 5'] } },
|
||||
{ name: 'mobile-safari', use: { ...devices['iPhone 12'] } },
|
||||
],
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120 * 1000,
|
||||
},
|
||||
});
|
||||
`;
|
||||
|
||||
const authFixture = `import { test as base, expect, Page } from '@playwright/test';
|
||||
|
||||
type AuthFixtures = {
|
||||
authenticatedPage: Page;
|
||||
};
|
||||
|
||||
export const test = base.extend<AuthFixtures>({
|
||||
authenticatedPage: async ({ browser, request }, use, testInfo) => {
|
||||
// API-based authentication (faster than UI login)
|
||||
const response = await request.post('/api/auth/login', {
|
||||
data: {
|
||||
email: process.env.TEST_USER_EMAIL || 'test@example.com',
|
||||
password: process.env.TEST_USER_PASSWORD || 'password',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok()) {
|
||||
throw new Error(\`Auth failed: \${response.status()}\`);
|
||||
}
|
||||
|
||||
const { token } = await response.json();
|
||||
const context = await browser.newContext();
|
||||
|
||||
// Set auth cookie/header
|
||||
await context.addCookies([
|
||||
{
|
||||
name: 'auth-token',
|
||||
value: token,
|
||||
domain: 'localhost',
|
||||
path: '/',
|
||||
},
|
||||
]);
|
||||
|
||||
const page = await context.newPage();
|
||||
await use(page);
|
||||
await context.close();
|
||||
},
|
||||
});
|
||||
|
||||
export { expect };
|
||||
`;
|
||||
|
||||
const exampleTest = `import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Homepage', () => {
|
||||
test('should load successfully', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveTitle(/./);
|
||||
});
|
||||
|
||||
test('should have navigation', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const nav = page.getByRole('navigation');
|
||||
await expect(nav).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Accessibility', () => {
|
||||
test('homepage passes axe checks', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// Install @axe-core/playwright for full accessibility testing
|
||||
// const results = await new AxeBuilder({ page }).analyze();
|
||||
// expect(results.violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
`;
|
||||
|
||||
const authTest = `import { test, expect } from '../fixtures/auth';
|
||||
|
||||
test.describe('Authenticated User', () => {
|
||||
test('can access dashboard', async ({ authenticatedPage: page }) => {
|
||||
await page.goto('/dashboard');
|
||||
await expect(page).toHaveURL(/dashboard/);
|
||||
});
|
||||
});
|
||||
`;
|
||||
|
||||
const globalSetup = `import { chromium, FullConfig } from '@playwright/test';
|
||||
|
||||
async function globalSetup(config: FullConfig) {
|
||||
// Run once before all tests
|
||||
// Example: seed database, create test users
|
||||
|
||||
console.log('Global setup running...');
|
||||
|
||||
// Optional: Create authenticated state for reuse
|
||||
// const browser = await chromium.launch();
|
||||
// const page = await browser.newPage();
|
||||
// await page.goto(config.projects[0].use.baseURL + '/login');
|
||||
// // ... login flow
|
||||
// await page.context().storageState({ path: './playwright/.auth/user.json' });
|
||||
// await browser.close();
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
`;
|
||||
|
||||
const componentTestConfig = `import { defineConfig, devices } from '@playwright/experimental-ct-react';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './src',
|
||||
testMatch: '**/*.ct.{ts,tsx}',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
trace: 'on-first-retry',
|
||||
ctPort: 3100,
|
||||
},
|
||||
projects: [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
|
||||
],
|
||||
});
|
||||
`;
|
||||
|
||||
const envExample = `# Test environment variables
|
||||
BASE_URL=http://localhost:3000
|
||||
TEST_USER_EMAIL=test@example.com
|
||||
TEST_USER_PASSWORD=password
|
||||
`;
|
||||
|
||||
// File creation helper
|
||||
function writeFile(filePath, content) {
|
||||
const fullPath = path.join(targetDir, filePath);
|
||||
const dir = path.dirname(fullPath);
|
||||
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
if (fs.existsSync(fullPath)) {
|
||||
console.log(`⏭️ Skipping (exists): ${filePath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
fs.writeFileSync(fullPath, content);
|
||||
console.log(`✅ Created: ${filePath}`);
|
||||
}
|
||||
|
||||
// Main execution
|
||||
console.log('\n🎭 Initializing Playwright with best practices...\n');
|
||||
|
||||
// Core files
|
||||
writeFile('playwright.config.ts', playwrightConfig);
|
||||
writeFile('tests/e2e/fixtures/auth.ts', authFixture);
|
||||
writeFile('tests/e2e/example.spec.ts', exampleTest);
|
||||
writeFile('tests/e2e/auth.spec.ts', authTest);
|
||||
writeFile('tests/e2e/global-setup.ts', globalSetup);
|
||||
writeFile('.env.test.example', envExample);
|
||||
|
||||
// Component testing (optional)
|
||||
if (includeComponentTesting) {
|
||||
writeFile('playwright-ct.config.ts', componentTestConfig);
|
||||
console.log('\n📦 Component testing config created. Install:');
|
||||
console.log(' npm install -D @playwright/experimental-ct-react');
|
||||
}
|
||||
|
||||
// Package.json scripts suggestion
|
||||
console.log('\n📝 Add to package.json scripts:');
|
||||
console.log(`
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:headed": "playwright test --headed",
|
||||
"test:e2e:debug": "playwright test --debug"
|
||||
`);
|
||||
|
||||
// Install commands
|
||||
console.log('\n📦 Install dependencies:');
|
||||
console.log(' npm install -D @playwright/test');
|
||||
console.log(' npx playwright install');
|
||||
|
||||
if (includeComponentTesting) {
|
||||
console.log(' npm install -D @playwright/experimental-ct-react');
|
||||
}
|
||||
|
||||
console.log('\n✨ Playwright setup complete!\n');
|
||||
Reference in New Issue
Block a user