mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-25 06:14:59 +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)
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,7 @@
|
||||
"test:e2e:headed": "playwright test --headed",
|
||||
"test:e2e:debug": "playwright test --debug",
|
||||
"test:e2e:report": "playwright show-report",
|
||||
"test:generate": "tsx e2e/generators/generate.ts",
|
||||
"test:generate:watch": "tsx watch e2e/generators/generate.ts",
|
||||
"storybook:generate": "tsx storybook/generators/generate.ts",
|
||||
"storybook:generate:watch": "tsx watch storybook/generators/generate.ts"
|
||||
"test:e2e:json": "playwright test e2e/json-packages.spec.ts"
|
||||
},
|
||||
"workspaces": [
|
||||
"dbal/development",
|
||||
|
||||
2
storybook/generators/.gitignore
vendored
2
storybook/generators/.gitignore
vendored
@@ -1,2 +0,0 @@
|
||||
# Ignore generated story files
|
||||
../generated/
|
||||
@@ -1,114 +0,0 @@
|
||||
# Storybook Story Generators
|
||||
|
||||
This folder contains tools for generating Storybook stories from declarative JSON definitions in packages.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
storybook/
|
||||
├── generators/
|
||||
│ ├── storybook-generator.ts # Core generator logic
|
||||
│ └── generate.ts # CLI script
|
||||
├── generated/ # Generated .stories.tsx files (gitignored)
|
||||
└── .storybook/ # Storybook configuration
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Generate All Package Stories
|
||||
|
||||
```bash
|
||||
# From project root
|
||||
npm run storybook:generate
|
||||
|
||||
# Or with watch mode
|
||||
npm run storybook:generate:watch
|
||||
```
|
||||
|
||||
### Generate Specific Package Stories
|
||||
|
||||
```bash
|
||||
npm run storybook:generate ui_home
|
||||
```
|
||||
|
||||
### Run Storybook
|
||||
|
||||
```bash
|
||||
cd storybook
|
||||
npm run storybook
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Discovery**: Scans `packages/*/storybook/stories.json` files
|
||||
2. **Parsing**: Reads JSON story definitions
|
||||
3. **Generation**: Converts to TypeScript `.stories.tsx` files
|
||||
4. **Output**: Writes to `storybook/generated/`
|
||||
|
||||
## Package Story Definitions
|
||||
|
||||
Packages define stories in `storybook/stories.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://metabuilder.dev/schemas/package-storybook.schema.json",
|
||||
"title": "Home Page Components",
|
||||
"stories": [
|
||||
{
|
||||
"name": "HomePage",
|
||||
"render": "home_page",
|
||||
"description": "Complete home page with all sections"
|
||||
},
|
||||
{
|
||||
"name": "HeroSection",
|
||||
"render": "hero_section",
|
||||
"args": {
|
||||
"title": "Build Anything, Visually"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Generated Output Example
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Auto-generated Storybook stories for ui_home package
|
||||
* Generated from: packages/ui_home/storybook/stories.json
|
||||
* DO NOT EDIT - This file is auto-generated
|
||||
*/
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { JSONComponentRenderer } from '@/components/JSONComponentRenderer'
|
||||
|
||||
const pkg = await loadJSONPackage(join(packagesDir, 'ui_home'))
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Home Page Components',
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const HomePage: Story = {
|
||||
name: 'HomePage',
|
||||
render: () => {
|
||||
const component = pkg.components?.find(c => c.id === 'home_page')
|
||||
return <JSONComponentRenderer component={component} allComponents={pkg.components} />
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
- **Data-Driven**: Stories are JSON configuration, not code
|
||||
- **Package-Scoped**: Each package owns its story definitions
|
||||
- **Auto-Generated**: No manual TypeScript story writing
|
||||
- **Schema-Validated**: Stories conform to JSON schema
|
||||
- **Meta Architecture**: Stories themselves are declarative
|
||||
|
||||
## See Also
|
||||
|
||||
- `schemas/package-schemas/storybook_schema.json` - JSON Schema
|
||||
- `packages/*/storybook/` - Package story definitions
|
||||
@@ -1,48 +0,0 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Generate Storybook stories from JSON definitions
|
||||
*
|
||||
* Usage:
|
||||
* npm run storybook:generate # Generate all package stories
|
||||
* npm run storybook:generate ui_home # Generate specific package stories
|
||||
*/
|
||||
|
||||
import { resolve } from 'path'
|
||||
import { generateStorybookStory, generateAllStorybookStories, discoverStorybookPackages } from './storybook-generator'
|
||||
|
||||
async function main() {
|
||||
const packageName = process.argv[2]
|
||||
const projectRoot = resolve(__dirname, '../..')
|
||||
const packagesDir = resolve(projectRoot, 'packages')
|
||||
const outputDir = resolve(projectRoot, 'storybook/generated')
|
||||
|
||||
console.log('📚 Storybook Story 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 stories for package: ${packageName}`)
|
||||
const outputPath = await generateStorybookStory(packageName, packagesDir, outputDir)
|
||||
console.log(`✅ Generated: ${outputPath}`)
|
||||
} else {
|
||||
// Discover and generate all
|
||||
const packages = await discoverStorybookPackages(packagesDir)
|
||||
console.log(`Found ${packages.length} packages with Storybook stories:`)
|
||||
packages.forEach(pkg => console.log(` - ${pkg}`))
|
||||
console.log('')
|
||||
|
||||
const generated = await generateAllStorybookStories(packagesDir, outputDir)
|
||||
console.log('')
|
||||
console.log(`✅ Generated ${generated.length} story files`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error:', error)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -1,231 +0,0 @@
|
||||
/**
|
||||
* Storybook Story Generator
|
||||
*
|
||||
* Generates Storybook .stories.tsx files from declarative JSON story definitions
|
||||
* in packages/*/storybook/stories.json
|
||||
*
|
||||
* Usage:
|
||||
* import { generateStorybookStories } from '@/lib/generators/storybook-generator'
|
||||
* await generateStorybookStories('ui_home')
|
||||
*/
|
||||
|
||||
import { readFile, writeFile, mkdir } from 'fs/promises'
|
||||
import { join, dirname } from 'path'
|
||||
import { existsSync } from 'fs'
|
||||
|
||||
export interface StorybookDefinition {
|
||||
$schema: string
|
||||
featured?: boolean
|
||||
excludeFromDiscovery?: boolean
|
||||
category?: string
|
||||
title?: string
|
||||
description?: string
|
||||
stories: Story[]
|
||||
renders?: Record<string, RenderMetadata>
|
||||
defaultContext?: Record<string, unknown>
|
||||
contextVariants?: ContextVariant[]
|
||||
scripts?: {
|
||||
renderFunctions?: string[]
|
||||
ignoredScripts?: string[]
|
||||
}
|
||||
argTypes?: Record<string, unknown>
|
||||
parameters?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface Story {
|
||||
name: string
|
||||
render: string
|
||||
description?: string
|
||||
type?: string
|
||||
args?: Record<string, unknown>
|
||||
argTypes?: Record<string, unknown>
|
||||
parameters?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface RenderMetadata {
|
||||
description?: string
|
||||
featured?: boolean
|
||||
}
|
||||
|
||||
export interface ContextVariant {
|
||||
name: string
|
||||
description?: string
|
||||
context: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Storybook story file from JSON definition
|
||||
*/
|
||||
export async function generateStorybookStory(
|
||||
packageName: string,
|
||||
packagesDir: string,
|
||||
outputDir: string
|
||||
): Promise<string> {
|
||||
// Read the story definition
|
||||
const storyDefPath = join(packagesDir, packageName, 'storybook', 'stories.json')
|
||||
|
||||
if (!existsSync(storyDefPath)) {
|
||||
throw new Error(`No storybook stories found for package: ${packageName}`)
|
||||
}
|
||||
|
||||
const storyDefContent = await readFile(storyDefPath, 'utf-8')
|
||||
const storyDef: StorybookDefinition = JSON.parse(storyDefContent)
|
||||
|
||||
// Generate TypeScript code
|
||||
const code = generateStoryCode(storyDef, packageName)
|
||||
|
||||
// Write to output directory
|
||||
const outputPath = join(outputDir, `${packageName}.stories.tsx`)
|
||||
await mkdir(dirname(outputPath), { recursive: true })
|
||||
await writeFile(outputPath, code, 'utf-8')
|
||||
|
||||
return outputPath
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate TypeScript code from story definition
|
||||
*/
|
||||
function generateStoryCode(storyDef: StorybookDefinition, packageName: string): string {
|
||||
const lines: string[] = []
|
||||
|
||||
// Header
|
||||
lines.push(`/**`)
|
||||
lines.push(` * Auto-generated Storybook stories for ${packageName} package`)
|
||||
lines.push(` * Generated from: packages/${packageName}/storybook/stories.json`)
|
||||
lines.push(` * DO NOT EDIT - This file is auto-generated`)
|
||||
lines.push(` */`)
|
||||
lines.push(``)
|
||||
lines.push(`import type { Meta, StoryObj } from '@storybook/react'`)
|
||||
lines.push(`import { JSONComponentRenderer } from '@/components/JSONComponentRenderer'`)
|
||||
lines.push(`import { loadJSONPackage } from '@/lib/packages/json/functions/load-json-package'`)
|
||||
lines.push(`import { join } from 'path'`)
|
||||
lines.push(``)
|
||||
|
||||
// Load package at build time
|
||||
lines.push(`// Load package components`)
|
||||
lines.push(`const packagesDir = join(process.cwd(), '../../packages')`)
|
||||
lines.push(`const pkg = await loadJSONPackage(join(packagesDir, '${packageName}'))`)
|
||||
lines.push(``)
|
||||
|
||||
// Meta configuration
|
||||
lines.push(`const meta: Meta = {`)
|
||||
lines.push(` title: '${storyDef.category ? storyDef.category + '/' : ''}${storyDef.title || packageName}',`)
|
||||
|
||||
if (storyDef.description) {
|
||||
lines.push(` parameters: {`)
|
||||
lines.push(` docs: {`)
|
||||
lines.push(` description: {`)
|
||||
lines.push(` component: '${storyDef.description}'`)
|
||||
lines.push(` }`)
|
||||
lines.push(` },`)
|
||||
if (storyDef.parameters) {
|
||||
lines.push(` ...${JSON.stringify(storyDef.parameters, null, 2).split('\n').join('\n ')}`)
|
||||
}
|
||||
lines.push(` },`)
|
||||
}
|
||||
|
||||
if (storyDef.argTypes) {
|
||||
lines.push(` argTypes: ${JSON.stringify(storyDef.argTypes, null, 2).split('\n').join('\n ')},`)
|
||||
}
|
||||
|
||||
lines.push(`}`)
|
||||
lines.push(``)
|
||||
lines.push(`export default meta`)
|
||||
lines.push(`type Story = StoryObj<typeof meta>`)
|
||||
lines.push(``)
|
||||
|
||||
// Generate individual stories
|
||||
storyDef.stories.forEach(story => {
|
||||
generateStory(story, storyDef, lines)
|
||||
})
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a single story
|
||||
*/
|
||||
function generateStory(story: Story, storyDef: StorybookDefinition, lines: string[]): void {
|
||||
const storyName = story.name.replace(/\s+/g, '')
|
||||
|
||||
lines.push(`/**`)
|
||||
lines.push(` * ${story.description || story.name}`)
|
||||
lines.push(` */`)
|
||||
lines.push(`export const ${storyName}: Story = {`)
|
||||
lines.push(` name: '${story.name}',`)
|
||||
|
||||
// Render function
|
||||
lines.push(` render: () => {`)
|
||||
lines.push(` const component = pkg.components?.find(c => c.id === '${story.render}' || c.name === '${story.render}')`)
|
||||
lines.push(` if (!component) {`)
|
||||
lines.push(` return <div style={{ padding: '1rem', border: '1px solid red' }}>`)
|
||||
lines.push(` Component '${story.render}' not found in package`)
|
||||
lines.push(` </div>`)
|
||||
lines.push(` }`)
|
||||
|
||||
if (story.args) {
|
||||
lines.push(` const args = ${JSON.stringify(story.args, null, 2).split('\n').join('\n ')}`)
|
||||
lines.push(` return <JSONComponentRenderer component={component} props={args} allComponents={pkg.components} />`)
|
||||
} else {
|
||||
lines.push(` return <JSONComponentRenderer component={component} allComponents={pkg.components} />`)
|
||||
}
|
||||
|
||||
lines.push(` },`)
|
||||
|
||||
// Args
|
||||
if (story.args) {
|
||||
lines.push(` args: ${JSON.stringify(story.args, null, 2).split('\n').join('\n ')},`)
|
||||
}
|
||||
|
||||
// Parameters
|
||||
if (story.parameters) {
|
||||
lines.push(` parameters: ${JSON.stringify(story.parameters, null, 2).split('\n').join('\n ')},`)
|
||||
}
|
||||
|
||||
lines.push(`}`)
|
||||
lines.push(``)
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover all packages with Storybook stories
|
||||
*/
|
||||
export async function discoverStorybookPackages(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 storyPath = join(packagesDir, dir.name, 'storybook', 'stories.json')
|
||||
if (existsSync(storyPath)) {
|
||||
packages.push(dir.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return packages
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate stories for all packages
|
||||
*/
|
||||
export async function generateAllStorybookStories(
|
||||
packagesDir: string,
|
||||
outputDir: string
|
||||
): Promise<string[]> {
|
||||
const packages = await discoverStorybookPackages(packagesDir)
|
||||
const generated: string[] = []
|
||||
|
||||
for (const pkg of packages) {
|
||||
try {
|
||||
const path = await generateStorybookStory(pkg, packagesDir, outputDir)
|
||||
generated.push(path)
|
||||
console.log(`✓ Generated: ${path}`)
|
||||
} catch (error) {
|
||||
console.error(`✗ Failed to generate stories for ${pkg}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
return generated
|
||||
}
|
||||
81
storybook/json-loader/DynamicStory.tsx
Normal file
81
storybook/json-loader/DynamicStory.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Dynamic Storybook Story Renderer
|
||||
*
|
||||
* Renders Storybook stories directly from JSON definitions.
|
||||
* Used by Storybook to display stories without code generation.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import type { StorybookDefinition, Story } from './storybook-json-loader'
|
||||
import { loadJSONPackage } from '../../frontends/nextjs/src/lib/packages/json/functions/load-json-package'
|
||||
import { JSONComponentRenderer } from '../../frontends/nextjs/src/components/JSONComponentRenderer'
|
||||
import { join } from 'path'
|
||||
|
||||
interface DynamicStoryProps {
|
||||
packageName: string
|
||||
storyDef: StorybookDefinition
|
||||
story: Story
|
||||
packagesDir: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single story from JSON definition
|
||||
*/
|
||||
export async function DynamicStory({
|
||||
packageName,
|
||||
storyDef,
|
||||
story,
|
||||
packagesDir
|
||||
}: DynamicStoryProps) {
|
||||
// Load the package components
|
||||
const pkg = await loadJSONPackage(join(packagesDir, packageName))
|
||||
|
||||
// Find the component to render
|
||||
const component = pkg.components?.find(
|
||||
c => c.id === story.render || c.name === story.render
|
||||
)
|
||||
|
||||
if (!component) {
|
||||
return (
|
||||
<div style={{ padding: '1rem', border: '1px solid red', borderRadius: '4px' }}>
|
||||
<strong>Component not found:</strong> {story.render}
|
||||
<br />
|
||||
<small>Package: {packageName}</small>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Merge story args with default context
|
||||
const props = {
|
||||
...storyDef.defaultContext,
|
||||
...story.args,
|
||||
}
|
||||
|
||||
return (
|
||||
<JSONComponentRenderer
|
||||
component={component}
|
||||
props={props}
|
||||
allComponents={pkg.components}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create story metadata for Storybook
|
||||
*/
|
||||
export function createStoryMeta(storyDef: StorybookDefinition) {
|
||||
return {
|
||||
title: storyDef.category
|
||||
? `${storyDef.category}/${storyDef.title}`
|
||||
: storyDef.title,
|
||||
parameters: {
|
||||
...storyDef.parameters,
|
||||
docs: {
|
||||
description: {
|
||||
component: storyDef.description
|
||||
}
|
||||
}
|
||||
},
|
||||
argTypes: storyDef.argTypes,
|
||||
}
|
||||
}
|
||||
166
storybook/json-loader/README.md
Normal file
166
storybook/json-loader/README.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# JSON Storybook Story Loader
|
||||
|
||||
**No code generation - stories are loaded and rendered directly from JSON at runtime.**
|
||||
|
||||
This is the true meta/abstract approach: the JSON itself defines renderable stories, not templates for code generation.
|
||||
|
||||
## Philosophy
|
||||
|
||||
Instead of generating `.stories.tsx` files from JSON, we **directly load and render** the JSON story definitions. This keeps stories as pure data that's interpreted at runtime, staying true to the 95% configuration rule.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Discovery**: Scans `packages/*/storybook/stories.json` files
|
||||
2. **Loading**: Reads JSON story definitions at runtime
|
||||
3. **Rendering**: Renders components directly from JSON via `DynamicStory` component
|
||||
4. **No Intermediate**: No code generation step - JSON → Render
|
||||
|
||||
## Usage
|
||||
|
||||
### Load Stories from JSON
|
||||
|
||||
```typescript
|
||||
import { loadAllStoryDefinitions } from './json-loader/storybook-json-loader'
|
||||
import { DynamicStory } from './json-loader/DynamicStory'
|
||||
|
||||
// Load all story definitions
|
||||
const storyDefs = await loadAllStoryDefinitions(packagesDir)
|
||||
|
||||
// Get a specific package's stories
|
||||
const uiHomeStories = storyDefs.get('ui_home')
|
||||
|
||||
// Render a story
|
||||
<DynamicStory
|
||||
packageName="ui_home"
|
||||
storyDef={uiHomeStories}
|
||||
story={uiHomeStories.stories[0]}
|
||||
packagesDir={packagesDir}
|
||||
/>
|
||||
```
|
||||
|
||||
### Example: JSON Story Definition
|
||||
|
||||
`packages/ui_home/storybook/stories.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://metabuilder.dev/schemas/package-storybook.schema.json",
|
||||
"title": "Home Page Components",
|
||||
"category": "UI",
|
||||
"description": "Landing page components",
|
||||
"stories": [
|
||||
{
|
||||
"name": "HomePage",
|
||||
"render": "home_page",
|
||||
"description": "Complete home page with all sections"
|
||||
},
|
||||
{
|
||||
"name": "HeroSection",
|
||||
"render": "hero_section",
|
||||
"args": {
|
||||
"title": "Build Anything, Visually",
|
||||
"subtitle": "A 6-level meta-architecture"
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"layout": "fullscreen"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Rendered directly - no intermediate code generation!**
|
||||
|
||||
## How Stories Are Rendered
|
||||
|
||||
1. `DynamicStory` component receives story definition
|
||||
2. Loads package components from `packages/{name}/components/ui.json`
|
||||
3. Finds component by `story.render` (matches component id or name)
|
||||
4. Passes `story.args` as props to `JSONComponentRenderer`
|
||||
5. Component renders from JSON definition
|
||||
|
||||
## Story Features
|
||||
|
||||
- **Args**: Pass props to components via `story.args`
|
||||
- **Parameters**: Configure Storybook display via `parameters`
|
||||
- **ArgTypes**: Define controls via `argTypes`
|
||||
- **Context Variants**: Multiple rendering contexts via `contextVariants`
|
||||
- **Default Context**: Shared context via `defaultContext`
|
||||
|
||||
## Benefits of JSON Loading
|
||||
|
||||
1. **True Meta Architecture**: Stories are data, not code
|
||||
2. **No Build Step**: JSON is directly loaded
|
||||
3. **Runtime Loading**: Changes take effect immediately
|
||||
4. **Single Source of Truth**: JSON is the only definition
|
||||
5. **Package Ownership**: Each package owns its story data
|
||||
6. **Schema Validated**: Stories conform to JSON schema
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
storybook/
|
||||
├── json-loader/
|
||||
│ ├── storybook-json-loader.ts # JSON story loader
|
||||
│ └── DynamicStory.tsx # Story renderer component
|
||||
└── .storybook/ # Storybook config
|
||||
```
|
||||
|
||||
## Integration with Storybook
|
||||
|
||||
Create a `.stories.tsx` file that uses the JSON loader:
|
||||
|
||||
```typescript
|
||||
// ui_home.stories.tsx
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { loadStoryDefinition } from '../json-loader/storybook-json-loader'
|
||||
import { DynamicStory } from '../json-loader/DynamicStory'
|
||||
|
||||
const storyDef = await loadStoryDefinition('ui_home', packagesDir)
|
||||
|
||||
const meta: Meta = {
|
||||
title: storyDef.title,
|
||||
parameters: storyDef.parameters,
|
||||
}
|
||||
|
||||
export default meta
|
||||
|
||||
// Each story is rendered from JSON
|
||||
export const HomePage: StoryObj = {
|
||||
render: () => <DynamicStory
|
||||
packageName="ui_home"
|
||||
storyDef={storyDef}
|
||||
story={storyDef.stories[0]}
|
||||
packagesDir={packagesDir}
|
||||
/>
|
||||
}
|
||||
```
|
||||
|
||||
Or create a single loader file that auto-discovers all packages:
|
||||
|
||||
```typescript
|
||||
// all-packages.stories.tsx
|
||||
import { loadAllStoryDefinitions } from '../json-loader/storybook-json-loader'
|
||||
|
||||
const allStories = await loadAllStoryDefinitions(packagesDir)
|
||||
|
||||
// Generate stories for each package dynamically
|
||||
for (const [packageName, storyDef] of allStories) {
|
||||
// Register stories...
|
||||
}
|
||||
```
|
||||
|
||||
## vs Code Generation
|
||||
|
||||
| Approach | Source of Truth | Runtime | Changes |
|
||||
|----------|----------------|---------|---------|
|
||||
| **Code Generation** | JSON → Generate `.stories.tsx` | Renders React | Requires regeneration |
|
||||
| **JSON Loading** ✅ | JSON (only) | Loads & renders JSON | Immediate effect |
|
||||
|
||||
JSON loading is more meta/abstract - the configuration itself is renderable!
|
||||
|
||||
## See Also
|
||||
|
||||
- `schemas/package-schemas/storybook_schema.json` - JSON Schema
|
||||
- `packages/*/storybook/stories.json` - Story definitions
|
||||
- `frontends/nextjs/src/components/JSONComponentRenderer.tsx` - Component renderer
|
||||
133
storybook/json-loader/storybook-json-loader.ts
Normal file
133
storybook/json-loader/storybook-json-loader.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* JSON Storybook Story Loader
|
||||
*
|
||||
* Directly loads and renders Storybook stories from JSON definitions without code generation.
|
||||
* Stories are interpreted at runtime from packages/*/storybook/stories.json
|
||||
*
|
||||
* This is the meta/abstract approach - JSON itself defines renderable stories.
|
||||
*/
|
||||
|
||||
import { readFile, readdir } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { existsSync } from 'fs'
|
||||
|
||||
export interface StorybookDefinition {
|
||||
$schema: string
|
||||
featured?: boolean
|
||||
excludeFromDiscovery?: boolean
|
||||
category?: string
|
||||
title?: string
|
||||
description?: string
|
||||
stories: Story[]
|
||||
renders?: Record<string, RenderMetadata>
|
||||
defaultContext?: Record<string, unknown>
|
||||
contextVariants?: ContextVariant[]
|
||||
scripts?: {
|
||||
renderFunctions?: string[]
|
||||
ignoredScripts?: string[]
|
||||
}
|
||||
argTypes?: Record<string, unknown>
|
||||
parameters?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface Story {
|
||||
name: string
|
||||
render: string
|
||||
description?: string
|
||||
type?: string
|
||||
args?: Record<string, unknown>
|
||||
argTypes?: Record<string, unknown>
|
||||
parameters?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface RenderMetadata {
|
||||
description?: string
|
||||
featured?: boolean
|
||||
}
|
||||
|
||||
export interface ContextVariant {
|
||||
name: string
|
||||
description?: string
|
||||
context: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover all packages with Storybook story definitions
|
||||
*/
|
||||
export async function discoverStoryPackages(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 storyPath = join(packagesDir, dir.name, 'storybook', 'stories.json')
|
||||
if (existsSync(storyPath)) {
|
||||
packages.push(dir.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return packages
|
||||
}
|
||||
|
||||
/**
|
||||
* Load story definition from package
|
||||
*/
|
||||
export async function loadStoryDefinition(
|
||||
packageName: string,
|
||||
packagesDir: string
|
||||
): Promise<StorybookDefinition> {
|
||||
const storyPath = join(packagesDir, packageName, 'storybook', 'stories.json')
|
||||
const content = await readFile(storyPath, 'utf-8')
|
||||
return JSON.parse(content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all story definitions
|
||||
*/
|
||||
export async function loadAllStoryDefinitions(
|
||||
packagesDir: string
|
||||
): Promise<Map<string, StorybookDefinition>> {
|
||||
const packages = await discoverStoryPackages(packagesDir)
|
||||
const definitions = new Map<string, StorybookDefinition>()
|
||||
|
||||
for (const packageName of packages) {
|
||||
const storyDef = await loadStoryDefinition(packageName, packagesDir)
|
||||
definitions.set(packageName, storyDef)
|
||||
}
|
||||
|
||||
return definitions
|
||||
}
|
||||
|
||||
/**
|
||||
* Get story metadata for display
|
||||
*/
|
||||
export function getStoryMeta(storyDef: StorybookDefinition) {
|
||||
return {
|
||||
title: storyDef.category
|
||||
? `${storyDef.category}/${storyDef.title || storyDef.$schema}`
|
||||
: storyDef.title || storyDef.$schema,
|
||||
description: storyDef.description,
|
||||
parameters: storyDef.parameters,
|
||||
argTypes: storyDef.argTypes,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a story by name
|
||||
*/
|
||||
export function findStory(storyDef: StorybookDefinition, storyName: string): Story | undefined {
|
||||
return storyDef.stories.find(s => s.name === storyName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all stories from a definition
|
||||
*/
|
||||
export function getAllStories(storyDef: StorybookDefinition): Story[] {
|
||||
return storyDef.stories
|
||||
}
|
||||
Reference in New Issue
Block a user