@ai-qa/workflow 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/agents/playwright-test-generator.agent.md +33 -0
- package/.github/agents/playwright-test-healer.agent.md +36 -0
- package/.github/agents/playwright-test-planner.agent.md +44 -0
- package/.opencode/agents/qa-generator.md +19 -0
- package/.opencode/agents/qa-healer.md +25 -0
- package/.opencode/agents/qa-planner.md +20 -0
- package/.qa-workflow.json +22 -0
- package/README.md +365 -0
- package/ai-qa-workflow.js +330 -0
- package/cli.js +7 -0
- package/docs/application-context.md +20 -0
- package/install.js +303 -0
- package/opencode.json +31 -0
- package/package.json +30 -0
- package/prompts/QAe2eprompt.md +513 -0
- package/prompts/general_prompt.md +13 -0
- package/qa-dashboard/.env +3 -0
- package/qa-dashboard/app.js +46 -0
- package/qa-dashboard/package.json +18 -0
- package/qa-dashboard/public/css/style.css +266 -0
- package/qa-dashboard/public/js/main.js +6 -0
- package/qa-dashboard/routes/analytics.js +52 -0
- package/qa-dashboard/routes/export.js +153 -0
- package/qa-dashboard/routes/index.js +10 -0
- package/qa-dashboard/routes/projects.js +92 -0
- package/qa-dashboard/routes/runs.js +66 -0
- package/qa-dashboard/routes/stories.js +101 -0
- package/qa-dashboard/routes/test-data.js +82 -0
- package/qa-dashboard/services/cli-bridge.js +143 -0
- package/qa-dashboard/services/project-manager.js +61 -0
- package/qa-dashboard/views/analytics.ejs +188 -0
- package/qa-dashboard/views/error.ejs +8 -0
- package/qa-dashboard/views/index.ejs +83 -0
- package/qa-dashboard/views/layouts/main.ejs +28 -0
- package/qa-dashboard/views/project-add.ejs +23 -0
- package/qa-dashboard/views/project.ejs +97 -0
- package/qa-dashboard/views/projects.ejs +29 -0
- package/qa-dashboard/views/run.ejs +84 -0
- package/qa-dashboard/views/runs.ejs +64 -0
- package/qa-dashboard/views/stories.ejs +53 -0
- package/qa-dashboard/views/story.ejs +63 -0
- package/qa-dashboard/views/test-data.ejs +117 -0
- package/scripts/executor.js +142 -0
- package/scripts/generator.js +130 -0
- package/scripts/healer.js +207 -0
- package/scripts/planner.js +142 -0
- package/scripts/reporter.js +190 -0
- package/scripts/utils.js +244 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
const { DIRS, ensureDir, readMarkdown, writeMarkdown, log } = require('./utils');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
|
|
5
|
+
function generateReport(runId) {
|
|
6
|
+
const runDir = runId
|
|
7
|
+
? path.join(DIRS.testResults, runId)
|
|
8
|
+
: findLatestRunDir();
|
|
9
|
+
|
|
10
|
+
if (!runDir || !fs.existsSync(runDir)) {
|
|
11
|
+
log('REPORTER', 'No execution results found');
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
log('REPORTER', `Generating report from: test-results/${path.basename(runDir)}`);
|
|
16
|
+
|
|
17
|
+
const report = {
|
|
18
|
+
runId: path.basename(runDir),
|
|
19
|
+
generatedAt: new Date().toISOString(),
|
|
20
|
+
execution: null,
|
|
21
|
+
healing: null,
|
|
22
|
+
testFiles: [],
|
|
23
|
+
stats: { total: 0, passed: 0, failed: 0, healed: 0 },
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const resultPath = path.join(runDir, 'execution-result.json');
|
|
27
|
+
if (fs.existsSync(resultPath)) {
|
|
28
|
+
report.execution = JSON.parse(fs.readFileSync(resultPath, 'utf-8'));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const healingPath = path.join(runDir, 'healing-report.json');
|
|
32
|
+
if (fs.existsSync(healingPath)) {
|
|
33
|
+
report.healing = JSON.parse(fs.readFileSync(healingPath, 'utf-8'));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
report.testFiles = listTestFilesWithStatus();
|
|
37
|
+
|
|
38
|
+
if (report.execution) {
|
|
39
|
+
report.stats.total = (report.execution.failedTests || []).length + (report.execution.passedTests || []).length;
|
|
40
|
+
if (report.execution.success) {
|
|
41
|
+
report.stats.passed = report.stats.total;
|
|
42
|
+
} else {
|
|
43
|
+
report.stats.failed = (report.execution.failedTests || []).length;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (report.healing) {
|
|
48
|
+
report.stats.healed = report.healing.totalHealed || 0;
|
|
49
|
+
report.stats.failed = Math.max(0, report.stats.failed - report.stats.healed);
|
|
50
|
+
report.stats.passed += report.stats.healed;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const md = generateMarkdownReport(report);
|
|
54
|
+
const reportPath = path.join(runDir, 'final-test-report.md');
|
|
55
|
+
writeMarkdown(reportPath, md);
|
|
56
|
+
|
|
57
|
+
log('REPORTER', `Report saved: test-results/${path.basename(runDir)}/final-test-report.md`);
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
reportPath,
|
|
61
|
+
stats: report.stats,
|
|
62
|
+
report,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function listTestFilesWithStatus() {
|
|
67
|
+
const testsDir = DIRS.tests;
|
|
68
|
+
if (!fs.existsSync(testsDir)) return [];
|
|
69
|
+
|
|
70
|
+
return fs.readdirSync(testsDir)
|
|
71
|
+
.filter(f => f.endsWith('.spec.ts'))
|
|
72
|
+
.map(f => {
|
|
73
|
+
const content = fs.readFileSync(path.join(testsDir, f), 'utf-8');
|
|
74
|
+
const testCount = (content.match(/test\(/g) || []).length;
|
|
75
|
+
return { file: f, tests: testCount };
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function generateMarkdownReport(report) {
|
|
80
|
+
const { runId, stats, execution, healing, testFiles } = report;
|
|
81
|
+
|
|
82
|
+
let md = `# AI QA Execution Report
|
|
83
|
+
|
|
84
|
+
**Run ID**: ${runId}
|
|
85
|
+
**Generated**: ${report.generatedAt}
|
|
86
|
+
**Status**: ${stats.failed === 0 ? '✅ PASSED' : '❌ FAILED'}
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Executive Summary
|
|
91
|
+
|
|
92
|
+
| Metric | Value |
|
|
93
|
+
|--------|-------|
|
|
94
|
+
| Total Test Cases | ${stats.total || 'N/A'} |
|
|
95
|
+
| Passed | ${stats.passed} |
|
|
96
|
+
| Failed | ${stats.failed} |
|
|
97
|
+
| Healed (Self-Healing) | ${stats.healed} |
|
|
98
|
+
| Pass Rate | ${stats.total > 0 ? Math.round((stats.passed / stats.total) * 100) : 'N/A'}% |
|
|
99
|
+
| Duration | ${execution ? `${(execution.duration / 1000).toFixed(1)}s` : 'N/A'} |
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## Test Coverage
|
|
104
|
+
|
|
105
|
+
| Test File | Scenarios |
|
|
106
|
+
|-----------|-----------|
|
|
107
|
+
`;
|
|
108
|
+
|
|
109
|
+
testFiles.forEach(tf => {
|
|
110
|
+
md += `| ${tf.file} | ${tf.tests} |\n`;
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
if (execution && !execution.success && execution.failedTests && execution.failedTests.length > 0) {
|
|
114
|
+
md += `\n## Failed Tests (Before Healing)\n\n`;
|
|
115
|
+
md += `| # | Test | Error |\n`;
|
|
116
|
+
md += `|---|------|-------|\n`;
|
|
117
|
+
execution.failedTests.forEach((ft, i) => {
|
|
118
|
+
const error = ft.error ? ft.error.substring(0, 100) : 'N/A';
|
|
119
|
+
md += `| ${i + 1} | ${ft.test || ft.file} | ${error} |\n`;
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (healing) {
|
|
124
|
+
md += `\n## Self-Healing Activity\n\n`;
|
|
125
|
+
|
|
126
|
+
if (healing.healingAttempts && healing.healingAttempts.length > 0) {
|
|
127
|
+
md += `| Attempt | Healed | Remaining |\n`;
|
|
128
|
+
md += `|---------|--------|-----------|\n`;
|
|
129
|
+
healing.healingAttempts.forEach(a => {
|
|
130
|
+
md += `| ${a.attempt} | ${a.healed.length} | ${a.stillFailing.length} |\n`;
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (healing.totalHealed > 0) {
|
|
135
|
+
md += `\n**Healed Tests:**\n\n`;
|
|
136
|
+
healing.healingAttempts.forEach(a => {
|
|
137
|
+
a.healed.forEach(h => {
|
|
138
|
+
md += `- \`${h.test || h.file}\` → Healed via "${h.strategy}" (attempt ${h.healedOnAttempt})\n`;
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (healing.totalRemaining > 0) {
|
|
144
|
+
md += `\n**Still Failing (requires AI investigation):**\n\n`;
|
|
145
|
+
md += `These tests could not be auto-healed and need manual inspection:\n\n`;
|
|
146
|
+
(healing.healingAttempts[healing.healingAttempts.length - 1]?.stillFailing || []).forEach(ft => {
|
|
147
|
+
md += `- \`${ft.test || ft.file}\`\n`;
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
md += `\n## Artifacts\n\n`;
|
|
153
|
+
md += `- **Execution Log**: \`test-results/${runId}/execution-output.json\`\n`;
|
|
154
|
+
md += `- **Execution Result**: \`test-results/${runId}/execution-result.json\`\n`;
|
|
155
|
+
|
|
156
|
+
if (healing) {
|
|
157
|
+
md += `- **Healing Report**: \`test-results/${runId}/healing-report.json\`\n`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
md += `- **Allure Results**: \`allure-results/\`\n`;
|
|
161
|
+
md += `- **Screenshots**: \`test-results/\`\n`;
|
|
162
|
+
|
|
163
|
+
md += `\n---\n\n`;
|
|
164
|
+
md += `> **Next Step**: If failures remain, use the Playwright Test Healer agent to investigate.\n`;
|
|
165
|
+
md += `> Run: \`node ai-qa-workflow.js heal ${runId}\`\n`;
|
|
166
|
+
|
|
167
|
+
return md;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function findLatestRunDir() {
|
|
171
|
+
if (!fs.existsSync(DIRS.testResults)) return null;
|
|
172
|
+
|
|
173
|
+
const dirs = fs.readdirSync(DIRS.testResults)
|
|
174
|
+
.map(d => ({ name: d, path: path.join(DIRS.testResults, d) }))
|
|
175
|
+
.filter(d => fs.statSync(d.path).isDirectory())
|
|
176
|
+
.sort((a, b) => fs.statSync(b.path).mtimeMs - fs.statSync(a.path).mtimeMs);
|
|
177
|
+
|
|
178
|
+
return dirs.length > 0 ? dirs[0].path : null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (require.main === module) {
|
|
182
|
+
const runId = process.argv[2];
|
|
183
|
+
const result = generateReport(runId);
|
|
184
|
+
if (result) {
|
|
185
|
+
console.log(`\n Report: ${result.reportPath}`);
|
|
186
|
+
console.log(` Passed: ${result.stats.passed} | Failed: ${result.stats.failed} | Healed: ${result.stats.healed}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
module.exports = { generateReport };
|
package/scripts/utils.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
5
|
+
|
|
6
|
+
const CONFIG_PATH = path.join(ROOT, '.qa-workflow.json');
|
|
7
|
+
|
|
8
|
+
function extractUrl(script) {
|
|
9
|
+
if (!script) return null;
|
|
10
|
+
const portMatch = script.match(/--port\s+(\d+)/) || script.match(/-p\s+(\d+)/) || script.match(/PORT=(\d+)/);
|
|
11
|
+
if (portMatch) return `http://localhost:${portMatch[1]}`;
|
|
12
|
+
const urlMatch = script.match(/https?:\/\/[^\s"'`]+/);
|
|
13
|
+
return urlMatch ? urlMatch[0] : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function autoDetectConfig() {
|
|
17
|
+
const detected = {
|
|
18
|
+
project: { name: '', description: '', url: '', environment: '' },
|
|
19
|
+
browser: { type: 'chromium', cdpPort: 9222, headed: false },
|
|
20
|
+
test: { timeout: 120000, retries: 0, workers: 1 },
|
|
21
|
+
auth: { user: '', credentials: {} },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Detect from package.json
|
|
25
|
+
const pkgPath = path.join(ROOT, 'package.json');
|
|
26
|
+
if (fs.existsSync(pkgPath)) {
|
|
27
|
+
try {
|
|
28
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
29
|
+
detected.project.name = pkg.name || path.basename(ROOT);
|
|
30
|
+
detected.project.description = pkg.description || '';
|
|
31
|
+
if (pkg.scripts) {
|
|
32
|
+
const devScript = pkg.scripts.dev || pkg.scripts.start || pkg.scripts.serve || '';
|
|
33
|
+
detected.project.url = extractUrl(devScript) || 'http://localhost:3000';
|
|
34
|
+
}
|
|
35
|
+
} catch {}
|
|
36
|
+
} else {
|
|
37
|
+
detected.project.name = path.basename(ROOT);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Detect from docker-compose (first exposed port)
|
|
41
|
+
const composePaths = ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml'];
|
|
42
|
+
for (const cp of composePaths) {
|
|
43
|
+
const cpPath = path.join(ROOT, cp);
|
|
44
|
+
if (fs.existsSync(cpPath)) {
|
|
45
|
+
try {
|
|
46
|
+
const content = fs.readFileSync(cpPath, 'utf-8');
|
|
47
|
+
const portMatch = content.match(/"(\d+):\d+"/) || content.match(/'(\d+):\d+'/);
|
|
48
|
+
if (portMatch) detected.project.url = `http://localhost:${portMatch[1]}`;
|
|
49
|
+
const envMatch = content.match(/COMPOSE_PROJECT_NAME[=:]\s*["']?([^"'\s]+)/);
|
|
50
|
+
if (envMatch && !detected.project.name) detected.project.name = envMatch[1];
|
|
51
|
+
} catch {}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Detect environment from .env
|
|
56
|
+
const envPath = path.join(ROOT, '.env');
|
|
57
|
+
if (fs.existsSync(envPath)) {
|
|
58
|
+
try {
|
|
59
|
+
const envContent = fs.readFileSync(envPath, 'utf-8');
|
|
60
|
+
const appUrl = envContent.match(/APP_URL\s*=\s*(.+)/);
|
|
61
|
+
if (appUrl) detected.project.url = appUrl[1].trim();
|
|
62
|
+
const appEnv = envContent.match(/APP_ENV\s*=\s*(.+)/) || envContent.match(/NODE_ENV\s*=\s*(.+)/);
|
|
63
|
+
if (appEnv) detected.project.environment = appEnv[1].trim();
|
|
64
|
+
} catch {}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Detect from README
|
|
68
|
+
for (const readme of ['README.md', 'README.rst', 'README.txt']) {
|
|
69
|
+
const rp = path.join(ROOT, readme);
|
|
70
|
+
if (fs.existsSync(rp)) {
|
|
71
|
+
try {
|
|
72
|
+
const content = fs.readFileSync(rp, 'utf-8');
|
|
73
|
+
const titleMatch = content.match(/^#\s+(.+)/m);
|
|
74
|
+
if (titleMatch && !detected.project.name) detected.project.name = titleMatch[1].trim();
|
|
75
|
+
const urlMatch = content.match(/https?:\/\/[^\s"')\]>]+/);
|
|
76
|
+
if (urlMatch) detected.project.url = urlMatch[0].replace(/[.)]>]$/g, '');
|
|
77
|
+
const descLine = content.split('\n').slice(1, 3).join(' ').replace(/^>?\s*/, '').substring(0, 300);
|
|
78
|
+
if (descLine && !detected.project.description) detected.project.description = descLine;
|
|
79
|
+
} catch {}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Detect browser type from playwright config
|
|
84
|
+
const pwPaths = ['playwright.config.ts', 'playwright.config.js'];
|
|
85
|
+
for (const pw of pwPaths) {
|
|
86
|
+
const pwPath = path.join(ROOT, pw);
|
|
87
|
+
if (fs.existsSync(pwPath)) {
|
|
88
|
+
try {
|
|
89
|
+
const content = fs.readFileSync(pwPath, 'utf-8');
|
|
90
|
+
if (/\bedge\b/i.test(content) || /\bmsedge\b/i.test(content)) detected.browser.type = 'edge';
|
|
91
|
+
if (/webkit/i.test(content)) detected.browser.type = 'webkit';
|
|
92
|
+
} catch {}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Detect project type from directory patterns
|
|
97
|
+
if (fs.existsSync(path.join(ROOT, 'src', 'app')) || fs.existsSync(path.join(ROOT, 'angular.json'))) {
|
|
98
|
+
detected.project.environment = detected.project.environment || 'Angular App';
|
|
99
|
+
} else if (fs.existsSync(path.join(ROOT, 'src', 'pages')) && fs.existsSync(path.join(ROOT, 'next.config.js'))) {
|
|
100
|
+
detected.project.environment = detected.project.environment || 'Next.js App';
|
|
101
|
+
} else if (fs.existsSync(path.join(ROOT, 'src')) && !detected.project.environment) {
|
|
102
|
+
detected.project.environment = 'Web Application';
|
|
103
|
+
} else if (fs.existsSync(path.join(ROOT, 'app.py')) || fs.existsSync(path.join(ROOT, 'manage.py'))) {
|
|
104
|
+
detected.project.environment = 'Python Web App';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return detected;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function loadConfig() {
|
|
111
|
+
const defaults = {
|
|
112
|
+
project: { name: 'my-project', description: '', url: 'http://localhost:3000', environment: 'Development' },
|
|
113
|
+
browser: { type: 'chromium', cdpPort: 9222, headed: false },
|
|
114
|
+
test: { timeout: 120000, retries: 0, workers: 1 },
|
|
115
|
+
auth: { user: '', credentials: {} },
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
let base = { ...defaults };
|
|
119
|
+
if (fs.existsSync(CONFIG_PATH)) {
|
|
120
|
+
try {
|
|
121
|
+
const userConfig = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
|
122
|
+
deepMerge(base, userConfig);
|
|
123
|
+
return base;
|
|
124
|
+
} catch (e) {
|
|
125
|
+
console.warn(` ⚠ Failed to parse .qa-workflow.json: ${e.message}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// No config file — auto-detect from project, then fill gaps with defaults
|
|
130
|
+
const detected = autoDetectConfig();
|
|
131
|
+
deepMerge(base, detected);
|
|
132
|
+
return base;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function deepMerge(target, source) {
|
|
136
|
+
for (const key of Object.keys(source)) {
|
|
137
|
+
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
|
138
|
+
if (!target[key]) target[key] = {};
|
|
139
|
+
deepMerge(target[key], source[key]);
|
|
140
|
+
} else {
|
|
141
|
+
target[key] = source[key];
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let CONFIG = loadConfig();
|
|
147
|
+
|
|
148
|
+
function reloadConfig() {
|
|
149
|
+
CONFIG = loadConfig();
|
|
150
|
+
return CONFIG;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const DIRS = {
|
|
154
|
+
userStory: path.join(ROOT, 'user-story'),
|
|
155
|
+
specs: path.join(ROOT, 'specs'),
|
|
156
|
+
tests: path.join(ROOT, 'tests'),
|
|
157
|
+
testResults: path.join(ROOT, 'test-results'),
|
|
158
|
+
allureResults: path.join(ROOT, 'allure-results'),
|
|
159
|
+
scratch: path.join(ROOT, 'scratch'),
|
|
160
|
+
docs: path.join(ROOT, 'docs'),
|
|
161
|
+
scripts: path.join(ROOT, 'scripts'),
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
function ensureDir(dir) {
|
|
165
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function listFiles(dir, ext) {
|
|
169
|
+
if (!fs.existsSync(dir)) return [];
|
|
170
|
+
return fs.readdirSync(dir)
|
|
171
|
+
.filter(f => f.endsWith(ext))
|
|
172
|
+
.sort();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function listUserStories() {
|
|
176
|
+
return listFiles(DIRS.userStory, '.md');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function listTestPlans() {
|
|
180
|
+
return listFiles(DIRS.specs, '.md');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function listTestSpecs() {
|
|
184
|
+
return listFiles(DIRS.tests, '.spec.ts');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function readMarkdown(filePath) {
|
|
188
|
+
return fs.readFileSync(filePath, 'utf-8');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function writeMarkdown(filePath, content) {
|
|
192
|
+
ensureDir(path.dirname(filePath));
|
|
193
|
+
fs.writeFileSync(filePath, content, 'utf-8');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function parseUserStory(filePath) {
|
|
197
|
+
const content = readMarkdown(filePath);
|
|
198
|
+
const story = { content, metadata: {} };
|
|
199
|
+
|
|
200
|
+
const idMatch = content.match(/\*\*Story ID\*\*:\s*(\S+)/);
|
|
201
|
+
if (idMatch) story.id = idMatch[1];
|
|
202
|
+
|
|
203
|
+
const titleMatch = content.match(/^#\s+(.+)$/m);
|
|
204
|
+
if (titleMatch) story.title = titleMatch[1].trim();
|
|
205
|
+
|
|
206
|
+
const titleMetaMatch = content.match(/\*\*Title\*\*:\s*(.+)/);
|
|
207
|
+
if (titleMetaMatch) story.metaTitle = titleMetaMatch[1].trim();
|
|
208
|
+
|
|
209
|
+
const descMatch = content.match(/## Description\s*\n(.+?)(?=\n## |\n---|$)/s);
|
|
210
|
+
if (descMatch) story.description = descMatch[1].trim();
|
|
211
|
+
|
|
212
|
+
const precondMatch = content.match(/## Preconditions\s*\n(.+?)(?=\n## |\n---|$)/s);
|
|
213
|
+
if (precondMatch) {
|
|
214
|
+
story.preconditions = precondMatch[1]
|
|
215
|
+
.split('\n')
|
|
216
|
+
.filter(l => l.trim().startsWith('-'))
|
|
217
|
+
.map(l => l.replace(/^- /, '').trim());
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const acMatch = content.match(/## Acceptance Criteria\s*\n(.+?)(?=\n## |\n---|$)/s);
|
|
221
|
+
if (acMatch) {
|
|
222
|
+
story.acceptanceCriteria = acMatch[1].trim();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return story;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function slugify(text) {
|
|
229
|
+
return text
|
|
230
|
+
.toLowerCase()
|
|
231
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
232
|
+
.replace(/^-|-$/g, '');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function timestamp() {
|
|
236
|
+
return new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function log(step, msg) {
|
|
240
|
+
const ts = new Date().toLocaleTimeString();
|
|
241
|
+
console.log(` [${ts}] [${step}] ${msg}`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
module.exports = { ROOT, DIRS, CONFIG, CONFIG_PATH, loadConfig, reloadConfig, autoDetectConfig, ensureDir, listFiles, listUserStories, listTestPlans, listTestSpecs, readMarkdown, writeMarkdown, parseUserStory, slugify, timestamp, log, extractUrl };
|