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:headed": "playwright test --headed",
|
||||||
"test:e2e:debug": "playwright test --debug",
|
"test:e2e:debug": "playwright test --debug",
|
||||||
"test:e2e:report": "playwright show-report",
|
"test:e2e:report": "playwright show-report",
|
||||||
"test:generate": "tsx e2e/generators/generate.ts",
|
"test:e2e:json": "playwright test e2e/json-packages.spec.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"
|
|
||||||
},
|
},
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"dbal/development",
|
"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