Compare commits
8 Commits
116865e6d2
...
39655bc65f
| Author | SHA1 | Date |
|---|---|---|
|
|
39655bc65f | |
|
|
e0318d9da8 | |
|
|
4a983d64a7 | |
|
|
f25fcc686c | |
|
|
411cded4d0 | |
|
|
1d49e045db | |
|
|
7851fd8b80 | |
|
|
d83ce5e21f |
|
|
@ -10,6 +10,7 @@ permissions:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
bundle-and-publish:
|
bundle-and-publish:
|
||||||
|
if: ${{ false }} # Temporarily disabled while web bundles are paused.
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout BMAD-METHOD
|
- name: Checkout BMAD-METHOD
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ CLAUDE.local.md
|
||||||
|
|
||||||
# Bundler temporary files and generated bundles
|
# Bundler temporary files and generated bundles
|
||||||
.bundler-temp/
|
.bundler-temp/
|
||||||
|
web-bundles/
|
||||||
|
|
||||||
# Generated web bundles (built by CI, not committed)
|
# Generated web bundles (built by CI, not committed)
|
||||||
src/modules/bmm/sub-modules/
|
src/modules/bmm/sub-modules/
|
||||||
|
|
@ -60,6 +61,7 @@ _bmad-output
|
||||||
.claude
|
.claude
|
||||||
.codex
|
.codex
|
||||||
.github/chatmodes
|
.github/chatmodes
|
||||||
|
.github/agents
|
||||||
.agent
|
.agent
|
||||||
.agentvibes/
|
.agentvibes/
|
||||||
.kiro/
|
.kiro/
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# BMad Method
|

