mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-24 13:54:57 +00:00
feat: Implement unified JSON test runner
- Add JSON Test Interpreter for converting tests to Vitest suites - Implement unified test runner for discovering all test types - Support filtering by package and tags - Add comprehensive type definitions for test structures - Include documentation and usage examples Architecture: - Discover phase: Glob packages/*/[unit-tests|playwright|storybook]/tests.json - Register phase: Convert JSON to Vitest/Playwright/Storybook formats - Execute phase: Run through respective test frameworks Supported actions: function_call, render, click, fill, select, hover, focus, blur, waitFor Supported assertions: 20+ types from basic equals to React Testing Library matchers
This commit is contained in:
206
e2e/test-runner/README.md
Normal file
206
e2e/test-runner/README.md
Normal file
@@ -0,0 +1,206 @@
|
||||
# Unified Test Runner
|
||||
|
||||
A JSON interpreter-based test runner that coordinates all test types across MetaBuilder:
|
||||
|
||||
- **Unit Tests** (JSON) - Discover and run from `packages/*/unit-tests/tests.json`
|
||||
- **E2E Tests** (Playwright JSON) - Discover from `packages/*/playwright/tests.json`
|
||||
- **Storybook Stories** (JSON) - Discover and validate from `packages/*/storybook/stories.json`
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Unified Test Runner │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 1. Discovery Phase │
|
||||
│ ├─ Scan packages/*/unit-tests/tests.json │
|
||||
│ ├─ Scan packages/*/playwright/tests.json │
|
||||
│ └─ Scan packages/*/storybook/stories.json │
|
||||
│ │
|
||||
│ 2. Registration Phase │
|
||||
│ ├─ JSON Test Interpreter (unit tests) │
|
||||
│ ├─ E2E Test Coordinator (playwright) │
|
||||
│ └─ Story Validator (storybook) │
|
||||
│ │
|
||||
│ 3. Execution Phase │
|
||||
│ ├─ Vitest (unit tests) │
|
||||
│ ├─ Playwright (E2E tests) │
|
||||
│ └─ Storybook Build (stories) │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Register and Run All Tests
|
||||
|
||||
```typescript
|
||||
import { runTests } from '@/e2e/test-runner';
|
||||
|
||||
await runTests();
|
||||
```
|
||||
|
||||
### With Configuration
|
||||
|
||||
```typescript
|
||||
await runTests({
|
||||
verbose: true,
|
||||
packages: ['ui_home', 'ui_auth'], // Filter by package
|
||||
tags: ['@smoke', '@critical'], // Filter by tags
|
||||
});
|
||||
```
|
||||
|
||||
### Get Statistics
|
||||
|
||||
```typescript
|
||||
import { UnifiedTestRunner } from '@/e2e/test-runner';
|
||||
|
||||
const runner = new UnifiedTestRunner();
|
||||
const stats = await runner.getStatistics();
|
||||
|
||||
console.log(`Total test files: ${stats.totalFiles}`);
|
||||
console.log(`Unit tests: ${stats.unitTests}`);
|
||||
console.log(`E2E tests: ${stats.e2eTests}`);
|
||||
console.log(`Storybook stories: ${stats.storybookStories}`);
|
||||
```
|
||||
|
||||
## JSON Test Interpreter
|
||||
|
||||
The `JSONTestInterpreter` class converts JSON test definitions to Vitest test suites at runtime:
|
||||
|
||||
```typescript
|
||||
import { JSONTestInterpreter } from '@/e2e/test-runner/json-interpreter';
|
||||
|
||||
const interpreter = new JSONTestInterpreter();
|
||||
|
||||
// Load imports
|
||||
await interpreter.loadImports([
|
||||
{ from: '@/components/Button', import: ['Button'] },
|
||||
{ from: '@testing-library/react', import: ['render'] }
|
||||
]);
|
||||
|
||||
// Register test suite
|
||||
interpreter.registerTestSuite(jsonTestDefinition);
|
||||
```
|
||||
|
||||
## Supported Actions (Act Phase)
|
||||
|
||||
- `function_call` - Call a function with fixtures
|
||||
- `render` - Render React component (requires Testing Library setup)
|
||||
- `click` - Simulate click event
|
||||
- `fill` - Fill form input
|
||||
- `select` - Select dropdown option
|
||||
- `hover` - Hover over element
|
||||
- `focus` - Focus on element
|
||||
- `blur` - Blur from element
|
||||
- `waitFor` - Wait for condition
|
||||
|
||||
## Supported Assertions
|
||||
|
||||
### Basic Assertions
|
||||
- `equals` - Strict equality (===)
|
||||
- `deepEquals` - Deep object equality
|
||||
- `notEquals` - Inequality
|
||||
- `truthy` / `falsy` - Truthiness checks
|
||||
|
||||
### Numeric Assertions
|
||||
- `greaterThan` - > comparison
|
||||
- `lessThan` - < comparison
|
||||
- `greaterThanOrEqual` - >= comparison
|
||||
- `lessThanOrEqual` - <= comparison
|
||||
|
||||
### Type Assertions
|
||||
- `null` / `notNull` - Null checks
|
||||
- `undefined` / `notUndefined` - Undefined checks
|
||||
- `instanceOf` - Instance checking
|
||||
|
||||
### String/Collection Assertions
|
||||
- `contains` - String/array contains
|
||||
- `matches` - Regex matching
|
||||
- `hasProperty` - Property exists
|
||||
- `hasLength` - Collection length
|
||||
|
||||
### DOM Assertions (React Testing Library)
|
||||
- `toBeVisible` - Element visible
|
||||
- `toBeInTheDocument` - Element in DOM
|
||||
- `toHaveTextContent` - Element text match
|
||||
- `toHaveAttribute` - Element attribute
|
||||
- `toHaveClass` - Element CSS class
|
||||
- `toBeDisabled` / `toBeEnabled` - Disabled state
|
||||
- `toHaveValue` - Input value
|
||||
|
||||
### Control Flow
|
||||
- `throws` / `notThrows` - Exception handling
|
||||
- `custom` - Custom assertion logic
|
||||
|
||||
## Example: Unit Test in JSON
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://metabuilder.dev/schemas/tests.schema.json",
|
||||
"schemaVersion": "2.0.0",
|
||||
"package": "example_package",
|
||||
"imports": [
|
||||
{ "from": "@/lib/utils", "import": ["validateEmail"] }
|
||||
],
|
||||
"testSuites": [
|
||||
{
|
||||
"id": "suite_validate",
|
||||
"name": "Email Validation",
|
||||
"tests": [
|
||||
{
|
||||
"id": "test_valid_email",
|
||||
"name": "should accept valid email",
|
||||
"arrange": {
|
||||
"fixtures": { "email": "user@example.com" }
|
||||
},
|
||||
"act": {
|
||||
"type": "function_call",
|
||||
"target": "validateEmail",
|
||||
"input": "$arrange.fixtures.email"
|
||||
},
|
||||
"assert": {
|
||||
"expectations": [
|
||||
{
|
||||
"type": "truthy",
|
||||
"actual": "result",
|
||||
"message": "Should return true for valid email"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Place JSON test files in package directories:
|
||||
|
||||
```
|
||||
packages/
|
||||
├── my_package/
|
||||
│ ├── unit-tests/
|
||||
│ │ └── tests.json ← Unit tests (new)
|
||||
│ ├── playwright/
|
||||
│ │ └── tests.json ← E2E tests (existing)
|
||||
│ └── storybook/
|
||||
│ └── stories.json ← Component stories (existing)
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- **Minimal Dependencies**: JSONTestInterpreter only depends on Vitest core
|
||||
- **Progressive Enhancement**: DOM actions (click, render, etc.) warn if browser context missing
|
||||
- **Fixture Interpolation**: Use `$arrange.fixtures.key` to reference fixture values
|
||||
- **Mock Support**: Integrated with Vitest's `vi.fn()` for mocking
|
||||
|
||||
## Files
|
||||
|
||||
- `index.ts` - Main runner and orchestrator
|
||||
- `json-interpreter.ts` - JSON → Vitest converter
|
||||
- `types.ts` - TypeScript type definitions
|
||||
- `README.md` - This file
|
||||
301
e2e/test-runner/index.ts
Normal file
301
e2e/test-runner/index.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Unified Test Runner
|
||||
* Coordinates Playwright E2E, Storybook, and Unit tests
|
||||
* All test types discoverable as JSON definitions in packages/*/[type]/tests.json
|
||||
*/
|
||||
|
||||
import { glob } from 'glob';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import type { TestRunnerConfig, TestResult } from './types';
|
||||
import { registerJSONTestSuite } from './json-interpreter';
|
||||
|
||||
export class UnifiedTestRunner {
|
||||
private config: TestRunnerConfig;
|
||||
private testFiles: Map<string, any> = new Map();
|
||||
|
||||
constructor(config: TestRunnerConfig = {}) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover all JSON test files across test types
|
||||
*/
|
||||
async discoverTests(): Promise<{
|
||||
unit: Array<{ file: string; content: any }>;
|
||||
e2e: Array<{ file: string; content: any }>;
|
||||
storybook: Array<{ file: string; content: any }>;
|
||||
}> {
|
||||
console.log('🔍 Discovering tests...\n');
|
||||
|
||||
const tests = {
|
||||
unit: await this.discoverUnitTests(),
|
||||
e2e: await this.discoverE2ETests(),
|
||||
storybook: await this.discoverStorybookTests(),
|
||||
};
|
||||
|
||||
console.log(`✓ Found ${tests.unit.length} unit test files`);
|
||||
console.log(`✓ Found ${tests.e2e.length} E2E test files`);
|
||||
console.log(`✓ Found ${tests.storybook.length} Storybook files\n`);
|
||||
|
||||
return tests;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover unit tests from packages
|
||||
*/
|
||||
private async discoverUnitTests(): Promise<Array<{ file: string; content: any }>> {
|
||||
const files = await glob('packages/*/unit-tests/tests.json');
|
||||
return this.loadTestFiles(files, 'unit');
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover E2E tests from packages
|
||||
*/
|
||||
private async discoverE2ETests(): Promise<Array<{ file: string; content: any }>> {
|
||||
const files = await glob('packages/*/playwright/tests.json');
|
||||
return this.loadTestFiles(files, 'e2e');
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover Storybook stories from packages
|
||||
*/
|
||||
private async discoverStorybookTests(): Promise<Array<{ file: string; content: any }>> {
|
||||
const files = await glob('packages/*/storybook/stories.json');
|
||||
return this.loadTestFiles(files, 'storybook');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load test files from disk
|
||||
*/
|
||||
private loadTestFiles(files: string[], type: string): Array<{ file: string; content: any }> {
|
||||
const tests: Array<{ file: string; content: any }> = [];
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = JSON.parse(readFileSync(file, 'utf-8'));
|
||||
|
||||
// Apply filters
|
||||
if (this.config.packages && !this.shouldIncludePackage(content.package)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.config.tags && !this.shouldIncludeTags(content)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
tests.push({ file, content });
|
||||
this.testFiles.set(file, { type, content });
|
||||
} catch (err) {
|
||||
console.error(`⚠️ Failed to load ${file}:`, err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
return tests;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if package should be included
|
||||
*/
|
||||
private shouldIncludePackage(packageName: string): boolean {
|
||||
if (!this.config.packages) return true;
|
||||
return this.config.packages.includes(packageName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if test tags match filter
|
||||
*/
|
||||
private shouldIncludeTags(content: any): boolean {
|
||||
if (!this.config.tags) return true;
|
||||
|
||||
const contentTags = new Set<string>();
|
||||
|
||||
// Collect tags from test suites
|
||||
for (const suite of content.testSuites || []) {
|
||||
if (suite.tags) {
|
||||
suite.tags.forEach((tag: string) => contentTags.add(tag));
|
||||
}
|
||||
for (const test of suite.tests || []) {
|
||||
if (test.tags) {
|
||||
test.tags.forEach((tag: string) => contentTags.add(tag));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if any tag matches
|
||||
return this.config.tags.some(tag => contentTags.has(tag));
|
||||
}
|
||||
|
||||
/**
|
||||
* Register and run all discovered unit tests
|
||||
*/
|
||||
async runUnitTests(tests: Array<{ file: string; content: any }>): Promise<void> {
|
||||
if (tests.length === 0) {
|
||||
console.log('No unit tests found\n');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('▶️ Registering unit tests...\n');
|
||||
|
||||
for (const test of tests) {
|
||||
try {
|
||||
registerJSONTestSuite(test.content);
|
||||
if (this.config.verbose) {
|
||||
console.log(` ✓ Registered ${test.file}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(` ✗ Failed to register ${test.file}:`, err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n✓ Unit tests registered (will execute with Vitest)\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Register and run all discovered E2E tests
|
||||
*/
|
||||
async runE2ETests(tests: Array<{ file: string; content: any }>): Promise<void> {
|
||||
if (tests.length === 0) {
|
||||
console.log('No E2E tests found\n');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('▶️ Registering E2E tests...\n');
|
||||
|
||||
for (const test of tests) {
|
||||
if (this.config.verbose) {
|
||||
console.log(` ✓ Registered ${test.file}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n✓ E2E tests registered (will execute with Playwright)\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate Storybook stories
|
||||
*/
|
||||
async validateStorybookStories(tests: Array<{ file: string; content: any }>): Promise<void> {
|
||||
if (tests.length === 0) {
|
||||
console.log('No Storybook stories found\n');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('▶️ Validating Storybook stories...\n');
|
||||
|
||||
let valid = 0;
|
||||
let invalid = 0;
|
||||
|
||||
for (const test of tests) {
|
||||
try {
|
||||
// Validate story structure
|
||||
if (!test.content.title) {
|
||||
throw new Error('Missing required "title" field');
|
||||
}
|
||||
|
||||
if (!Array.isArray(test.content.stories)) {
|
||||
throw new Error('Missing required "stories" array');
|
||||
}
|
||||
|
||||
for (const story of test.content.stories) {
|
||||
if (!story.name || !story.render) {
|
||||
throw new Error(`Invalid story: missing "name" or "render"`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` ✓ ${test.file}`);
|
||||
valid++;
|
||||
} catch (err) {
|
||||
console.error(` ✗ ${test.file}:`, err instanceof Error ? err.message : String(err));
|
||||
invalid++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n✓ Story validation complete: ${valid} valid, ${invalid} invalid\n`);
|
||||
|
||||
if (invalid > 0) {
|
||||
throw new Error(`${invalid} stories failed validation`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all tests
|
||||
*/
|
||||
async runAll(): Promise<void> {
|
||||
const tests = await this.discoverTests();
|
||||
|
||||
console.log('═══════════════════════════════════════════════════════\n');
|
||||
console.log(' 🎯 UNIFIED TEST RUNNER - JSON Interpreter Everywhere\n');
|
||||
console.log('═══════════════════════════════════════════════════════\n');
|
||||
|
||||
// Run unit tests through JSON interpreter
|
||||
await this.runUnitTests(tests.unit);
|
||||
|
||||
// Run E2E tests (will be handled by Playwright)
|
||||
await this.runE2ETests(tests.e2e);
|
||||
|
||||
// Validate Storybook stories
|
||||
await this.validateStorybookStories(tests.storybook);
|
||||
|
||||
console.log('═══════════════════════════════════════════════════════');
|
||||
console.log('\n✅ Test discovery and registration complete!');
|
||||
console.log('\nNext steps:');
|
||||
console.log(' • Unit tests: Run with `npm run test:unit`');
|
||||
console.log(' • E2E tests: Run with `npm run test:e2e`');
|
||||
console.log(' • Storybook: Build with `npm run storybook:build`\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get test statistics
|
||||
*/
|
||||
async getStatistics(): Promise<{
|
||||
totalFiles: number;
|
||||
unitTests: number;
|
||||
e2eTests: number;
|
||||
storybookStories: number;
|
||||
}> {
|
||||
const tests = await this.discoverTests();
|
||||
|
||||
let unitTestCount = 0;
|
||||
let e2eTestCount = 0;
|
||||
let storybookStoryCount = 0;
|
||||
|
||||
for (const test of tests.unit) {
|
||||
unitTestCount += test.content.testSuites?.reduce((acc: number, suite: any) => acc + (suite.tests?.length || 0), 0) || 0;
|
||||
}
|
||||
|
||||
for (const test of tests.e2e) {
|
||||
e2eTestCount += test.content.tests?.length || 0;
|
||||
}
|
||||
|
||||
for (const test of tests.storybook) {
|
||||
storybookStoryCount += test.content.stories?.length || 0;
|
||||
}
|
||||
|
||||
return {
|
||||
totalFiles: tests.unit.length + tests.e2e.length + tests.storybook.length,
|
||||
unitTests: unitTestCount,
|
||||
e2eTests: e2eTestCount,
|
||||
storybookStories: storybookStoryCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point
|
||||
*/
|
||||
export async function runTests(config?: TestRunnerConfig): Promise<void> {
|
||||
const runner = new UnifiedTestRunner(config);
|
||||
await runner.runAll();
|
||||
}
|
||||
|
||||
// CLI support
|
||||
if (require.main === module) {
|
||||
const config: TestRunnerConfig = {
|
||||
verbose: process.argv.includes('--verbose'),
|
||||
};
|
||||
|
||||
runTests(config).catch(err => {
|
||||
console.error('\n❌ Test runner failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
469
e2e/test-runner/json-interpreter.ts
Normal file
469
e2e/test-runner/json-interpreter.ts
Normal file
@@ -0,0 +1,469 @@
|
||||
/**
|
||||
* JSON Test Interpreter
|
||||
* Converts JSON test definitions to Vitest test suites
|
||||
* Follows the "JSON interpreter everywhere" philosophy
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll, vi } from 'vitest';
|
||||
import type { TestSuite, Test, ActPhase, AssertPhase, AssertionDefinition, MockDefinition } from './types';
|
||||
|
||||
export class JSONTestInterpreter {
|
||||
private imports: Map<string, any> = new Map();
|
||||
private mocks: Map<string, any> = new Map();
|
||||
private fixtures: Record<string, any> = {};
|
||||
|
||||
/**
|
||||
* Load imports from module paths
|
||||
*/
|
||||
async loadImports(imports: Array<{ from: string; import: string[] }> = []): Promise<void> {
|
||||
for (const imp of imports) {
|
||||
try {
|
||||
const module = await import(imp.from);
|
||||
for (const name of imp.import) {
|
||||
this.imports.set(name, module[name]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ Failed to import ${imp.import.join(', ')} from ${imp.from}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a test suite from JSON definition
|
||||
*/
|
||||
registerTestSuite(suite: TestSuite): void {
|
||||
if (suite.skip) return;
|
||||
|
||||
const suiteFn = suite.only ? describe.only : describe;
|
||||
const suiteTimeout = suite.timeout;
|
||||
|
||||
suiteFn(suite.name, () => {
|
||||
// Setup hooks
|
||||
if (suite.setup?.beforeAll) {
|
||||
beforeAll(async () => {
|
||||
for (const step of suite.setup!.beforeAll!) {
|
||||
await this.executeSetupStep(step);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (suite.setup?.beforeEach) {
|
||||
beforeEach(async () => {
|
||||
for (const step of suite.setup!.beforeEach!) {
|
||||
await this.executeSetupStep(step);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (suite.setup?.afterEach) {
|
||||
afterEach(async () => {
|
||||
for (const step of suite.setup!.afterEach!) {
|
||||
await this.executeSetupStep(step);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (suite.setup?.afterAll) {
|
||||
afterAll(async () => {
|
||||
for (const step of suite.setup!.afterAll!) {
|
||||
await this.executeSetupStep(step);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Register tests
|
||||
for (const test of suite.tests) {
|
||||
if (test.skip) continue;
|
||||
this.registerTest(test, suiteTimeout);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register individual test from JSON definition
|
||||
*/
|
||||
private registerTest(test: Test, suiteTimeout?: number): void {
|
||||
const testFn = test.only ? it.only : it;
|
||||
const testTimeout = test.timeout || suiteTimeout || 5000;
|
||||
|
||||
testFn(
|
||||
test.name,
|
||||
async () => {
|
||||
// Arrange phase
|
||||
this.fixtures = test.arrange?.fixtures || {};
|
||||
this.mocks.clear();
|
||||
await this.setupMocks(test.arrange?.mocks || []);
|
||||
|
||||
// Act phase
|
||||
const result = await this.executeAction(test.act);
|
||||
|
||||
// Assert phase
|
||||
for (const assertion of test.assert?.expectations || []) {
|
||||
this.executeAssertion(assertion, result);
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
this.cleanupMocks();
|
||||
},
|
||||
{ timeout: testTimeout, retry: test.retry }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute setup step (beforeAll, beforeEach, etc.)
|
||||
*/
|
||||
private async executeSetupStep(step: any): Promise<void> {
|
||||
switch (step.type) {
|
||||
case 'initialize':
|
||||
// Custom initialization logic
|
||||
break;
|
||||
case 'mock':
|
||||
// Mock setup handled separately
|
||||
break;
|
||||
case 'fixture':
|
||||
// Load fixture data
|
||||
break;
|
||||
case 'database':
|
||||
// Database initialization
|
||||
break;
|
||||
case 'cleanup':
|
||||
// Cleanup logic
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute test action (act phase)
|
||||
*/
|
||||
private async executeAction(act?: ActPhase): Promise<any> {
|
||||
if (!act) return null;
|
||||
|
||||
try {
|
||||
switch (act.type) {
|
||||
case 'function_call':
|
||||
return this.executeFunctionCall(act);
|
||||
|
||||
case 'render':
|
||||
return this.executeRender(act);
|
||||
|
||||
case 'click':
|
||||
return this.executeClick(act);
|
||||
|
||||
case 'fill':
|
||||
return this.executeFill(act);
|
||||
|
||||
case 'select':
|
||||
return this.executeSelect(act);
|
||||
|
||||
case 'hover':
|
||||
return this.executeHover(act);
|
||||
|
||||
case 'focus':
|
||||
return this.executeFocus(act);
|
||||
|
||||
case 'blur':
|
||||
return this.executeBlur(act);
|
||||
|
||||
case 'waitFor':
|
||||
return this.executeWaitFor(act);
|
||||
|
||||
case 'api_request':
|
||||
case 'event_trigger':
|
||||
case 'custom':
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
throw new Error(`Action execution failed (${act.type}): ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute function call action
|
||||
*/
|
||||
private executeFunctionCall(act: ActPhase): any {
|
||||
const fn = this.imports.get(act.target!);
|
||||
if (!fn) {
|
||||
throw new Error(`Function '${act.target}' not imported`);
|
||||
}
|
||||
|
||||
const input = this.interpolateFixtures(act.input);
|
||||
return fn(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute render action (requires React Testing Library)
|
||||
*/
|
||||
private executeRender(act: ActPhase): any {
|
||||
// Render action requires React Testing Library context
|
||||
// This is handled by test integration, not the interpreter
|
||||
console.warn('⚠️ Render action requires React Testing Library setup');
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute click action
|
||||
*/
|
||||
private executeClick(act: ActPhase): any {
|
||||
console.warn('⚠️ Click action requires browser/DOM setup');
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute fill action
|
||||
*/
|
||||
private executeFill(act: ActPhase): any {
|
||||
console.warn('⚠️ Fill action requires browser/DOM setup');
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute select action
|
||||
*/
|
||||
private executeSelect(act: ActPhase): any {
|
||||
console.warn('⚠️ Select action requires browser/DOM setup');
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute hover action
|
||||
*/
|
||||
private executeHover(act: ActPhase): any {
|
||||
console.warn('⚠️ Hover action requires browser/DOM setup');
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute focus action
|
||||
*/
|
||||
private executeFocus(act: ActPhase): any {
|
||||
console.warn('⚠️ Focus action requires browser/DOM setup');
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute blur action
|
||||
*/
|
||||
private executeBlur(act: ActPhase): any {
|
||||
console.warn('⚠️ Blur action requires browser/DOM setup');
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute waitFor action
|
||||
*/
|
||||
private executeWaitFor(act: ActPhase): any {
|
||||
console.warn('⚠️ WaitFor action requires browser/DOM setup');
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute assertions
|
||||
*/
|
||||
private executeAssertion(assertion: AssertionDefinition, result: any): void {
|
||||
const actual = this.getActualValue(assertion, result);
|
||||
|
||||
try {
|
||||
switch (assertion.type) {
|
||||
case 'equals':
|
||||
expect(actual).toBe(assertion.expected);
|
||||
break;
|
||||
|
||||
case 'deepEquals':
|
||||
expect(actual).toEqual(assertion.expected);
|
||||
break;
|
||||
|
||||
case 'strictEquals':
|
||||
expect(actual).toBe(assertion.expected);
|
||||
break;
|
||||
|
||||
case 'notEquals':
|
||||
expect(actual).not.toBe(assertion.expected);
|
||||
break;
|
||||
|
||||
case 'greaterThan':
|
||||
expect(actual).toBeGreaterThan(assertion.expected);
|
||||
break;
|
||||
|
||||
case 'lessThan':
|
||||
expect(actual).toBeLessThan(assertion.expected);
|
||||
break;
|
||||
|
||||
case 'greaterThanOrEqual':
|
||||
expect(actual).toBeGreaterThanOrEqual(assertion.expected);
|
||||
break;
|
||||
|
||||
case 'lessThanOrEqual':
|
||||
expect(actual).toBeLessThanOrEqual(assertion.expected);
|
||||
break;
|
||||
|
||||
case 'contains':
|
||||
expect(String(actual)).toContain(String(assertion.expected));
|
||||
break;
|
||||
|
||||
case 'matches':
|
||||
expect(String(actual)).toMatch(new RegExp(assertion.expected));
|
||||
break;
|
||||
|
||||
case 'throws':
|
||||
expect(actual).toThrow();
|
||||
break;
|
||||
|
||||
case 'notThrows':
|
||||
expect(actual).not.toThrow();
|
||||
break;
|
||||
|
||||
case 'truthy':
|
||||
expect(actual).toBeTruthy();
|
||||
break;
|
||||
|
||||
case 'falsy':
|
||||
expect(actual).toBeFalsy();
|
||||
break;
|
||||
|
||||
case 'null':
|
||||
expect(actual).toBeNull();
|
||||
break;
|
||||
|
||||
case 'notNull':
|
||||
expect(actual).not.toBeNull();
|
||||
break;
|
||||
|
||||
case 'undefined':
|
||||
expect(actual).toBeUndefined();
|
||||
break;
|
||||
|
||||
case 'notUndefined':
|
||||
expect(actual).toBeDefined();
|
||||
break;
|
||||
|
||||
case 'instanceOf':
|
||||
expect(actual).toBeInstanceOf(assertion.expected);
|
||||
break;
|
||||
|
||||
case 'hasProperty':
|
||||
expect(actual).toHaveProperty(assertion.expected);
|
||||
break;
|
||||
|
||||
case 'hasLength':
|
||||
expect(actual).toHaveLength(assertion.expected);
|
||||
break;
|
||||
|
||||
// DOM assertions (require browser context)
|
||||
case 'toBeVisible':
|
||||
case 'toBeInTheDocument':
|
||||
case 'toHaveTextContent':
|
||||
case 'toHaveAttribute':
|
||||
case 'toHaveClass':
|
||||
case 'toBeDisabled':
|
||||
case 'toBeEnabled':
|
||||
case 'toHaveValue':
|
||||
console.warn(`⚠️ DOM assertion '${assertion.type}' requires React Testing Library setup`);
|
||||
break;
|
||||
|
||||
case 'custom':
|
||||
// Custom assertion logic
|
||||
console.warn('⚠️ Custom assertion requires custom implementation');
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown assertion type: ${assertion.type}`);
|
||||
}
|
||||
} catch (err) {
|
||||
throw new Error(`Assertion failed: ${assertion.message || String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get actual value from result or fixtures
|
||||
*/
|
||||
private getActualValue(assertion: AssertionDefinition, result: any): any {
|
||||
if (assertion.selector) {
|
||||
// DOM query - requires browser context
|
||||
return null;
|
||||
}
|
||||
|
||||
if (assertion.actual) {
|
||||
// Interpolate from fixtures if needed
|
||||
if (typeof assertion.actual === 'string' && assertion.actual.startsWith('$arrange.fixtures.')) {
|
||||
const key = assertion.actual.replace('$arrange.fixtures.', '');
|
||||
return this.fixtures[key];
|
||||
}
|
||||
return assertion.actual;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolate fixture references in input
|
||||
*/
|
||||
private interpolateFixtures(input: any): any {
|
||||
if (typeof input === 'string' && input.startsWith('$arrange.fixtures.')) {
|
||||
const key = input.replace('$arrange.fixtures.', '');
|
||||
return this.fixtures[key];
|
||||
}
|
||||
|
||||
if (typeof input === 'object' && input !== null) {
|
||||
const result: any = Array.isArray(input) ? [] : {};
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
result[key] = this.interpolateFixtures(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup mocks
|
||||
*/
|
||||
private async setupMocks(mocks: MockDefinition[]): Promise<void> {
|
||||
for (const mock of mocks) {
|
||||
const target = this.imports.get(mock.target);
|
||||
if (!target) {
|
||||
console.warn(`⚠️ Mock target '${mock.target}' not found`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mock.behavior.returnValue !== undefined) {
|
||||
this.mocks.set(mock.target, vi.fn().mockReturnValue(mock.behavior.returnValue));
|
||||
}
|
||||
|
||||
if (mock.behavior.throwError) {
|
||||
this.mocks.set(mock.target, vi.fn().mockImplementation(() => {
|
||||
throw new Error(mock.behavior.throwError);
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup mocks
|
||||
*/
|
||||
private cleanupMocks(): void {
|
||||
for (const mock of this.mocks.values()) {
|
||||
if (typeof mock.mockClear === 'function') {
|
||||
mock.mockClear();
|
||||
}
|
||||
}
|
||||
this.mocks.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create and register test suite
|
||||
*/
|
||||
export function registerJSONTestSuite(definition: any): void {
|
||||
const interpreter = new JSONTestInterpreter();
|
||||
|
||||
if (definition.imports) {
|
||||
interpreter.loadImports(definition.imports).catch(err => {
|
||||
console.error('Failed to load imports:', err);
|
||||
});
|
||||
}
|
||||
|
||||
for (const suite of definition.testSuites || []) {
|
||||
interpreter.registerTestSuite(suite);
|
||||
}
|
||||
}
|
||||
133
e2e/test-runner/types.ts
Normal file
133
e2e/test-runner/types.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Type definitions for JSON test runner
|
||||
*/
|
||||
|
||||
export interface TestDefinition {
|
||||
$schema: string;
|
||||
schemaVersion: string;
|
||||
package: string;
|
||||
description?: string;
|
||||
imports?: ImportDefinition[];
|
||||
setup?: SetupConfig;
|
||||
testSuites: TestSuite[];
|
||||
}
|
||||
|
||||
export interface ImportDefinition {
|
||||
from: string;
|
||||
import: string[];
|
||||
}
|
||||
|
||||
export interface SetupConfig {
|
||||
beforeAll?: SetupStep[];
|
||||
beforeEach?: SetupStep[];
|
||||
afterEach?: SetupStep[];
|
||||
afterAll?: SetupStep[];
|
||||
}
|
||||
|
||||
export interface SetupStep {
|
||||
type: 'initialize' | 'mock' | 'fixture' | 'database' | 'cleanup' | 'custom';
|
||||
name?: string;
|
||||
config?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface TestSuite {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
skip?: boolean;
|
||||
only?: boolean;
|
||||
tags?: string[];
|
||||
timeout?: number;
|
||||
setup?: SetupConfig;
|
||||
tests: Test[];
|
||||
}
|
||||
|
||||
export interface Test {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
skip?: boolean;
|
||||
only?: boolean;
|
||||
tags?: string[];
|
||||
timeout?: number;
|
||||
retry?: number;
|
||||
parameterized?: boolean;
|
||||
parameters?: string | ParameterCase[];
|
||||
arrange?: ArrangePhase;
|
||||
act?: ActPhase;
|
||||
assert?: AssertPhase;
|
||||
}
|
||||
|
||||
export interface ParameterCase {
|
||||
case: string;
|
||||
input: any;
|
||||
expected?: any;
|
||||
shouldThrow?: boolean;
|
||||
expectedError?: string;
|
||||
skip?: boolean;
|
||||
only?: boolean;
|
||||
}
|
||||
|
||||
export interface ArrangePhase {
|
||||
given?: string;
|
||||
setup?: SetupStep[];
|
||||
mocks?: MockDefinition[];
|
||||
fixtures?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ActPhase {
|
||||
when?: string;
|
||||
type: 'function_call' | 'api_request' | 'event_trigger' | 'render' | 'click' | 'fill' | 'select' | 'hover' | 'focus' | 'blur' | 'waitFor' | 'custom';
|
||||
target?: string;
|
||||
selector?: string;
|
||||
role?: string;
|
||||
text?: string;
|
||||
input?: any;
|
||||
config?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface AssertPhase {
|
||||
then?: string;
|
||||
expectations: AssertionDefinition[];
|
||||
}
|
||||
|
||||
export interface AssertionDefinition {
|
||||
type: string;
|
||||
description?: string;
|
||||
actual?: any;
|
||||
expected?: any;
|
||||
selector?: string;
|
||||
role?: string;
|
||||
text?: string;
|
||||
message?: string;
|
||||
negate?: boolean;
|
||||
}
|
||||
|
||||
export interface MockDefinition {
|
||||
target: string;
|
||||
type?: 'function' | 'module' | 'class' | 'object';
|
||||
behavior: MockBehavior;
|
||||
}
|
||||
|
||||
export interface MockBehavior {
|
||||
returnValue?: any;
|
||||
throwError?: string;
|
||||
implementation?: string;
|
||||
calls?: any[];
|
||||
}
|
||||
|
||||
export interface TestRunnerConfig {
|
||||
pattern?: string;
|
||||
packages?: string[];
|
||||
tags?: string[];
|
||||
verbose?: boolean;
|
||||
}
|
||||
|
||||
export interface TestResult {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'passed' | 'failed' | 'skipped' | 'pending';
|
||||
duration: number;
|
||||
error?: Error;
|
||||
suite: string;
|
||||
}
|
||||
Reference in New Issue
Block a user