diff --git a/e2e/generators/.gitignore b/e2e/generators/.gitignore deleted file mode 100644 index 11442c98e..000000000 --- a/e2e/generators/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -# Ignore generated test files -../generated/ diff --git a/e2e/generators/README.md b/e2e/generators/README.md deleted file mode 100644 index adc3f6988..000000000 --- a/e2e/generators/README.md +++ /dev/null @@ -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 diff --git a/e2e/generators/generate.ts b/e2e/generators/generate.ts deleted file mode 100644 index 0f96e307f..000000000 --- a/e2e/generators/generate.ts +++ /dev/null @@ -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() diff --git a/e2e/generators/playwright-generator.ts b/e2e/generators/playwright-generator.ts deleted file mode 100644 index a3e0f6560..000000000 --- a/e2e/generators/playwright-generator.ts +++ /dev/null @@ -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 - 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 - 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 { - // 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 { - 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 { - 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 -} diff --git a/e2e/json-packages.spec.ts b/e2e/json-packages.spec.ts new file mode 100644 index 000000000..f8cd0174d --- /dev/null +++ b/e2e/json-packages.spec.ts @@ -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) diff --git a/e2e/json-runner/README.md b/e2e/json-runner/README.md new file mode 100644 index 000000000..5bd02f69f --- /dev/null +++ b/e2e/json-runner/README.md @@ -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 diff --git a/e2e/json-runner/playwright-json-runner.ts b/e2e/json-runner/playwright-json-runner.ts new file mode 100644 index 000000000..2ccb0fa93 --- /dev/null +++ b/e2e/json-runner/playwright-json-runner.ts @@ -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 + 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 + 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 { + 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 { + 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 { + 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 { + 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) + } +} diff --git a/package.json b/package.json index 61d3bd1f9..b2834cbb3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/storybook/generators/.gitignore b/storybook/generators/.gitignore deleted file mode 100644 index 104f206b8..000000000 --- a/storybook/generators/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -# Ignore generated story files -../generated/ diff --git a/storybook/generators/README.md b/storybook/generators/README.md deleted file mode 100644 index 20e1f8a2d..000000000 --- a/storybook/generators/README.md +++ /dev/null @@ -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 - -export const HomePage: Story = { - name: 'HomePage', - render: () => { - const component = pkg.components?.find(c => c.id === 'home_page') - return - }, -} -``` - -## 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 diff --git a/storybook/generators/generate.ts b/storybook/generators/generate.ts deleted file mode 100644 index 40ab5a3ac..000000000 --- a/storybook/generators/generate.ts +++ /dev/null @@ -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() diff --git a/storybook/generators/storybook-generator.ts b/storybook/generators/storybook-generator.ts deleted file mode 100644 index c2e00e826..000000000 --- a/storybook/generators/storybook-generator.ts +++ /dev/null @@ -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 - defaultContext?: Record - contextVariants?: ContextVariant[] - scripts?: { - renderFunctions?: string[] - ignoredScripts?: string[] - } - argTypes?: Record - parameters?: Record -} - -export interface Story { - name: string - render: string - description?: string - type?: string - args?: Record - argTypes?: Record - parameters?: Record -} - -export interface RenderMetadata { - description?: string - featured?: boolean -} - -export interface ContextVariant { - name: string - description?: string - context: Record -} - -/** - * Generate Storybook story file from JSON definition - */ -export async function generateStorybookStory( - packageName: string, - packagesDir: string, - outputDir: string -): Promise { - // 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`) - 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
`) - lines.push(` Component '${story.render}' not found in package`) - lines.push(`
`) - lines.push(` }`) - - if (story.args) { - lines.push(` const args = ${JSON.stringify(story.args, null, 2).split('\n').join('\n ')}`) - lines.push(` return `) - } else { - lines.push(` return `) - } - - 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 { - 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 { - 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 -} diff --git a/storybook/json-loader/DynamicStory.tsx b/storybook/json-loader/DynamicStory.tsx new file mode 100644 index 000000000..6ab2ea712 --- /dev/null +++ b/storybook/json-loader/DynamicStory.tsx @@ -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 ( +
+ Component not found: {story.render} +
+ Package: {packageName} +
+ ) + } + + // Merge story args with default context + const props = { + ...storyDef.defaultContext, + ...story.args, + } + + return ( + + ) +} + +/** + * 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, + } +} diff --git a/storybook/json-loader/README.md b/storybook/json-loader/README.md new file mode 100644 index 000000000..187f0e613 --- /dev/null +++ b/storybook/json-loader/README.md @@ -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 + +``` + +### 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: () => +} +``` + +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 diff --git a/storybook/json-loader/storybook-json-loader.ts b/storybook/json-loader/storybook-json-loader.ts new file mode 100644 index 000000000..07161401a --- /dev/null +++ b/storybook/json-loader/storybook-json-loader.ts @@ -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 + defaultContext?: Record + contextVariants?: ContextVariant[] + scripts?: { + renderFunctions?: string[] + ignoredScripts?: string[] + } + argTypes?: Record + parameters?: Record +} + +export interface Story { + name: string + render: string + description?: string + type?: string + args?: Record + argTypes?: Record + parameters?: Record +} + +export interface RenderMetadata { + description?: string + featured?: boolean +} + +export interface ContextVariant { + name: string + description?: string + context: Record +} + +/** + * Discover all packages with Storybook story definitions + */ +export async function discoverStoryPackages(packagesDir: string): Promise { + 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 { + 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> { + const packages = await discoverStoryPackages(packagesDir) + const definitions = new Map() + + 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 +}