|
||||||
|
|
||||||
[](https://www.npmjs.com/package/bmad-method)
|
[](https://www.npmjs.com/package/bmad-method)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 366 KiB |
|
|
@ -1,11 +1,9 @@
|
||||||
---
|
---
|
||||||
title: "Getting Started with TEA (Test Architect) - TEA Lite"
|
title: "Getting Started with Test Architect"
|
||||||
description: Learn TEA fundamentals by generating and running tests for an existing demo app in 30 minutes
|
description: Learn Test Architect fundamentals by generating and running tests for an existing demo app in 30 minutes
|
||||||
---
|
---
|
||||||
|
|
||||||
# Getting Started with TEA (Test Architect) - TEA Lite
|
Welcome! **Test Architect (TEA) Lite** is the simplest way to get started with TEA - just use `*automate` to generate tests for existing features. Perfect for beginners who want to learn TEA fundamentals quickly.
|
||||||
|
|
||||||
Welcome! **TEA Lite** is the simplest way to get started with TEA - just use `*automate` to generate tests for existing features. Perfect for beginners who want to learn TEA fundamentals quickly.
|
|
||||||
|
|
||||||
## What You'll Build
|
## What You'll Build
|
||||||
|
|
||||||
|
|
@ -14,11 +12,15 @@ By the end of this 30-minute tutorial, you'll have:
|
||||||
- Your first risk-based test plan
|
- Your first risk-based test plan
|
||||||
- Passing tests for an existing demo app feature
|
- Passing tests for an existing demo app feature
|
||||||
|
|
||||||
## Prerequisites
|
:::note[Prerequisites]
|
||||||
|
- Node.js installed (v20 or later)
|
||||||
- Node.js installed (v18 or later)
|
|
||||||
- 30 minutes of focused time
|
- 30 minutes of focused time
|
||||||
- We'll use TodoMVC (<https://todomvc.com/examples/react/dist/>) as our demo app
|
- We'll use TodoMVC (<https://todomvc.com/examples/react/>) as our demo app
|
||||||
|
:::
|
||||||
|
|
||||||
|
:::tip[Quick Path]
|
||||||
|
Load TEA (`*tea`) → scaffold framework (`*framework`) → create test plan (`*test-design`) → generate tests (`*automate`) → run with `npx playwright test`.
|
||||||
|
:::
|
||||||
|
|
||||||
## TEA Approaches Explained
|
## TEA Approaches Explained
|
||||||
|
|
||||||
|
|
@ -30,8 +32,6 @@ Before we start, understand the three ways to use TEA:
|
||||||
|
|
||||||
This tutorial focuses on **TEA Lite** - the fastest way to see TEA in action.
|
This tutorial focuses on **TEA Lite** - the fastest way to see TEA in action.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 0: Setup (2 minutes)
|
## Step 0: Setup (2 minutes)
|
||||||
|
|
||||||
We'll test TodoMVC, a standard demo app used across testing documentation.
|
We'll test TodoMVC, a standard demo app used across testing documentation.
|
||||||
|
|
@ -45,8 +45,6 @@ No installation needed - TodoMVC runs in your browser. Open the link above and:
|
||||||
|
|
||||||
You've just explored the features we'll test!
|
You've just explored the features we'll test!
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 1: Install BMad and Scaffold Framework (10 minutes)
|
## Step 1: Install BMad and Scaffold Framework (10 minutes)
|
||||||
|
|
||||||
### Install BMad Method
|
### Install BMad Method
|
||||||
|
|
@ -60,7 +58,7 @@ When prompted:
|
||||||
- **Planning artifacts folder:** Keep default
|
- **Planning artifacts folder:** Keep default
|
||||||
- **Implementation artifacts folder:** Keep default
|
- **Implementation artifacts folder:** Keep default
|
||||||
- **Project knowledge folder:** Keep default
|
- **Project knowledge folder:** Keep default
|
||||||
- **Enable TEA Playwright MCP enhancements?** Choose "No" for now (we'll explore this later)
|
- **Enable TEA Playwright Model Context Protocol (MCP) enhancements?** Choose "No" for now (we'll explore this later)
|
||||||
- **Using playwright-utils?** Choose "No" for now (we'll explore this later)
|
- **Using playwright-utils?** Choose "No" for now (we'll explore this later)
|
||||||
|
|
||||||
BMad is now installed! You'll see a `_bmad/` folder in your project.
|
BMad is now installed! You'll see a `_bmad/` folder in your project.
|
||||||
|
|
@ -92,9 +90,9 @@ A: "We're testing a React web application (TodoMVC)"
|
||||||
A: "Playwright"
|
A: "Playwright"
|
||||||
|
|
||||||
**Q: Testing scope?**
|
**Q: Testing scope?**
|
||||||
A: "E2E testing for web application"
|
A: "End-to-end (E2E) testing for a web application"
|
||||||
|
|
||||||
**Q: CI/CD platform?**
|
**Q: Continuous integration/continuous deployment (CI/CD) platform?**
|
||||||
A: "GitHub Actions" (or your preference)
|
A: "GitHub Actions" (or your preference)
|
||||||
|
|
||||||
TEA will generate:
|
TEA will generate:
|
||||||
|
|
@ -113,8 +111,6 @@ npx playwright install
|
||||||
|
|
||||||
You now have a production-ready test framework!
|
You now have a production-ready test framework!
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 2: Your First Test Design (5 minutes)
|
## Step 2: Your First Test Design (5 minutes)
|
||||||
|
|
||||||
Test design is where TEA shines - risk-based planning before writing tests.
|
Test design is where TEA shines - risk-based planning before writing tests.
|
||||||
|
|
@ -131,7 +127,7 @@ In your chat with TEA, run:
|
||||||
A: "Epic-level - I want to test TodoMVC's basic functionality"
|
A: "Epic-level - I want to test TodoMVC's basic functionality"
|
||||||
|
|
||||||
**Q: What feature are you testing?**
|
**Q: What feature are you testing?**
|
||||||
A: "TodoMVC's core CRUD operations - creating, completing, and deleting todos"
|
A: "TodoMVC's core operations - creating, completing, and deleting todos"
|
||||||
|
|
||||||
**Q: Any specific risks or concerns?**
|
**Q: Any specific risks or concerns?**
|
||||||
A: "We want to ensure the filter buttons (All, Active, Completed) work correctly"
|
A: "We want to ensure the filter buttons (All, Active, Completed) work correctly"
|
||||||
|
|
@ -156,8 +152,6 @@ TEA will analyze and create `test-design-epic-1.md` with:
|
||||||
|
|
||||||
**Review the test design file** - notice how TEA provides a systematic approach to what needs testing and why.
|
**Review the test design file** - notice how TEA provides a systematic approach to what needs testing and why.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 3: Generate Tests for Existing Features (5 minutes)
|
## Step 3: Generate Tests for Existing Features (5 minutes)
|
||||||
|
|
||||||
Now the magic happens - TEA generates tests based on your test design.
|
Now the magic happens - TEA generates tests based on your test design.
|
||||||
|
|
@ -288,8 +282,6 @@ test('should mark todo as complete', async ({ page, apiRequest }) => {
|
||||||
|
|
||||||
See [Integrate Playwright Utils](/docs/how-to/customization/integrate-playwright-utils.md) to enable this.
|
See [Integrate Playwright Utils](/docs/how-to/customization/integrate-playwright-utils.md) to enable this.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 4: Run and Validate (5 minutes)
|
## Step 4: Run and Validate (5 minutes)
|
||||||
|
|
||||||
Time to see your tests in action!
|
Time to see your tests in action!
|
||||||
|
|
@ -334,16 +326,18 @@ You used **TEA Lite** to:
|
||||||
|
|
||||||
All in 30 minutes!
|
All in 30 minutes!
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What You Learned
|
## What You Learned
|
||||||
|
|
||||||
Congratulations! You've completed the TEA Lite tutorial. You learned:
|
Congratulations! You've completed the TEA Lite tutorial. You learned:
|
||||||
|
|
||||||
### TEA Workflows
|
### Quick Reference
|
||||||
- `*framework` - Scaffold test infrastructure
|
|
||||||
- `*test-design` - Risk-based test planning
|
| Command | Purpose |
|
||||||
- `*automate` - Generate tests for existing features
|
| -------------- | ------------------------------------ |
|
||||||
|
| `*tea` | Load the TEA agent |
|
||||||
|
| `*framework` | Scaffold test infrastructure |
|
||||||
|
| `*test-design` | Risk-based test planning |
|
||||||
|
| `*automate` | Generate tests for existing features |
|
||||||
|
|
||||||
### TEA Principles
|
### TEA Principles
|
||||||
- **Risk-based testing** - Depth scales with impact (P0 vs P3)
|
- **Risk-based testing** - Depth scales with impact (P0 vs P3)
|
||||||
|
|
@ -351,15 +345,9 @@ Congratulations! You've completed the TEA Lite tutorial. You learned:
|
||||||
- **Network-first patterns** - Tests wait for actual responses (no hard waits)
|
- **Network-first patterns** - Tests wait for actual responses (no hard waits)
|
||||||
- **Production-ready from day one** - Not toy examples
|
- **Production-ready from day one** - Not toy examples
|
||||||
|
|
||||||
### Key Takeaway
|
:::tip[Key Takeaway]
|
||||||
|
TEA Lite (just `*automate`) is perfect for beginners learning TEA fundamentals, testing existing applications, quick test coverage expansion, and teams wanting fast results.
|
||||||
TEA Lite (just `*automate`) is perfect for:
|
:::
|
||||||
- Beginners learning TEA fundamentals
|
|
||||||
- Testing existing applications
|
|
||||||
- Quick test coverage expansion
|
|
||||||
- Teams wanting fast results
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Understanding ATDD vs Automate
|
## Understanding ATDD vs Automate
|
||||||
|
|
||||||
|
|
@ -370,14 +358,12 @@ This tutorial used `*automate` to generate tests for **existing features** (test
|
||||||
- Want to add test coverage
|
- Want to add test coverage
|
||||||
- Tests should pass on first run
|
- Tests should pass on first run
|
||||||
|
|
||||||
**When to use `*atdd`:**
|
**When to use `*atdd` (Acceptance Test-Driven Development):**
|
||||||
- Feature doesn't exist yet (TDD workflow)
|
- Feature doesn't exist yet (Test-Driven Development workflow)
|
||||||
- Want failing tests BEFORE implementation
|
- Want failing tests BEFORE implementation
|
||||||
- Following red → green → refactor cycle
|
- Following red → green → refactor cycle
|
||||||
|
|
||||||
See [How to Run ATDD](/docs/how-to/workflows/run-atdd.md) for the TDD approach.
|
See [How to Run ATDD](/docs/how-to/workflows/run-atdd.md) for the test-drive development (TDD) approach.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Next Steps
|
## Next Steps
|
||||||
|
|
||||||
|
|
@ -411,21 +397,22 @@ See [TEA Overview](/docs/explanation/features/tea-overview.md) for engagement mo
|
||||||
### Go Full TEA Integrated
|
### Go Full TEA Integrated
|
||||||
|
|
||||||
Want the complete quality operating model? Try TEA Integrated with BMad Method:
|
Want the complete quality operating model? Try TEA Integrated with BMad Method:
|
||||||
- Phase 2: Planning with NFR assessment
|
- Phase 2: Planning with non-functional requirements (NFR) assessment
|
||||||
- Phase 3: Architecture testability review
|
- Phase 3: Architecture testability review
|
||||||
- Phase 4: Per-epic test design → ATDD → automate
|
- Phase 4: Per-epic test design → ATDD → automate
|
||||||
- Release Gate: Coverage traceability and gate decisions
|
- Release Gate: Coverage traceability and gate decisions
|
||||||
|
|
||||||
See [BMad Method Documentation](/) for the full workflow.
|
See [BMad Method Documentation](/) for the full workflow.
|
||||||
|
|
||||||
---
|
## Common Questions
|
||||||
|
|
||||||
## Troubleshooting
|
- [Why can't my tests find elements?](#why-cant-my-tests-find-elements)
|
||||||
|
- [How do I fix network timeouts?](#how-do-i-fix-network-timeouts)
|
||||||
|
|
||||||
### Tests Failing?
|
### Why can't my tests find elements?
|
||||||
|
|
||||||
|
TodoMVC doesn't use test IDs or accessible roles consistently. The selectors in this tutorial use CSS classes that match TodoMVC's actual structure:
|
||||||
|
|
||||||
**Problem:** Tests can't find elements
|
|
||||||
**Solution:** TodoMVC doesn't use test IDs or accessible roles consistently. The selectors in this tutorial use CSS classes that match TodoMVC's actual structure:
|
|
||||||
```typescript
|
```typescript
|
||||||
// TodoMVC uses these CSS classes:
|
// TodoMVC uses these CSS classes:
|
||||||
page.locator('.new-todo') // Input field
|
page.locator('.new-todo') // Input field
|
||||||
|
|
@ -438,26 +425,20 @@ page.getByRole('listitem')
|
||||||
page.getByRole('checkbox')
|
page.getByRole('checkbox')
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note:** In production code, use accessible selectors (`getByRole`, `getByLabel`, `getByText`) for better resilience. TodoMVC is used here for learning, not as a selector best practice example.
|
In production code, use accessible selectors (`getByRole`, `getByLabel`, `getByText`) for better resilience. TodoMVC is used here for learning, not as a selector best practice example.
|
||||||
|
|
||||||
|
### How do I fix network timeouts?
|
||||||
|
|
||||||
|
Increase timeout in `playwright.config.ts`:
|
||||||
|
|
||||||
**Problem:** Network timeout
|
|
||||||
**Solution:** Increase timeout in `playwright.config.ts`:
|
|
||||||
```typescript
|
```typescript
|
||||||
use: {
|
use: {
|
||||||
timeout: 30000, // 30 seconds
|
timeout: 30000, // 30 seconds
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Need Help?
|
## Getting Help
|
||||||
|
|
||||||
- **Documentation:** <https://docs.bmad-method.org>
|
- **Documentation:** <https://docs.bmad-method.org>
|
||||||
- **GitHub Issues:** <https://github.com/bmad-code-org/bmad-method/issues>
|
- **GitHub Issues:** <https://github.com/bmad-code-org/bmad-method/issues>
|
||||||
- **Discord:** Join the BMAD community
|
- **Discord:** Join the BMAD community
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Feedback
|
|
||||||
|
|
||||||
Found this tutorial helpful? Have suggestions? Open an issue on GitHub!
|
|
||||||
|
|
||||||
Generated with [BMad Method](https://bmad-method.org) - TEA (Test Architect)
|
|
||||||
|
|
|
||||||
|
|
@ -45,10 +45,12 @@
|
||||||
"release:minor": "gh workflow run \"Manual Release\" -f version_bump=minor",
|
"release:minor": "gh workflow run \"Manual Release\" -f version_bump=minor",
|
||||||
"release:patch": "gh workflow run \"Manual Release\" -f version_bump=patch",
|
"release:patch": "gh workflow run \"Manual Release\" -f version_bump=patch",
|
||||||
"release:watch": "gh run watch",
|
"release:watch": "gh run watch",
|
||||||
"test": "npm run test:schemas && npm run test:install && npm run validate:schemas && npm run lint && npm run lint:md && npm run format:check",
|
"test": "npm run test:schemas && npm run test:modules && npm run test:install && npm run validate:schemas && npm run validate:modules && npm run lint && npm run lint:md && npm run format:check",
|
||||||
"test:coverage": "c8 --reporter=text --reporter=html npm run test:schemas",
|
"test:coverage": "c8 --reporter=text --reporter=html npm run test:schemas",
|
||||||
"test:install": "node test/test-installation-components.js",
|
"test:install": "node test/test-installation-components.js",
|
||||||
|
"test:modules": "node test/test-module-schema.js",
|
||||||
"test:schemas": "node test/test-agent-schema.js",
|
"test:schemas": "node test/test-agent-schema.js",
|
||||||
|
"validate:modules": "node tools/validate-module-schema.js",
|
||||||
"validate:schemas": "node tools/validate-agent-schema.js"
|
"validate:schemas": "node tools/validate-agent-schema.js"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
|
|
|
||||||
|
|
@ -42,13 +42,13 @@ Load `{moduleYamlConventionsFile}` for reference.
|
||||||
|
|
||||||
Create `{targetLocation}/module.yaml` with:
|
Create `{targetLocation}/module.yaml` with:
|
||||||
|
|
||||||
**Required fields:**
|
**Required fields (replace with actual values):**
|
||||||
```yaml
|
```yaml
|
||||||
code: {module_code}
|
code: "my-module" # kebab-case, 2-20 chars, starts with letter
|
||||||
name: "{module_display_name}"
|
name: "My Module: Description" # human-readable name
|
||||||
header: "{brief_header}"
|
header: "One-line summary" # one-line summary
|
||||||
subheader: "{additional_context}"
|
subheader: "Additional context" # additional context
|
||||||
default_selected: false
|
default_selected: false # typically false for new modules
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note for Extension modules:** `code:` matches base module
|
**Note for Extension modules:** `code:` matches base module
|
||||||
|
|
|
||||||
|
|
@ -38,11 +38,11 @@ Read `{targetPath}/module.yaml`
|
||||||
|
|
||||||
### 2. Validate Required Fields
|
### 2. Validate Required Fields
|
||||||
|
|
||||||
Check for required frontmatter:
|
Check required fields (must have actual values, not placeholders):
|
||||||
- [ ] `code:` present and valid (kebab-case, 2-20 chars)
|
- [ ] `code:` present (kebab-case, 2-20 chars, starts with letter)
|
||||||
- [ ] `name:` present
|
- [ ] `name:` present (non-empty string)
|
||||||
- [ ] `header:` present
|
- [ ] `header:` present (non-empty string)
|
||||||
- [ ] `subheader:` present
|
- [ ] `subheader:` present (non-empty string)
|
||||||
- [ ] `default_selected:` present (boolean)
|
- [ ] `default_selected:` present (boolean)
|
||||||
|
|
||||||
### 3. Validate Custom Variables
|
### 3. Validate Custom Variables
|
||||||
|
|
|
||||||
11
test/fixtures/module-schema/invalid/code-format/number-start-code.module.yaml
vendored
Normal file
11
test/fixtures/module-schema/invalid/code-format/number-start-code.module.yaml
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Test: Code field starts with a number
|
||||||
|
# Expected: FAIL
|
||||||
|
# Error code: invalid_string
|
||||||
|
# Error path: code
|
||||||
|
|
||||||
|
code: 123-module
|
||||||
|
name: Test Module
|
||||||
|
header: Test Header
|
||||||
|
subheader: Test Subheader
|
||||||
|
default_selected: false
|
||||||
|
|
||||||
11
test/fixtures/module-schema/invalid/code-format/placeholder-code.module.yaml
vendored
Normal file
11
test/fixtures/module-schema/invalid/code-format/placeholder-code.module.yaml
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Test: Code field contains placeholder text (the main bug we're fixing)
|
||||||
|
# Expected: FAIL
|
||||||
|
# Error code: invalid_string
|
||||||
|
# Error path: code
|
||||||
|
|
||||||
|
code: "{module_code}"
|
||||||
|
name: Test Module
|
||||||
|
header: Test Header
|
||||||
|
subheader: Test Subheader
|
||||||
|
default_selected: false
|
||||||
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Test: Code field too short (minimum 2 characters)
|
||||||
|
# Expected: FAIL
|
||||||
|
# Error code: invalid_string
|
||||||
|
# Error path: code
|
||||||
|
|
||||||
|
code: x
|
||||||
|
name: Test Module
|
||||||
|
header: Test Header
|
||||||
|
subheader: Test Subheader
|
||||||
|
default_selected: false
|
||||||
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Test: Code field with underscores (should be kebab-case)
|
||||||
|
# Expected: FAIL
|
||||||
|
# Error code: invalid_string
|
||||||
|
# Error path: code
|
||||||
|
|
||||||
|
code: test_module
|
||||||
|
name: Test Module
|
||||||
|
header: Test Header
|
||||||
|
subheader: Test Subheader
|
||||||
|
default_selected: false
|
||||||
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Test: Code field with uppercase letters
|
||||||
|
# Expected: FAIL
|
||||||
|
# Error code: invalid_string
|
||||||
|
# Error path: code
|
||||||
|
|
||||||
|
code: TestModule
|
||||||
|
name: Test Module
|
||||||
|
header: Test Header
|
||||||
|
subheader: Test Subheader
|
||||||
|
default_selected: false
|
||||||
|
|
||||||
11
test/fixtures/module-schema/invalid/required-fields/missing-code.module.yaml
vendored
Normal file
11
test/fixtures/module-schema/invalid/required-fields/missing-code.module.yaml
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Test: Missing required code field
|
||||||
|
# Expected: FAIL
|
||||||
|
# Error code: invalid_type
|
||||||
|
# Error path: code
|
||||||
|
# Error message: Required
|
||||||
|
|
||||||
|
name: Test Module
|
||||||
|
header: Test Header
|
||||||
|
subheader: Test Subheader
|
||||||
|
default_selected: false
|
||||||
|
|
||||||
11
test/fixtures/module-schema/invalid/required-fields/missing-header.module.yaml
vendored
Normal file
11
test/fixtures/module-schema/invalid/required-fields/missing-header.module.yaml
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Test: Missing required header field
|
||||||
|
# Expected: FAIL
|
||||||
|
# Error code: invalid_type
|
||||||
|
# Error path: header
|
||||||
|
# Error message: Required
|
||||||
|
|
||||||
|
code: test-module
|
||||||
|
name: Test Module
|
||||||
|
subheader: Test Subheader
|
||||||
|
default_selected: false
|
||||||
|
|
||||||
11
test/fixtures/module-schema/invalid/required-fields/missing-name.module.yaml
vendored
Normal file
11
test/fixtures/module-schema/invalid/required-fields/missing-name.module.yaml
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Test: Missing required name field
|
||||||
|
# Expected: FAIL
|
||||||
|
# Error code: invalid_type
|
||||||
|
# Error path: name
|
||||||
|
# Error message: Required
|
||||||
|
|
||||||
|
code: test-module
|
||||||
|
header: Test Header
|
||||||
|
subheader: Test Subheader
|
||||||
|
default_selected: false
|
||||||
|
|
||||||
11
test/fixtures/module-schema/invalid/required-fields/missing-subheader.module.yaml
vendored
Normal file
11
test/fixtures/module-schema/invalid/required-fields/missing-subheader.module.yaml
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Test: Missing required subheader field
|
||||||
|
# Expected: FAIL
|
||||||
|
# Error code: invalid_type
|
||||||
|
# Error path: subheader
|
||||||
|
# Error message: Required
|
||||||
|
|
||||||
|
code: test-module
|
||||||
|
name: Test Module
|
||||||
|
header: Test Header
|
||||||
|
default_selected: false
|
||||||
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
# Test: Variable with empty prompt
|
||||||
|
# Expected: FAIL
|
||||||
|
# Error code: custom
|
||||||
|
# Error path: my_variable
|
||||||
|
# Error message: my_variable.prompt must be a non-empty string
|
||||||
|
|
||||||
|
code: test-module
|
||||||
|
name: Test Module
|
||||||
|
header: Test Header
|
||||||
|
subheader: Test Subheader
|
||||||
|
default_selected: false
|
||||||
|
|
||||||
|
my_variable:
|
||||||
|
prompt: " "
|
||||||
|
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
# Test: Variable without prompt or inherit
|
||||||
|
# Expected: FAIL
|
||||||
|
# Error code: custom
|
||||||
|
# Error path: my_variable
|
||||||
|
# Error message: my_variable must have a 'prompt' or 'inherit' field
|
||||||
|
|
||||||
|
code: test-module
|
||||||
|
name: Test Module
|
||||||
|
header: Test Header
|
||||||
|
subheader: Test Subheader
|
||||||
|
default_selected: false
|
||||||
|
|
||||||
|
my_variable:
|
||||||
|
required: true
|
||||||
|
result: some result
|
||||||
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
# Test: Valid module with default_selected set to true
|
||||||
|
# Expected: PASS
|
||||||
|
|
||||||
|
code: core-like
|
||||||
|
name: Core Module
|
||||||
|
header: Core Header
|
||||||
|
subheader: Module with default_selected true
|
||||||
|
default_selected: true
|
||||||
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
# Test: Valid module with only required fields
|
||||||
|
# Expected: PASS
|
||||||
|
|
||||||
|
code: my-module
|
||||||
|
name: My Module Name
|
||||||
|
header: Module Header
|
||||||
|
subheader: Short description of what this module does
|
||||||
|
default_selected: false
|
||||||
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
# Test: Valid module with minimum 2-character code
|
||||||
|
# Expected: PASS
|
||||||
|
|
||||||
|
code: ab
|
||||||
|
name: Two Letter Module
|
||||||
|
header: Short Code Header
|
||||||
|
subheader: The shortest valid code is 2 characters
|
||||||
|
default_selected: true
|
||||||
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
# Test: Valid module with inherit variable
|
||||||
|
# Expected: PASS
|
||||||
|
|
||||||
|
code: inherit-mod
|
||||||
|
name: Inherit Module
|
||||||
|
header: Module With Inherited Variables
|
||||||
|
subheader: Uses inherit instead of prompt
|
||||||
|
default_selected: false
|
||||||
|
|
||||||
|
inherited_var:
|
||||||
|
inherit: other-module.some_var
|
||||||
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
# Test: Valid module with variable definition (prompt style)
|
||||||
|
# Expected: PASS
|
||||||
|
|
||||||
|
code: var-module
|
||||||
|
name: Variable Module
|
||||||
|
header: Module With Variables
|
||||||
|
subheader: Demonstrates variable definitions
|
||||||
|
default_selected: false
|
||||||
|
|
||||||
|
project_name:
|
||||||
|
prompt: What is your project name?
|
||||||
|
required: true
|
||||||
|
result: The project name is {project_name}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
# Test: Valid module with single-select and multi-select
|
||||||
|
# Expected: PASS
|
||||||
|
|
||||||
|
code: select-mod
|
||||||
|
name: Select Module
|
||||||
|
header: Module With Selects
|
||||||
|
subheader: Demonstrates single-select and multi-select
|
||||||
|
default_selected: false
|
||||||
|
|
||||||
|
environment:
|
||||||
|
prompt: Select your target environment
|
||||||
|
single-select:
|
||||||
|
- value: dev
|
||||||
|
label: Development
|
||||||
|
- value: staging
|
||||||
|
label: Staging
|
||||||
|
- value: prod
|
||||||
|
label: Production
|
||||||
|
|
||||||
|
features:
|
||||||
|
prompt:
|
||||||
|
- What features do you want?
|
||||||
|
- You can select multiple options.
|
||||||
|
multi-select:
|
||||||
|
- value: auth
|
||||||
|
label: Authentication
|
||||||
|
- value: api
|
||||||
|
label: REST API
|
||||||
|
- value: db
|
||||||
|
label: Database
|
||||||
|
|
||||||
|
|
@ -0,0 +1,345 @@
|
||||||
|
/**
|
||||||
|
* Module Schema Validation Test Runner
|
||||||
|
*
|
||||||
|
* Runs all test fixtures and verifies expected outcomes.
|
||||||
|
* Reports pass/fail for each test and overall coverage statistics.
|
||||||
|
*
|
||||||
|
* Usage: node test/test-module-schema.js
|
||||||
|
* Exit codes: 0 = all tests pass, 1 = test failures
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const yaml = require('yaml');
|
||||||
|
const { validateModuleFile } = require('../tools/schema/module.js');
|
||||||
|
const { glob } = require('glob');
|
||||||
|
|
||||||
|
// ANSI color codes
|
||||||
|
const colors = {
|
||||||
|
reset: '\u001B[0m',
|
||||||
|
green: '\u001B[32m',
|
||||||
|
red: '\u001B[31m',
|
||||||
|
yellow: '\u001B[33m',
|
||||||
|
blue: '\u001B[34m',
|
||||||
|
cyan: '\u001B[36m',
|
||||||
|
dim: '\u001B[2m',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse test metadata from YAML comments
|
||||||
|
* @param {string} filePath
|
||||||
|
* @returns {{shouldPass: boolean, errorExpectation?: object}}
|
||||||
|
*/
|
||||||
|
function parseTestMetadata(filePath) {
|
||||||
|
const content = fs.readFileSync(filePath, 'utf8');
|
||||||
|
const lines = content.split('\n');
|
||||||
|
|
||||||
|
let shouldPass = true;
|
||||||
|
const errorExpectation = {};
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.includes('Expected: PASS')) {
|
||||||
|
shouldPass = true;
|
||||||
|
} else if (line.includes('Expected: FAIL')) {
|
||||||
|
shouldPass = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse error metadata
|
||||||
|
const codeMatch = line.match(/^# Error code: (.+)$/);
|
||||||
|
if (codeMatch) {
|
||||||
|
errorExpectation.code = codeMatch[1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathMatch = line.match(/^# Error path: (.+)$/);
|
||||||
|
if (pathMatch) {
|
||||||
|
errorExpectation.path = pathMatch[1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageMatch = line.match(/^# Error message: (.+)$/);
|
||||||
|
if (messageMatch) {
|
||||||
|
errorExpectation.message = messageMatch[1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const minimumMatch = line.match(/^# Error minimum: (\d+)$/);
|
||||||
|
if (minimumMatch) {
|
||||||
|
errorExpectation.minimum = parseInt(minimumMatch[1], 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedMatch = line.match(/^# Error expected: (.+)$/);
|
||||||
|
if (expectedMatch) {
|
||||||
|
errorExpectation.expected = expectedMatch[1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const receivedMatch = line.match(/^# Error received: (.+)$/);
|
||||||
|
if (receivedMatch) {
|
||||||
|
errorExpectation.received = receivedMatch[1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const keysMatch = line.match(/^# Error keys: \[(.+)\]$/);
|
||||||
|
if (keysMatch) {
|
||||||
|
errorExpectation.keys = keysMatch[1].split(',').map((k) => k.trim().replaceAll(/['"]/g, ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
shouldPass,
|
||||||
|
errorExpectation: Object.keys(errorExpectation).length > 0 ? errorExpectation : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert dot-notation path string to array (handles array indices)
|
||||||
|
* e.g., "module.dependencies[0]" => ["module", "dependencies", 0]
|
||||||
|
*/
|
||||||
|
function parsePathString(pathString) {
|
||||||
|
return pathString
|
||||||
|
.replaceAll(/\[(\d+)\]/g, '.$1')
|
||||||
|
.split('.')
|
||||||
|
.map((part) => {
|
||||||
|
const num = parseInt(part, 10);
|
||||||
|
return isNaN(num) ? part : num;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate error against expectations
|
||||||
|
* @param {object} error - Zod error issue
|
||||||
|
* @param {object} expectation - Expected error structure
|
||||||
|
* @returns {{valid: boolean, reason?: string}}
|
||||||
|
*/
|
||||||
|
function validateError(error, expectation) {
|
||||||
|
if (expectation.code && error.code !== expectation.code) {
|
||||||
|
return { valid: false, reason: `Expected code "${expectation.code}", got "${error.code}"` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expectation.path) {
|
||||||
|
const expectedPath = parsePathString(expectation.path);
|
||||||
|
const actualPath = error.path;
|
||||||
|
|
||||||
|
if (JSON.stringify(expectedPath) !== JSON.stringify(actualPath)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: `Expected path ${JSON.stringify(expectedPath)}, got ${JSON.stringify(actualPath)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expectation.code === 'custom' && expectation.message && error.message !== expectation.message) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: `Expected message "${expectation.message}", got "${error.message}"`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expectation.minimum !== undefined && error.minimum !== expectation.minimum) {
|
||||||
|
return { valid: false, reason: `Expected minimum ${expectation.minimum}, got ${error.minimum}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expectation.expected && error.expected !== expectation.expected) {
|
||||||
|
return { valid: false, reason: `Expected type "${expectation.expected}", got "${error.expected}"` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expectation.received && error.received !== expectation.received) {
|
||||||
|
return { valid: false, reason: `Expected received "${expectation.received}", got "${error.received}"` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expectation.keys) {
|
||||||
|
const expectedKeys = expectation.keys.sort();
|
||||||
|
const actualKeys = (error.keys || []).sort();
|
||||||
|
if (JSON.stringify(expectedKeys) !== JSON.stringify(actualKeys)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: `Expected keys ${JSON.stringify(expectedKeys)}, got ${JSON.stringify(actualKeys)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a single test case
|
||||||
|
* @param {string} filePath
|
||||||
|
* @returns {{passed: boolean, message: string}}
|
||||||
|
*/
|
||||||
|
function runTest(filePath) {
|
||||||
|
try {
|
||||||
|
const metadata = parseTestMetadata(filePath);
|
||||||
|
const { shouldPass, errorExpectation } = metadata;
|
||||||
|
|
||||||
|
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||||
|
let moduleData;
|
||||||
|
|
||||||
|
try {
|
||||||
|
moduleData = yaml.parse(fileContent);
|
||||||
|
} catch (parseError) {
|
||||||
|
if (shouldPass) {
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
message: `Expected PASS but got YAML parse error: ${parseError.message}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
passed: true,
|
||||||
|
message: 'Got expected YAML parse error',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = validateModuleFile(filePath, moduleData);
|
||||||
|
|
||||||
|
if (result.success && shouldPass) {
|
||||||
|
return {
|
||||||
|
passed: true,
|
||||||
|
message: 'Validation passed as expected',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.success && !shouldPass) {
|
||||||
|
const actualError = result.error?.issues?.[0];
|
||||||
|
if (!actualError) {
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
message: 'Expected validation error issues, but validator returned none',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorExpectation) {
|
||||||
|
const validation = validateError(actualError, errorExpectation);
|
||||||
|
|
||||||
|
if (!validation.valid) {
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
message: `Error validation failed: ${validation.reason}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
passed: true,
|
||||||
|
message: `Got expected error (${errorExpectation.code}): ${actualError.message}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
passed: true,
|
||||||
|
message: `Got expected validation error: ${actualError.message}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.success && !shouldPass) {
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
message: 'Expected validation to FAIL but it PASSED',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.success && shouldPass) {
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
message: `Expected validation to PASS but it FAILED: ${result.error?.issues?.[0]?.message ?? 'Unknown error'}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
message: 'Unexpected test state',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
message: `Test execution error: ${error.message}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main test runner
|
||||||
|
*/
|
||||||
|
async function main() {
|
||||||
|
console.log(`${colors.cyan}╔═══════════════════════════════════════════════════════════╗${colors.reset}`);
|
||||||
|
console.log(`${colors.cyan}║ Module Schema Validation Test Suite ║${colors.reset}`);
|
||||||
|
console.log(`${colors.cyan}╚═══════════════════════════════════════════════════════════╝${colors.reset}\n`);
|
||||||
|
|
||||||
|
const testFiles = await glob('test/fixtures/module-schema/**/*.module.yaml', {
|
||||||
|
cwd: path.join(__dirname, '..'),
|
||||||
|
absolute: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (testFiles.length === 0) {
|
||||||
|
console.log(`${colors.yellow}⚠️ No test fixtures found${colors.reset}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Found ${colors.cyan}${testFiles.length}${colors.reset} test fixture(s)\n`);
|
||||||
|
|
||||||
|
// Group tests by category
|
||||||
|
const categories = {};
|
||||||
|
for (const testFile of testFiles) {
|
||||||
|
const relativePath = path.relative(path.join(__dirname, 'fixtures/module-schema'), testFile);
|
||||||
|
const parts = relativePath.split(path.sep);
|
||||||
|
const validInvalid = parts[0]; // 'valid' or 'invalid'
|
||||||
|
const category = parts[1] || 'general';
|
||||||
|
|
||||||
|
const categoryKey = `${validInvalid}/${category}`;
|
||||||
|
if (!categories[categoryKey]) {
|
||||||
|
categories[categoryKey] = [];
|
||||||
|
}
|
||||||
|
categories[categoryKey].push(testFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run tests by category
|
||||||
|
let totalTests = 0;
|
||||||
|
let passedTests = 0;
|
||||||
|
const failures = [];
|
||||||
|
|
||||||
|
for (const [categoryKey, files] of Object.entries(categories).sort()) {
|
||||||
|
const [validInvalid, category] = categoryKey.split('/');
|
||||||
|
const categoryLabel = category.replaceAll('-', ' ').toUpperCase();
|
||||||
|
const validLabel = validInvalid === 'valid' ? '✅' : '❌';
|
||||||
|
|
||||||
|
console.log(`${colors.blue}${validLabel} ${categoryLabel} (${validInvalid})${colors.reset}`);
|
||||||
|
|
||||||
|
for (const testFile of files) {
|
||||||
|
totalTests++;
|
||||||
|
const testName = path.basename(testFile, '.module.yaml');
|
||||||
|
const result = runTest(testFile);
|
||||||
|
|
||||||
|
if (result.passed) {
|
||||||
|
passedTests++;
|
||||||
|
console.log(` ${colors.green}✓${colors.reset} ${testName} ${colors.dim}${result.message}${colors.reset}`);
|
||||||
|
} else {
|
||||||
|
console.log(` ${colors.red}✗${colors.reset} ${testName} ${colors.red}${result.message}${colors.reset}`);
|
||||||
|
failures.push({
|
||||||
|
file: path.relative(process.cwd(), testFile),
|
||||||
|
message: result.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
console.log(`${colors.cyan}═══════════════════════════════════════════════════════════${colors.reset}`);
|
||||||
|
console.log(`${colors.cyan}Test Results:${colors.reset}`);
|
||||||
|
console.log(` Total: ${totalTests}`);
|
||||||
|
console.log(` Passed: ${colors.green}${passedTests}${colors.reset}`);
|
||||||
|
console.log(` Failed: ${passedTests === totalTests ? colors.green : colors.red}${totalTests - passedTests}${colors.reset}`);
|
||||||
|
console.log(`${colors.cyan}═══════════════════════════════════════════════════════════${colors.reset}\n`);
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
console.log(`${colors.red}❌ FAILED TESTS:${colors.reset}\n`);
|
||||||
|
for (const failure of failures) {
|
||||||
|
console.log(`${colors.red}✗${colors.reset} ${failure.file}`);
|
||||||
|
console.log(` ${failure.message}\n`);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`${colors.green}✨ All tests passed!${colors.reset}\n`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(`${colors.red}Fatal error:${colors.reset}`, error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,213 @@
|
||||||
|
// Zod schema definition for module.yaml files
|
||||||
|
const { z } = require('zod');
|
||||||
|
|
||||||
|
// Pattern for module code: kebab-case, 2-20 characters, starts with letter
|
||||||
|
const MODULE_CODE_PATTERN = /^[a-z][a-z0-9-]{1,19}$/;
|
||||||
|
|
||||||
|
// Public API ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a module YAML payload against the schema.
|
||||||
|
*
|
||||||
|
* @param {string} filePath Path to the module file (used to detect core vs non-core modules).
|
||||||
|
* @param {unknown} moduleYaml Parsed YAML content.
|
||||||
|
* @returns {import('zod').SafeParseReturnType<unknown, unknown>} SafeParse result.
|
||||||
|
*/
|
||||||
|
function validateModuleFile(filePath, moduleYaml) {
|
||||||
|
const isCoreModule = typeof filePath === 'string' && filePath.replaceAll('\\', '/').includes('src/core/');
|
||||||
|
const schema = moduleSchema({ isCoreModule });
|
||||||
|
return schema.safeParse(moduleYaml);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { validateModuleFile };
|
||||||
|
|
||||||
|
// Internal helpers ---------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the Zod schema for validating a module.yaml file.
|
||||||
|
* @param {{isCoreModule?: boolean}} options - Options for schema validation.
|
||||||
|
* @returns {import('zod').ZodSchema} Configured Zod schema instance.
|
||||||
|
*/
|
||||||
|
function moduleSchema(options) {
|
||||||
|
const { isCoreModule = false } = options ?? {};
|
||||||
|
return z
|
||||||
|
.object({
|
||||||
|
// Required fields
|
||||||
|
code: z.string().regex(MODULE_CODE_PATTERN, {
|
||||||
|
message: 'module.code must be kebab-case, 2-20 characters, starting with a letter',
|
||||||
|
}),
|
||||||
|
name: createNonEmptyString('module.name'),
|
||||||
|
header: createNonEmptyString('module.header'),
|
||||||
|
subheader: createNonEmptyString('module.subheader'),
|
||||||
|
// default_selected is optional for core module, required for non-core modules
|
||||||
|
default_selected: z.boolean().optional(),
|
||||||
|
|
||||||
|
// Optional fields
|
||||||
|
type: createNonEmptyString('module.type').optional(),
|
||||||
|
global: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
.superRefine((value, ctx) => {
|
||||||
|
// Enforce default_selected for non-core modules
|
||||||
|
if (!isCoreModule && !('default_selected' in value)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['default_selected'],
|
||||||
|
message: 'module.default_selected is required for non-core modules',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate any additional keys as variable definitions
|
||||||
|
const reservedKeys = new Set(['code', 'name', 'header', 'subheader', 'default_selected', 'type', 'global']);
|
||||||
|
|
||||||
|
for (const key of Object.keys(value)) {
|
||||||
|
if (reservedKeys.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variableValue = value[key];
|
||||||
|
|
||||||
|
// Skip if null/undefined
|
||||||
|
if (variableValue === null || variableValue === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate variable definition
|
||||||
|
const variableResult = validateVariableDefinition(key, variableValue);
|
||||||
|
if (!variableResult.valid) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: [key],
|
||||||
|
message: variableResult.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a variable definition object.
|
||||||
|
* @param {string} variableName The name of the variable.
|
||||||
|
* @param {unknown} variableValue The variable definition value.
|
||||||
|
* @returns {{ valid: boolean, error?: string }}
|
||||||
|
*/
|
||||||
|
function validateVariableDefinition(variableName, variableValue) {
|
||||||
|
// If it's not an object, it's invalid
|
||||||
|
if (typeof variableValue !== 'object' || variableValue === null) {
|
||||||
|
return { valid: false, error: `${variableName} must be an object with variable definition properties` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasInherit = 'inherit' in variableValue;
|
||||||
|
const hasPrompt = 'prompt' in variableValue;
|
||||||
|
|
||||||
|
// Enforce mutual exclusivity: inherit and prompt cannot coexist
|
||||||
|
if (hasInherit && hasPrompt) {
|
||||||
|
return { valid: false, error: `${variableName} must not define both 'inherit' and 'prompt'` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for inherit alias - if present, it's the only required field
|
||||||
|
if (hasInherit) {
|
||||||
|
if (typeof variableValue.inherit !== 'string' || variableValue.inherit.trim().length === 0) {
|
||||||
|
return { valid: false, error: `${variableName}.inherit must be a non-empty string` };
|
||||||
|
}
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, prompt is required
|
||||||
|
if (!hasPrompt) {
|
||||||
|
return { valid: false, error: `${variableName} must have a 'prompt' or 'inherit' field` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate prompt: string or array of strings
|
||||||
|
const prompt = variableValue.prompt;
|
||||||
|
if (typeof prompt === 'string') {
|
||||||
|
if (prompt.trim().length === 0) {
|
||||||
|
return { valid: false, error: `${variableName}.prompt must be a non-empty string` };
|
||||||
|
}
|
||||||
|
} else if (Array.isArray(prompt)) {
|
||||||
|
if (prompt.length === 0) {
|
||||||
|
return { valid: false, error: `${variableName}.prompt array must not be empty` };
|
||||||
|
}
|
||||||
|
for (const [index, promptItem] of prompt.entries()) {
|
||||||
|
if (typeof promptItem !== 'string' || promptItem.trim().length === 0) {
|
||||||
|
return { valid: false, error: `${variableName}.prompt[${index}] must be a non-empty string` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return { valid: false, error: `${variableName}.prompt must be a string or array of strings` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce mutual exclusivity: single-select and multi-select cannot coexist
|
||||||
|
const hasSingle = 'single-select' in variableValue;
|
||||||
|
const hasMulti = 'multi-select' in variableValue;
|
||||||
|
if (hasSingle && hasMulti) {
|
||||||
|
return { valid: false, error: `${variableName} must not define both 'single-select' and 'multi-select'` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate optional single-select
|
||||||
|
if (hasSingle) {
|
||||||
|
const selectResult = validateSelectOptions(variableName, 'single-select', variableValue['single-select']);
|
||||||
|
if (!selectResult.valid) {
|
||||||
|
return selectResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate optional multi-select
|
||||||
|
if (hasMulti) {
|
||||||
|
const selectResult = validateSelectOptions(variableName, 'multi-select', variableValue['multi-select']);
|
||||||
|
if (!selectResult.valid) {
|
||||||
|
return selectResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate optional required field
|
||||||
|
if ('required' in variableValue && typeof variableValue.required !== 'boolean') {
|
||||||
|
return { valid: false, error: `${variableName}.required must be a boolean` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate optional result field
|
||||||
|
if ('result' in variableValue && (typeof variableValue.result !== 'string' || variableValue.result.trim().length === 0)) {
|
||||||
|
return { valid: false, error: `${variableName}.result must be a non-empty string` };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate single-select or multi-select options array.
|
||||||
|
* @param {string} variableName The variable name for error messages.
|
||||||
|
* @param {string} selectType Either 'single-select' or 'multi-select'.
|
||||||
|
* @param {unknown} options The options array to validate.
|
||||||
|
* @returns {{ valid: boolean, error?: string }}
|
||||||
|
*/
|
||||||
|
function validateSelectOptions(variableName, selectType, options) {
|
||||||
|
if (!Array.isArray(options)) {
|
||||||
|
return { valid: false, error: `${variableName}.${selectType} must be an array` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.length === 0) {
|
||||||
|
return { valid: false, error: `${variableName}.${selectType} must not be empty` };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [index, option] of options.entries()) {
|
||||||
|
if (typeof option !== 'object' || option === null) {
|
||||||
|
return { valid: false, error: `${variableName}.${selectType}[${index}] must be an object` };
|
||||||
|
}
|
||||||
|
if (!('value' in option) || typeof option.value !== 'string') {
|
||||||
|
return { valid: false, error: `${variableName}.${selectType}[${index}].value must be a string` };
|
||||||
|
}
|
||||||
|
if (!('label' in option) || typeof option.label !== 'string') {
|
||||||
|
return { valid: false, error: `${variableName}.${selectType}[${index}].label must be a string` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Primitive validators -----------------------------------------------------
|
||||||
|
|
||||||
|
function createNonEmptyString(label) {
|
||||||
|
return z.string().refine((value) => value.trim().length > 0, {
|
||||||
|
message: `${label} must be a non-empty string`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
/**
|
||||||
|
* Module Schema Validator CLI
|
||||||
|
*
|
||||||
|
* Scans all module.yaml files in src/core/ and src/modules/
|
||||||
|
* and validates them against the Zod schema.
|
||||||
|
*
|
||||||
|
* Usage: node tools/validate-module-schema.js [project_root]
|
||||||
|
* Exit codes: 0 = success, 1 = validation failures
|
||||||
|
*
|
||||||
|
* Optional argument:
|
||||||
|
* project_root - Directory to scan (defaults to BMAD repo root)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { glob } = require('glob');
|
||||||
|
const yaml = require('yaml');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { validateModuleFile } = require('./schema/module.js');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main validation routine
|
||||||
|
* @param {string} [customProjectRoot] - Optional project root to scan (for testing)
|
||||||
|
*/
|
||||||
|
async function main(customProjectRoot) {
|
||||||
|
console.log('🔍 Scanning for module files...\n');
|
||||||
|
|
||||||
|
// Determine project root: use custom path if provided, otherwise default to repo root
|
||||||
|
const project_root = customProjectRoot || path.join(__dirname, '..');
|
||||||
|
|
||||||
|
// Find all module files: core/module.yaml and modules/*/module.yaml
|
||||||
|
const moduleFiles = await glob('src/{core,modules/*}/module.yaml', {
|
||||||
|
cwd: project_root,
|
||||||
|
absolute: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (moduleFiles.length === 0) {
|
||||||
|
console.log('❌ No module files found. This likely indicates a configuration error.');
|
||||||
|
console.log(' Expected to find module.yaml files in src/core/ and src/modules/*/');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Found ${moduleFiles.length} module file(s)\n`);
|
||||||
|
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
// Validate each file
|
||||||
|
for (const filePath of moduleFiles) {
|
||||||
|
const relativePath = path.relative(project_root, filePath).replaceAll('\\', '/');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||||
|
const moduleData = yaml.parse(fileContent);
|
||||||
|
|
||||||
|
// Ensure path starts with src/ for core module detection
|
||||||
|
const srcRelativePath = relativePath.startsWith('src/') ? relativePath : `src/${relativePath}`;
|
||||||
|
|
||||||
|
const result = validateModuleFile(srcRelativePath, moduleData);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
console.log(`✅ ${relativePath}`);
|
||||||
|
} else {
|
||||||
|
errors.push({
|
||||||
|
file: relativePath,
|
||||||
|
issues: result.error.issues,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errors.push({
|
||||||
|
file: relativePath,
|
||||||
|
issues: [
|
||||||
|
{
|
||||||
|
code: 'parse_error',
|
||||||
|
message: `Failed to parse YAML: ${error.message}`,
|
||||||
|
path: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report errors
|
||||||
|
if (errors.length > 0) {
|
||||||
|
console.log('\n❌ Validation failed for the following files:\n');
|
||||||
|
|
||||||
|
for (const { file, issues } of errors) {
|
||||||
|
console.log(`\n📄 ${file}`);
|
||||||
|
for (const issue of issues) {
|
||||||
|
const pathString = issue.path.length > 0 ? issue.path.join('.') : '(root)';
|
||||||
|
console.log(` Path: ${pathString}`);
|
||||||
|
console.log(` Error: ${issue.message}`);
|
||||||
|
if (issue.code) {
|
||||||
|
console.log(` Code: ${issue.code}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n\n💥 ${errors.length} file(s) failed validation`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n✨ All ${moduleFiles.length} module file(s) passed validation!\n`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run with optional command-line argument for project root
|
||||||
|
const customProjectRoot = process.argv[2];
|
||||||
|
main(customProjectRoot).catch((error) => {
|
||||||
|
console.error('Fatal error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
@ -38,8 +38,10 @@ export default defineConfig({
|
||||||
tagline: 'AI-driven agile development with specialized agents and workflows that scale from bug fixes to enterprise platforms.',
|
tagline: 'AI-driven agile development with specialized agents and workflows that scale from bug fixes to enterprise platforms.',
|
||||||
|
|
||||||
logo: {
|
logo: {
|
||||||
src: './public/img/logo.svg',
|
light: './public/img/bmad-light.png',
|
||||||
alt: 'BMAD Logo',
|
dark: './public/img/bmad-dark.png',
|
||||||
|
alt: 'BMAD Method',
|
||||||
|
replacesTitle: true,
|
||||||
},
|
},
|
||||||
favicon: '/favicon.ico',
|
favicon: '/favicon.ico',
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
|
|
@ -222,6 +222,8 @@ header.header .header.sl-flex {
|
||||||
|
|
||||||
.site-title {
|
.site-title {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
margin-left: 0;
|
||||||
|
padding-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Logo sizing - constrain to reasonable size */
|
/* Logo sizing - constrain to reasonable size */
|
||||||
|
|
@ -470,14 +472,14 @@ footer {
|
||||||
/* Responsive padding on navbar row only - banner stays full-width */
|
/* Responsive padding on navbar row only - banner stays full-width */
|
||||||
@media (min-width: 50rem) {
|
@media (min-width: 50rem) {
|
||||||
header.header .header.sl-flex {
|
header.header .header.sl-flex {
|
||||||
padding-left: 2.5rem;
|
padding-left: 1rem;
|
||||||
padding-right: 2.5rem;
|
padding-right: 2.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 72rem) {
|
@media (min-width: 72rem) {
|
||||||
header.header .header.sl-flex {
|
header.header .header.sl-flex {
|
||||||
padding-left: 3rem;
|
padding-left: 1rem;
|
||||||
padding-right: 3rem;
|
padding-right: 3rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue