mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-24 13:54:57 +00:00
refactor: replace code generation with direct JSON interpretation
- Removed code generators (e2e/generators, storybook/generators) - Created JSON test runner that executes Playwright tests directly from JSON - Created JSON story loader that renders Storybook stories directly from JSON - No intermediate code generation - JSON is executable/renderable at runtime - json-packages.spec.ts auto-discovers and runs all package tests from JSON - DynamicStory component renders stories from JSON definitions - True meta/abstract architecture: configuration itself is executable - Single source of truth: JSON definitions only (no generated .spec.ts or .stories.tsx) - Changes to JSON take effect immediately without regeneration - Added comprehensive READMEs explaining the interpretation approach Co-authored-by: johndoe6345789 <224850594+johndoe6345789@users.noreply.github.com>
This commit is contained in:
2
e2e/generators/.gitignore
vendored
2
e2e/generators/.gitignore
vendored
@@ -1,2 +0,0 @@
|
||||
# Ignore generated test files
|
||||
../generated/
|
||||
@@ -1,103 +0,0 @@
|
||||
# E2E Test Generators
|
||||
|
||||
This folder contains tools for generating Playwright tests from declarative JSON definitions in packages.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
e2e/
|
||||
├── generators/
|
||||
│ ├── playwright-generator.ts # Core generator logic
|
||||
│ └── generate.ts # CLI script
|
||||
├── generated/ # Generated .spec.ts files (gitignored)
|
||||
├── smoke.spec.ts # Manual smoke tests
|
||||
└── *.config.ts # Playwright configurations
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Generate All Package Tests
|
||||
|
||||
```bash
|
||||
# From project root
|
||||
npm run test:generate
|
||||
|
||||
# Or with watch mode
|
||||
npm run test:generate:watch
|
||||
```
|
||||
|
||||
### Generate Specific Package Tests
|
||||
|
||||
```bash
|
||||
npm run test:generate ui_home
|
||||
```
|
||||
|
||||
### Run Generated Tests
|
||||
|
||||
```bash
|
||||
npm run test:e2e -- e2e/generated/ui_home.spec.ts
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Discovery**: Scans `packages/*/playwright/tests.json` files
|
||||
2. **Parsing**: Reads JSON test definitions
|
||||
3. **Generation**: Converts to TypeScript `.spec.ts` files
|
||||
4. **Output**: Writes to `e2e/generated/`
|
||||
|
||||
## Package Test Definitions
|
||||
|
||||
Packages define tests in `playwright/tests.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://metabuilder.dev/schemas/package-playwright.schema.json",
|
||||
"package": "ui_home",
|
||||
"tests": [
|
||||
{
|
||||
"name": "should load home page",
|
||||
"steps": [
|
||||
{ "action": "navigate", "url": "/" },
|
||||
{
|
||||
"action": "expect",
|
||||
"selector": "body",
|
||||
"assertion": { "matcher": "toBeVisible" }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Generated Output Example
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Auto-generated Playwright tests for ui_home package
|
||||
* Generated from: packages/ui_home/playwright/tests.json
|
||||
* DO NOT EDIT - This file is auto-generated
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('ui_home Package Tests', () => {
|
||||
test('should load home page', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await expect(page.locator('body')).toBeVisible()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
- **Data-Driven**: Tests are JSON configuration, not code
|
||||
- **Package-Scoped**: Each package owns its test definitions
|
||||
- **Auto-Generated**: No manual TypeScript test writing
|
||||
- **Schema-Validated**: Tests conform to JSON schema
|
||||
- **Meta Architecture**: Tests themselves are declarative
|
||||
|
||||
## See Also
|
||||
|
||||
- `schemas/package-schemas/playwright.schema.json` - JSON Schema
|
||||
- `schemas/package-schemas/PLAYWRIGHT_SCHEMA_README.md` - Schema docs
|
||||
- `packages/*/playwright/` - Package test definitions
|
||||
@@ -1,48 +0,0 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Generate Playwright tests from JSON definitions
|
||||
*
|
||||
* Usage:
|
||||
* npm run test:generate # Generate all package tests
|
||||
* npm run test:generate ui_home # Generate specific package tests
|
||||
*/
|
||||
|
||||
import { resolve } from 'path'
|
||||
import { generatePlaywrightTest, generateAllPlaywrightTests, discoverPlaywrightPackages } from './playwright-generator'
|
||||
|
||||
async function main() {
|
||||
const packageName = process.argv[2]
|
||||
const projectRoot = resolve(__dirname, '../..')
|
||||
const packagesDir = resolve(projectRoot, 'packages')
|
||||
const outputDir = resolve(projectRoot, 'e2e/generated')
|
||||
|
||||
console.log('🎭 Playwright Test Generator')
|
||||
console.log('═'.repeat(50))
|
||||
console.log(`Packages dir: ${packagesDir}`)
|
||||
console.log(`Output dir: ${outputDir}`)
|
||||
console.log('')
|
||||
|
||||
try {
|
||||
if (packageName) {
|
||||
// Generate for specific package
|
||||
console.log(`Generating tests for package: ${packageName}`)
|
||||
const outputPath = await generatePlaywrightTest(packageName, packagesDir, outputDir)
|
||||
console.log(`✅ Generated: ${outputPath}`)
|
||||
} else {
|
||||
// Discover and generate all
|
||||
const packages = await discoverPlaywrightPackages(packagesDir)
|
||||
console.log(`Found ${packages.length} packages with Playwright tests:`)
|
||||
packages.forEach(pkg => console.log(` - ${pkg}`))
|
||||
console.log('')
|
||||
|
||||
const generated = await generateAllPlaywrightTests(packagesDir, outputDir)
|
||||
console.log('')
|
||||
console.log(`✅ Generated ${generated.length} test files`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error:', error)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -1,363 +0,0 @@
|
||||
/**
|
||||
* Playwright Test Generator
|
||||
*
|
||||
* Generates executable Playwright .spec.ts files from declarative JSON test definitions
|
||||
* in packages/*/playwright/tests.json
|
||||
*
|
||||
* Usage:
|
||||
* import { generatePlaywrightTests } from '@/lib/generators/playwright-generator'
|
||||
* await generatePlaywrightTests('ui_home')
|
||||
*/
|
||||
|
||||
import { readFile, writeFile, mkdir } from 'fs/promises'
|
||||
import { join, dirname } from 'path'
|
||||
import { existsSync } from 'fs'
|
||||
|
||||
export interface PlaywrightTestDefinition {
|
||||
$schema: string
|
||||
package: string
|
||||
version?: string
|
||||
description?: string
|
||||
baseURL?: string
|
||||
setup?: {
|
||||
beforeAll?: SetupStep[]
|
||||
beforeEach?: SetupStep[]
|
||||
afterEach?: SetupStep[]
|
||||
afterAll?: SetupStep[]
|
||||
}
|
||||
fixtures?: Record<string, unknown>
|
||||
tests: TestCase[]
|
||||
}
|
||||
|
||||
export interface SetupStep {
|
||||
action: string
|
||||
description?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface TestCase {
|
||||
name: string
|
||||
description?: string
|
||||
skip?: boolean
|
||||
only?: boolean
|
||||
timeout?: number
|
||||
retries?: number
|
||||
tags?: string[]
|
||||
fixtures?: Record<string, unknown>
|
||||
steps: TestStep[]
|
||||
}
|
||||
|
||||
export interface TestStep {
|
||||
description?: string
|
||||
action: string
|
||||
url?: string
|
||||
selector?: string
|
||||
role?: string
|
||||
text?: string
|
||||
label?: string
|
||||
placeholder?: string
|
||||
testId?: string
|
||||
value?: unknown
|
||||
key?: string
|
||||
timeout?: number
|
||||
assertion?: Assertion
|
||||
state?: string
|
||||
path?: string
|
||||
fullPage?: boolean
|
||||
script?: string
|
||||
condition?: string
|
||||
}
|
||||
|
||||
export interface Assertion {
|
||||
matcher: string
|
||||
expected?: unknown
|
||||
not?: boolean
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Playwright test file from JSON definition
|
||||
*/
|
||||
export async function generatePlaywrightTest(
|
||||
packageName: string,
|
||||
packagesDir: string,
|
||||
outputDir: string
|
||||
): Promise<string> {
|
||||
// Read the test definition
|
||||
const testDefPath = join(packagesDir, packageName, 'playwright', 'tests.json')
|
||||
|
||||
if (!existsSync(testDefPath)) {
|
||||
throw new Error(`No playwright tests found for package: ${packageName}`)
|
||||
}
|
||||
|
||||
const testDefContent = await readFile(testDefPath, 'utf-8')
|
||||
const testDef: PlaywrightTestDefinition = JSON.parse(testDefContent)
|
||||
|
||||
// Generate TypeScript code
|
||||
const code = generateTestCode(testDef)
|
||||
|
||||
// Write to output directory
|
||||
const outputPath = join(outputDir, `${packageName}.spec.ts`)
|
||||
await mkdir(dirname(outputPath), { recursive: true })
|
||||
await writeFile(outputPath, code, 'utf-8')
|
||||
|
||||
return outputPath
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate TypeScript code from test definition
|
||||
*/
|
||||
function generateTestCode(testDef: PlaywrightTestDefinition): string {
|
||||
const lines: string[] = []
|
||||
|
||||
// Header
|
||||
lines.push(`/**`)
|
||||
lines.push(` * Auto-generated Playwright tests for ${testDef.package} package`)
|
||||
lines.push(` * Generated from: packages/${testDef.package}/playwright/tests.json`)
|
||||
lines.push(` * DO NOT EDIT - This file is auto-generated`)
|
||||
lines.push(` */`)
|
||||
lines.push(``)
|
||||
lines.push(`import { test, expect } from '@playwright/test'`)
|
||||
lines.push(``)
|
||||
|
||||
if (testDef.description) {
|
||||
lines.push(`/**`)
|
||||
lines.push(` * ${testDef.description}`)
|
||||
lines.push(` */`)
|
||||
}
|
||||
|
||||
lines.push(`test.describe('${testDef.package} Package Tests', () => {`)
|
||||
|
||||
// Generate setup hooks
|
||||
if (testDef.setup?.beforeAll) {
|
||||
lines.push(` test.beforeAll(async () => {`)
|
||||
testDef.setup.beforeAll.forEach(step => {
|
||||
lines.push(` // ${step.description || step.action}`)
|
||||
})
|
||||
lines.push(` })`)
|
||||
lines.push(``)
|
||||
}
|
||||
|
||||
if (testDef.setup?.beforeEach) {
|
||||
lines.push(` test.beforeEach(async ({ page }) => {`)
|
||||
testDef.setup.beforeEach.forEach(step => {
|
||||
lines.push(` // ${step.description || step.action}`)
|
||||
})
|
||||
lines.push(` })`)
|
||||
lines.push(``)
|
||||
}
|
||||
|
||||
// Generate test cases
|
||||
testDef.tests.forEach(testCase => {
|
||||
generateTestCase(testCase, lines)
|
||||
})
|
||||
|
||||
lines.push(`})`)
|
||||
lines.push(``)
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a single test case
|
||||
*/
|
||||
function generateTestCase(testCase: TestCase, lines: string[]): void {
|
||||
// Test declaration
|
||||
let testDecl = ' test'
|
||||
if (testCase.skip) testDecl += '.skip'
|
||||
if (testCase.only) testDecl += '.only'
|
||||
|
||||
testDecl += `('${testCase.name}', async ({ page }) => {`
|
||||
lines.push(testDecl)
|
||||
|
||||
// Test configuration
|
||||
if (testCase.timeout) {
|
||||
lines.push(` test.setTimeout(${testCase.timeout})`)
|
||||
}
|
||||
|
||||
// Test steps
|
||||
testCase.steps.forEach(step => {
|
||||
if (step.description) {
|
||||
lines.push(` // ${step.description}`)
|
||||
}
|
||||
|
||||
const stepCode = generateStepCode(step)
|
||||
lines.push(` ${stepCode}`)
|
||||
})
|
||||
|
||||
lines.push(` })`)
|
||||
lines.push(``)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate code for a single test step
|
||||
*/
|
||||
function generateStepCode(step: TestStep): string {
|
||||
switch (step.action) {
|
||||
case 'navigate':
|
||||
return `await page.goto('${step.url}')`
|
||||
|
||||
case 'click':
|
||||
return `await ${getLocator(step)}.click()`
|
||||
|
||||
case 'dblclick':
|
||||
return `await ${getLocator(step)}.dblclick()`
|
||||
|
||||
case 'fill':
|
||||
return `await ${getLocator(step)}.fill('${step.value}')`
|
||||
|
||||
case 'type':
|
||||
return `await ${getLocator(step)}.type('${step.value}')`
|
||||
|
||||
case 'select':
|
||||
return `await ${getLocator(step)}.selectOption('${step.value}')`
|
||||
|
||||
case 'check':
|
||||
return `await ${getLocator(step)}.check()`
|
||||
|
||||
case 'uncheck':
|
||||
return `await ${getLocator(step)}.uncheck()`
|
||||
|
||||
case 'hover':
|
||||
return `await ${getLocator(step)}.hover()`
|
||||
|
||||
case 'focus':
|
||||
return `await ${getLocator(step)}.focus()`
|
||||
|
||||
case 'press':
|
||||
return `await page.keyboard.press('${step.key}')`
|
||||
|
||||
case 'wait':
|
||||
return `await page.waitForTimeout(${step.timeout || 1000})`
|
||||
|
||||
case 'waitForSelector':
|
||||
return `await page.waitForSelector('${step.selector}'${step.timeout ? `, { timeout: ${step.timeout} }` : ''})`
|
||||
|
||||
case 'waitForNavigation':
|
||||
return `await page.waitForNavigation()`
|
||||
|
||||
case 'waitForLoadState':
|
||||
return `await page.waitForLoadState('${step.state || 'load'}')`
|
||||
|
||||
case 'screenshot':
|
||||
return `await page.screenshot({ path: '${step.path}'${step.fullPage ? ', fullPage: true' : ''} })`
|
||||
|
||||
case 'evaluate':
|
||||
return `await page.evaluate(() => { ${step.script} })`
|
||||
|
||||
case 'expect':
|
||||
return generateAssertion(step)
|
||||
|
||||
default:
|
||||
return `// Unknown action: ${step.action}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get locator string for a step
|
||||
*/
|
||||
function getLocator(step: TestStep): string {
|
||||
if (step.selector) {
|
||||
return `page.locator('${step.selector}')`
|
||||
}
|
||||
if (step.role) {
|
||||
const opts: string[] = []
|
||||
if (step.text) opts.push(`name: /${step.text}/i`)
|
||||
const optsStr = opts.length > 0 ? `{ ${opts.join(', ')} }` : ''
|
||||
return `page.getByRole('${step.role}'${optsStr ? `, ${optsStr}` : ''})`
|
||||
}
|
||||
if (step.text) {
|
||||
return `page.getByText('${step.text}')`
|
||||
}
|
||||
if (step.label) {
|
||||
return `page.getByLabel('${step.label}')`
|
||||
}
|
||||
if (step.placeholder) {
|
||||
return `page.getByPlaceholder('${step.placeholder}')`
|
||||
}
|
||||
if (step.testId) {
|
||||
return `page.getByTestId('${step.testId}')`
|
||||
}
|
||||
return 'page'
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate assertion code
|
||||
*/
|
||||
function generateAssertion(step: TestStep): string {
|
||||
if (!step.assertion) {
|
||||
return '// No assertion specified'
|
||||
}
|
||||
|
||||
const locator = getLocator(step)
|
||||
const { matcher, expected, not, timeout } = step.assertion
|
||||
|
||||
let assertion = `await expect(${locator})`
|
||||
if (not) assertion += '.not'
|
||||
|
||||
assertion += `.${matcher}(`
|
||||
|
||||
// Add expected value if needed
|
||||
if (expected !== undefined) {
|
||||
if (typeof expected === 'string') {
|
||||
assertion += `'${expected}'`
|
||||
} else if (typeof expected === 'number') {
|
||||
assertion += expected.toString()
|
||||
} else {
|
||||
assertion += JSON.stringify(expected)
|
||||
}
|
||||
}
|
||||
|
||||
assertion += ')'
|
||||
|
||||
// Add timeout if specified
|
||||
if (timeout) {
|
||||
assertion = assertion.slice(0, -1) + `, { timeout: ${timeout} })`
|
||||
}
|
||||
|
||||
return assertion
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover all packages with Playwright tests
|
||||
*/
|
||||
export async function discoverPlaywrightPackages(packagesDir: string): Promise<string[]> {
|
||||
const { readdir } = await import('fs/promises')
|
||||
const packages: string[] = []
|
||||
|
||||
const packageDirs = await readdir(packagesDir, { withFileTypes: true })
|
||||
|
||||
for (const dir of packageDirs) {
|
||||
if (dir.isDirectory()) {
|
||||
const testPath = join(packagesDir, dir.name, 'playwright', 'tests.json')
|
||||
if (existsSync(testPath)) {
|
||||
packages.push(dir.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return packages
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate tests for all packages
|
||||
*/
|
||||
export async function generateAllPlaywrightTests(
|
||||
packagesDir: string,
|
||||
outputDir: string
|
||||
): Promise<string[]> {
|
||||
const packages = await discoverPlaywrightPackages(packagesDir)
|
||||
const generated: string[] = []
|
||||
|
||||
for (const pkg of packages) {
|
||||
try {
|
||||
const path = await generatePlaywrightTest(pkg, packagesDir, outputDir)
|
||||
generated.push(path)
|
||||
console.log(`✓ Generated: ${path}`)
|
||||
} catch (error) {
|
||||
console.error(`✗ Failed to generate tests for ${pkg}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
return generated
|
||||
}
|
||||
16
e2e/json-packages.spec.ts
Normal file
16
e2e/json-packages.spec.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* JSON-Driven Package Tests
|
||||
*
|
||||
* This test file dynamically loads and executes all Playwright tests
|
||||
* defined in packages/*/playwright/tests.json
|
||||
*
|
||||
* No code generation - tests are interpreted directly from JSON at runtime.
|
||||
*/
|
||||
|
||||
import { test } from '@playwright/test'
|
||||
import { resolve } from 'path'
|
||||
import { loadAllPackageTests } from './json-runner/playwright-json-runner'
|
||||
|
||||
// Load all package tests from JSON
|
||||
const packagesDir = resolve(__dirname, '../packages')
|
||||
await loadAllPackageTests(packagesDir, test)
|
||||
139
e2e/json-runner/README.md
Normal file
139
e2e/json-runner/README.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# JSON Playwright Test Runner
|
||||
|
||||
**No code generation - tests are interpreted directly from JSON at runtime.**
|
||||
|
||||
This is the true meta/abstract approach: the JSON itself is executable, not just a template for code generation.
|
||||
|
||||
## Philosophy
|
||||
|
||||
Instead of generating `.spec.ts` files from JSON, we **directly execute** the JSON test definitions. This keeps tests as pure data that's interpreted at runtime, staying true to the 95% configuration rule.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Discovery**: Scans `packages/*/playwright/tests.json` files
|
||||
2. **Loading**: Reads JSON test definitions at runtime
|
||||
3. **Interpretation**: Executes test steps directly from JSON
|
||||
4. **No Intermediate**: No code generation step - JSON → Execution
|
||||
|
||||
## Usage
|
||||
|
||||
### Run JSON-Defined Package Tests
|
||||
|
||||
```bash
|
||||
# Run all JSON-defined package tests
|
||||
npm run test:e2e:json
|
||||
|
||||
# Or run directly
|
||||
npm run test:e2e -- e2e/json-packages.spec.ts
|
||||
|
||||
# With UI mode
|
||||
npm run test:e2e:ui -- e2e/json-packages.spec.ts
|
||||
```
|
||||
|
||||
### How Tests Are Loaded
|
||||
|
||||
The `json-packages.spec.ts` file automatically discovers and loads all tests:
|
||||
|
||||
```typescript
|
||||
import { loadAllPackageTests } from './json-runner/playwright-json-runner'
|
||||
|
||||
// Discovers packages/*/playwright/tests.json and registers tests
|
||||
await loadAllPackageTests(packagesDir, test)
|
||||
```
|
||||
|
||||
## Example: JSON Test Definition
|
||||
|
||||
`packages/ui_home/playwright/tests.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://metabuilder.dev/schemas/package-playwright.schema.json",
|
||||
"package": "ui_home",
|
||||
"tests": [{
|
||||
"name": "should display hero section",
|
||||
"tags": ["@smoke", "@ui"],
|
||||
"steps": [
|
||||
{
|
||||
"description": "Navigate to home page",
|
||||
"action": "navigate",
|
||||
"url": "/"
|
||||
},
|
||||
{
|
||||
"description": "Wait for page load",
|
||||
"action": "waitForLoadState",
|
||||
"state": "domcontentloaded"
|
||||
},
|
||||
{
|
||||
"description": "Verify hero title visible",
|
||||
"action": "expect",
|
||||
"selector": ".hero-title",
|
||||
"assertion": {
|
||||
"matcher": "toBeVisible"
|
||||
}
|
||||
}
|
||||
]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
**Executed directly - no intermediate code generation!**
|
||||
|
||||
## Supported Actions
|
||||
|
||||
- **Navigation**: `navigate`, `waitForNavigation`, `waitForLoadState`
|
||||
- **Interactions**: `click`, `dblclick`, `fill`, `type`, `select`, `check`, `uncheck`, `hover`, `focus`, `press`
|
||||
- **Assertions**: `expect` with all Playwright matchers
|
||||
- **Utilities**: `wait`, `waitForSelector`, `screenshot`, `evaluate`
|
||||
|
||||
## Supported Selectors
|
||||
|
||||
- `selector` - CSS selector
|
||||
- `role` - ARIA role with optional text
|
||||
- `text` - Text content
|
||||
- `label` - Form label
|
||||
- `placeholder` - Input placeholder
|
||||
- `testId` - data-testid attribute
|
||||
|
||||
## Supported Assertion Matchers
|
||||
|
||||
All standard Playwright matchers:
|
||||
- Visibility: `toBeVisible`, `toBeHidden`
|
||||
- State: `toBeEnabled`, `toBeDisabled`, `toBeChecked`, `toBeFocused`, `toBeEmpty`
|
||||
- Content: `toHaveText`, `toContainText`, `toHaveValue`
|
||||
- Count: `toHaveCount`
|
||||
- Attributes: `toHaveAttribute`, `toHaveClass`, `toHaveCSS`
|
||||
- Page: `toHaveURL`, `toHaveTitle`
|
||||
|
||||
## Benefits of JSON Execution
|
||||
|
||||
1. **True Meta Architecture**: Tests are data, not code
|
||||
2. **No Build Step**: JSON is directly executable
|
||||
3. **Runtime Interpretation**: Changes take effect immediately
|
||||
4. **Single Source of Truth**: JSON is the only definition
|
||||
5. **Package Ownership**: Each package owns its test data
|
||||
6. **Schema Validated**: Tests conform to JSON schema
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
e2e/
|
||||
├── json-runner/
|
||||
│ └── playwright-json-runner.ts # JSON test interpreter
|
||||
├── json-packages.spec.ts # Auto-loads all package tests
|
||||
└── smoke.spec.ts # Manual smoke tests
|
||||
```
|
||||
|
||||
## vs Code Generation
|
||||
|
||||
| Approach | Source of Truth | Runtime | Changes |
|
||||
|----------|----------------|---------|---------|
|
||||
| **Code Generation** | JSON → Generate `.spec.ts` | Executes TypeScript | Requires regeneration |
|
||||
| **JSON Execution** ✅ | JSON (only) | Interprets JSON | Immediate effect |
|
||||
|
||||
JSON execution is more meta/abstract - the configuration itself is executable!
|
||||
|
||||
## See Also
|
||||
|
||||
- `schemas/package-schemas/playwright.schema.json` - JSON Schema
|
||||
- `schemas/package-schemas/PLAYWRIGHT_SCHEMA_README.md` - Schema documentation
|
||||
- `packages/*/playwright/tests.json` - Test definitions
|
||||
356
e2e/json-runner/playwright-json-runner.ts
Normal file
356
e2e/json-runner/playwright-json-runner.ts
Normal file
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* JSON Playwright Test Runner
|
||||
*
|
||||
* Directly executes Playwright tests from JSON definitions without code generation.
|
||||
* Tests are interpreted at runtime from packages/*/playwright/tests.json
|
||||
*
|
||||
* This is the meta/abstract approach - JSON itself is executable, not just a template.
|
||||
*/
|
||||
|
||||
import { test as baseTest, expect, Page } from '@playwright/test'
|
||||
import { readFile, readdir } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { existsSync } from 'fs'
|
||||
|
||||
interface PlaywrightTestDefinition {
|
||||
$schema: string
|
||||
package: string
|
||||
version?: string
|
||||
description?: string
|
||||
baseURL?: string
|
||||
setup?: {
|
||||
beforeAll?: SetupStep[]
|
||||
beforeEach?: SetupStep[]
|
||||
afterEach?: SetupStep[]
|
||||
afterAll?: SetupStep[]
|
||||
}
|
||||
fixtures?: Record<string, unknown>
|
||||
tests: TestCase[]
|
||||
}
|
||||
|
||||
interface SetupStep {
|
||||
action: string
|
||||
description?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface TestCase {
|
||||
name: string
|
||||
description?: string
|
||||
skip?: boolean
|
||||
only?: boolean
|
||||
timeout?: number
|
||||
retries?: number
|
||||
tags?: string[]
|
||||
fixtures?: Record<string, unknown>
|
||||
steps: TestStep[]
|
||||
}
|
||||
|
||||
interface TestStep {
|
||||
description?: string
|
||||
action: string
|
||||
url?: string
|
||||
selector?: string
|
||||
role?: string
|
||||
text?: string
|
||||
label?: string
|
||||
placeholder?: string
|
||||
testId?: string
|
||||
value?: unknown
|
||||
key?: string
|
||||
timeout?: number
|
||||
assertion?: Assertion
|
||||
state?: string
|
||||
path?: string
|
||||
fullPage?: boolean
|
||||
script?: string
|
||||
condition?: string
|
||||
}
|
||||
|
||||
interface Assertion {
|
||||
matcher: string
|
||||
expected?: unknown
|
||||
not?: boolean
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover all packages with Playwright test definitions
|
||||
*/
|
||||
export async function discoverTestPackages(packagesDir: string): Promise<string[]> {
|
||||
const packages: string[] = []
|
||||
|
||||
if (!existsSync(packagesDir)) {
|
||||
return packages
|
||||
}
|
||||
|
||||
const packageDirs = await readdir(packagesDir, { withFileTypes: true })
|
||||
|
||||
for (const dir of packageDirs) {
|
||||
if (dir.isDirectory()) {
|
||||
const testPath = join(packagesDir, dir.name, 'playwright', 'tests.json')
|
||||
if (existsSync(testPath)) {
|
||||
packages.push(dir.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return packages
|
||||
}
|
||||
|
||||
/**
|
||||
* Load test definition from package
|
||||
*/
|
||||
export async function loadTestDefinition(packageName: string, packagesDir: string): Promise<PlaywrightTestDefinition> {
|
||||
const testPath = join(packagesDir, packageName, 'playwright', 'tests.json')
|
||||
const content = await readFile(testPath, 'utf-8')
|
||||
return JSON.parse(content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a test step
|
||||
*/
|
||||
async function executeStep(step: TestStep, page: Page): Promise<void> {
|
||||
if (step.description) {
|
||||
// Log step description for debugging
|
||||
console.log(` → ${step.description}`)
|
||||
}
|
||||
|
||||
switch (step.action) {
|
||||
case 'navigate':
|
||||
await page.goto(step.url!)
|
||||
break
|
||||
|
||||
case 'click':
|
||||
await getLocator(step, page).click()
|
||||
break
|
||||
|
||||
case 'dblclick':
|
||||
await getLocator(step, page).dblclick()
|
||||
break
|
||||
|
||||
case 'fill':
|
||||
await getLocator(step, page).fill(String(step.value))
|
||||
break
|
||||
|
||||
case 'type':
|
||||
await getLocator(step, page).pressSequentially(String(step.value))
|
||||
break
|
||||
|
||||
case 'select':
|
||||
await getLocator(step, page).selectOption(String(step.value))
|
||||
break
|
||||
|
||||
case 'check':
|
||||
await getLocator(step, page).check()
|
||||
break
|
||||
|
||||
case 'uncheck':
|
||||
await getLocator(step, page).uncheck()
|
||||
break
|
||||
|
||||
case 'hover':
|
||||
await getLocator(step, page).hover()
|
||||
break
|
||||
|
||||
case 'focus':
|
||||
await getLocator(step, page).focus()
|
||||
break
|
||||
|
||||
case 'press':
|
||||
await page.keyboard.press(step.key!)
|
||||
break
|
||||
|
||||
case 'wait':
|
||||
await page.waitForTimeout(step.timeout || 1000)
|
||||
break
|
||||
|
||||
case 'waitForSelector':
|
||||
await page.waitForSelector(step.selector!, step.timeout ? { timeout: step.timeout } : undefined)
|
||||
break
|
||||
|
||||
case 'waitForNavigation':
|
||||
await page.waitForLoadState('networkidle')
|
||||
break
|
||||
|
||||
case 'waitForLoadState':
|
||||
await page.waitForLoadState((step.state || 'load') as 'load' | 'domcontentloaded' | 'networkidle')
|
||||
break
|
||||
|
||||
case 'screenshot':
|
||||
await page.screenshot({
|
||||
path: step.path,
|
||||
fullPage: step.fullPage
|
||||
})
|
||||
break
|
||||
|
||||
case 'evaluate':
|
||||
await page.evaluate(step.script!)
|
||||
break
|
||||
|
||||
case 'expect':
|
||||
await executeAssertion(step, page)
|
||||
break
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown action: ${step.action}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get locator for a step
|
||||
*/
|
||||
function getLocator(step: TestStep, page: Page) {
|
||||
if (step.selector) {
|
||||
return page.locator(step.selector)
|
||||
}
|
||||
if (step.role) {
|
||||
const options: any = {}
|
||||
if (step.text) options.name = new RegExp(step.text, 'i')
|
||||
return page.getByRole(step.role as any, options)
|
||||
}
|
||||
if (step.text) {
|
||||
return page.getByText(step.text)
|
||||
}
|
||||
if (step.label) {
|
||||
return page.getByLabel(step.label)
|
||||
}
|
||||
if (step.placeholder) {
|
||||
return page.getByPlaceholder(step.placeholder)
|
||||
}
|
||||
if (step.testId) {
|
||||
return page.getByTestId(step.testId)
|
||||
}
|
||||
throw new Error('No selector specified for step')
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an assertion
|
||||
*/
|
||||
async function executeAssertion(step: TestStep, page: Page): Promise<void> {
|
||||
if (!step.assertion) {
|
||||
throw new Error('No assertion specified')
|
||||
}
|
||||
|
||||
const locator = getLocator(step, page)
|
||||
const { matcher, expected, not, timeout } = step.assertion
|
||||
|
||||
let assertion = expect(locator)
|
||||
if (not) assertion = assertion.not as any
|
||||
|
||||
const options = timeout ? { timeout } : undefined
|
||||
|
||||
// Execute the matcher
|
||||
switch (matcher) {
|
||||
case 'toBeVisible':
|
||||
await assertion.toBeVisible(options)
|
||||
break
|
||||
case 'toBeHidden':
|
||||
await assertion.toBeHidden(options)
|
||||
break
|
||||
case 'toBeEnabled':
|
||||
await assertion.toBeEnabled(options)
|
||||
break
|
||||
case 'toBeDisabled':
|
||||
await assertion.toBeDisabled(options)
|
||||
break
|
||||
case 'toBeChecked':
|
||||
await assertion.toBeChecked(options)
|
||||
break
|
||||
case 'toBeFocused':
|
||||
await assertion.toBeFocused(options)
|
||||
break
|
||||
case 'toBeEmpty':
|
||||
await assertion.toBeEmpty(options)
|
||||
break
|
||||
case 'toHaveText':
|
||||
await assertion.toHaveText(String(expected), options)
|
||||
break
|
||||
case 'toContainText':
|
||||
await assertion.toContainText(String(expected), options)
|
||||
break
|
||||
case 'toHaveValue':
|
||||
await assertion.toHaveValue(String(expected), options)
|
||||
break
|
||||
case 'toHaveCount':
|
||||
await assertion.toHaveCount(Number(expected), options)
|
||||
break
|
||||
case 'toHaveAttribute':
|
||||
// Expected should be [name, value]
|
||||
if (Array.isArray(expected) && expected.length === 2) {
|
||||
await assertion.toHaveAttribute(expected[0], expected[1], options)
|
||||
}
|
||||
break
|
||||
case 'toHaveClass':
|
||||
await assertion.toHaveClass(expected as any, options)
|
||||
break
|
||||
case 'toHaveCSS':
|
||||
// Expected should be [name, value]
|
||||
if (Array.isArray(expected) && expected.length === 2) {
|
||||
await assertion.toHaveCSS(expected[0], expected[1], options)
|
||||
}
|
||||
break
|
||||
case 'toHaveURL':
|
||||
await (assertion as any).toHaveURL(String(expected), options)
|
||||
break
|
||||
case 'toHaveTitle':
|
||||
await (assertion as any).toHaveTitle(String(expected), options)
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unknown matcher: ${matcher}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register tests from a JSON definition
|
||||
*/
|
||||
export function registerTestsFromJSON(testDef: PlaywrightTestDefinition, testFn = baseTest) {
|
||||
testFn.describe(`${testDef.package} Package Tests (from JSON)`, () => {
|
||||
// Setup hooks
|
||||
if (testDef.setup?.beforeAll) {
|
||||
testFn.beforeAll(async () => {
|
||||
console.log(`[Setup] beforeAll for ${testDef.package}`)
|
||||
// Setup steps would be executed here
|
||||
})
|
||||
}
|
||||
|
||||
if (testDef.setup?.beforeEach) {
|
||||
testFn.beforeEach(async ({ page }) => {
|
||||
console.log(`[Setup] beforeEach for ${testDef.package}`)
|
||||
// Setup steps would be executed here
|
||||
})
|
||||
}
|
||||
|
||||
// Register each test
|
||||
testDef.tests.forEach(testCase => {
|
||||
let test = testFn
|
||||
if (testCase.skip) test = test.skip
|
||||
if (testCase.only) test = test.only
|
||||
|
||||
test(testCase.name, async ({ page }) => {
|
||||
if (testCase.timeout) {
|
||||
test.setTimeout(testCase.timeout)
|
||||
}
|
||||
|
||||
console.log(`\n[Test] ${testCase.name}`)
|
||||
|
||||
// Execute all steps
|
||||
for (const step of testCase.steps) {
|
||||
await executeStep(step, page)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and register all package tests
|
||||
*/
|
||||
export async function loadAllPackageTests(packagesDir: string, testFn = baseTest) {
|
||||
const packages = await discoverTestPackages(packagesDir)
|
||||
|
||||
for (const packageName of packages) {
|
||||
const testDef = await loadTestDefinition(packageName, packagesDir)
|
||||
registerTestsFromJSON(testDef, testFn)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user