@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.
Files changed (48) hide show
  1. package/.github/agents/playwright-test-generator.agent.md +33 -0
  2. package/.github/agents/playwright-test-healer.agent.md +36 -0
  3. package/.github/agents/playwright-test-planner.agent.md +44 -0
  4. package/.opencode/agents/qa-generator.md +19 -0
  5. package/.opencode/agents/qa-healer.md +25 -0
  6. package/.opencode/agents/qa-planner.md +20 -0
  7. package/.qa-workflow.json +22 -0
  8. package/README.md +365 -0
  9. package/ai-qa-workflow.js +330 -0
  10. package/cli.js +7 -0
  11. package/docs/application-context.md +20 -0
  12. package/install.js +303 -0
  13. package/opencode.json +31 -0
  14. package/package.json +30 -0
  15. package/prompts/QAe2eprompt.md +513 -0
  16. package/prompts/general_prompt.md +13 -0
  17. package/qa-dashboard/.env +3 -0
  18. package/qa-dashboard/app.js +46 -0
  19. package/qa-dashboard/package.json +18 -0
  20. package/qa-dashboard/public/css/style.css +266 -0
  21. package/qa-dashboard/public/js/main.js +6 -0
  22. package/qa-dashboard/routes/analytics.js +52 -0
  23. package/qa-dashboard/routes/export.js +153 -0
  24. package/qa-dashboard/routes/index.js +10 -0
  25. package/qa-dashboard/routes/projects.js +92 -0
  26. package/qa-dashboard/routes/runs.js +66 -0
  27. package/qa-dashboard/routes/stories.js +101 -0
  28. package/qa-dashboard/routes/test-data.js +82 -0
  29. package/qa-dashboard/services/cli-bridge.js +143 -0
  30. package/qa-dashboard/services/project-manager.js +61 -0
  31. package/qa-dashboard/views/analytics.ejs +188 -0
  32. package/qa-dashboard/views/error.ejs +8 -0
  33. package/qa-dashboard/views/index.ejs +83 -0
  34. package/qa-dashboard/views/layouts/main.ejs +28 -0
  35. package/qa-dashboard/views/project-add.ejs +23 -0
  36. package/qa-dashboard/views/project.ejs +97 -0
  37. package/qa-dashboard/views/projects.ejs +29 -0
  38. package/qa-dashboard/views/run.ejs +84 -0
  39. package/qa-dashboard/views/runs.ejs +64 -0
  40. package/qa-dashboard/views/stories.ejs +53 -0
  41. package/qa-dashboard/views/story.ejs +63 -0
  42. package/qa-dashboard/views/test-data.ejs +117 -0
  43. package/scripts/executor.js +142 -0
  44. package/scripts/generator.js +130 -0
  45. package/scripts/healer.js +207 -0
  46. package/scripts/planner.js +142 -0
  47. package/scripts/reporter.js +190 -0
  48. package/scripts/utils.js +244 -0
@@ -0,0 +1,142 @@
1
+ const { DIRS, CONFIG, ensureDir, timestamp, log } = require('./utils');
2
+ const path = require('path');
3
+ const fs = require('fs');
4
+ const { execSync, spawn } = require('child_process');
5
+
6
+ function executeTests(testName, options = {}) {
7
+ const testCfg = CONFIG.test || {};
8
+ const {
9
+ headed = CONFIG.browser && CONFIG.browser.headed || false,
10
+ timeout = testCfg.timeout || 120000,
11
+ retries = testCfg.retries || 0,
12
+ workers = testCfg.workers || 1,
13
+ screenshots = 'off',
14
+ } = options;
15
+
16
+ const runId = `run-${timestamp()}`;
17
+ const runDir = path.join(DIRS.testResults, runId);
18
+ ensureDir(runDir);
19
+
20
+ log('EXECUTOR', `Run ID: ${runId}`);
21
+ log('EXECUTOR', `Test: ${testName || 'all tests'}`);
22
+
23
+ const args = ['playwright', 'test'];
24
+
25
+ if (testName) {
26
+ let testFile = testName.endsWith('.spec.ts') ? testName : `${testName}.spec.ts`;
27
+ if (testFile.includes('/') || testFile.includes('\\')) {
28
+ args.push(testFile);
29
+ } else {
30
+ args.push(path.join('tests', testFile));
31
+ }
32
+ }
33
+
34
+ args.push('--reporter=list,json');
35
+
36
+ if (headed) args.push('--headed');
37
+ if (retries > 0) args.push(`--retries=${retries}`);
38
+ if (workers) args.push(`--workers=${workers}`);
39
+ if (timeout) args.push(`--timeout=${timeout}`);
40
+ if (screenshots === 'off') args.push('--screenshot=off');
41
+ if (screenshots === 'only-on-failure') args.push('--screenshot=only-on-failure');
42
+
43
+ const outputPath = path.join(runDir, 'execution-output.json');
44
+ const resultPath = path.join(runDir, 'execution-result.json');
45
+
46
+ log('EXECUTOR', `Command: npx ${args.join(' ')}`);
47
+
48
+ const startTime = Date.now();
49
+
50
+ try {
51
+ const stdout = execSync(`npx ${args.join(' ')}`, {
52
+ cwd: DIRS.ROOT,
53
+ encoding: 'utf-8',
54
+ timeout: 300000,
55
+ maxBuffer: 10 * 1024 * 1024,
56
+ stdio: ['pipe', 'pipe', 'pipe'],
57
+ });
58
+
59
+ fs.writeFileSync(outputPath, stdout, 'utf-8');
60
+
61
+ const result = {
62
+ runId,
63
+ test: testName || 'all',
64
+ success: true,
65
+ exitCode: 0,
66
+ duration: Date.now() - startTime,
67
+ timestamp: new Date().toISOString(),
68
+ output: stdout.substring(0, 50000),
69
+ failedTests: [],
70
+ passedTests: [],
71
+ };
72
+
73
+ writeMarkdown(resultPath, JSON.stringify(result, null, 2));
74
+ log('EXECUTOR', `Tests passed (${result.duration}ms)`);
75
+
76
+ return result;
77
+ } catch (err) {
78
+ const stderr = err.stderr || '';
79
+ const stdout = err.stdout || '';
80
+
81
+ const combinedOutput = stdout + '\n' + stderr;
82
+ fs.writeFileSync(outputPath, combinedOutput, 'utf-8');
83
+
84
+ const failedTests = extractFailedTests(combinedOutput);
85
+
86
+ const result = {
87
+ runId,
88
+ test: testName || 'all',
89
+ success: false,
90
+ exitCode: err.status || 1,
91
+ duration: Date.now() - startTime,
92
+ timestamp: new Date().toISOString(),
93
+ error: err.message,
94
+ output: combinedOutput.substring(0, 50000),
95
+ failedTests,
96
+ };
97
+
98
+ writeMarkdown(resultPath, JSON.stringify(result, null, 2));
99
+ log('EXECUTOR', `Tests failed (${result.duration}ms) - ${failedTests.length} failure(s)`);
100
+
101
+ return result;
102
+ }
103
+ }
104
+
105
+ function extractFailedTests(output) {
106
+ const failed = [];
107
+ const lines = output.split('\n');
108
+
109
+ let current = null;
110
+ for (const line of lines) {
111
+ const failMatch = line.match(/\s+×\s+\d+\)\s+\[(.+?)\]\s+(.+)/);
112
+ if (failMatch) {
113
+ failed.push({ file: failMatch[1], test: failMatch[2].trim() });
114
+ continue;
115
+ }
116
+
117
+ const failSimple = line.match(/\s+×\s+(.+)/);
118
+ if (failSimple && !line.includes('ms')) {
119
+ current = { test: failSimple[1].trim(), error: '' };
120
+ continue;
121
+ }
122
+
123
+ if (current && line.trim()) {
124
+ current.error += line.trim() + ' ';
125
+ }
126
+
127
+ if (current && line.trim() === '') {
128
+ failed.push(current);
129
+ current = null;
130
+ }
131
+ }
132
+
133
+ return failed;
134
+ }
135
+
136
+ if (require.main === module) {
137
+ const testName = process.argv[2];
138
+ const headed = process.argv.includes('--headed');
139
+ executeTests(testName, { headed });
140
+ }
141
+
142
+ module.exports = { executeTests };
@@ -0,0 +1,130 @@
1
+ const { DIRS, CONFIG, readMarkdown, writeMarkdown, slugify, log } = require('./utils');
2
+ const path = require('path');
3
+ const fs = require('fs');
4
+
5
+ function generateTestSpec(planName) {
6
+ const planPath = path.join(DIRS.specs, planName);
7
+ if (!fs.existsSync(planPath)) {
8
+ console.error(`Test plan not found: ${planPath}`);
9
+ console.log(`Available plans: ${require('./utils').listTestPlans().join(', ')}`);
10
+ process.exit(1);
11
+ }
12
+
13
+ const content = readMarkdown(planPath);
14
+ const specName = planName.replace('-test-plan.md', '');
15
+ const specPath = path.join(DIRS.tests, `${specName}.spec.ts`);
16
+
17
+ log('GENERATOR', `Generating test spec from: ${planName}`);
18
+
19
+ const spec = generateSpecContent(content, specName, planName);
20
+
21
+ writeMarkdown(specPath, spec);
22
+ log('GENERATOR', `Test spec saved: tests/${specName}.spec.ts`);
23
+
24
+ return { specName, specPath };
25
+ }
26
+
27
+ function generateSpecContent(planContent, specName, planFileName) {
28
+ const featureMatch = planContent.match(/\*\*Feature\*\*:\s*(.+)/);
29
+ const feature = featureMatch ? featureMatch[1].trim() : specName;
30
+
31
+ const scenarios = extractScenarios(planContent);
32
+
33
+ const browserType = CONFIG.browser.type || 'chromium';
34
+ const cdpPort = CONFIG.browser.cdpPort || 9222;
35
+ const cdpImport = browserType === 'edge' ? "import { startEdgeCDP } from '../cdpSession';" : `import { chromium } from '@playwright/test';`;
36
+
37
+ let setupBlock;
38
+ if (browserType === 'edge') {
39
+ setupBlock = ` // const { page } = await startEdgeCDP();\n // global.page = page;`;
40
+ } else {
41
+ setupBlock = ` // const browser = await chromium.launch({ headless: !process.env.HEADED });\n // const page = await browser.newPage();\n // global.page = page;`;
42
+ }
43
+
44
+ let spec = `import { test, expect } from '@playwright/test';
45
+ ${cdpImport}
46
+
47
+ test.describe('${feature.replace(/'/g, "\\'")}', () => {
48
+
49
+ test.beforeEach(async () => {
50
+ ${setupBlock}
51
+ });
52
+
53
+ test.afterEach(async () => {
54
+ // Cleanup if needed
55
+ });
56
+
57
+ `;
58
+
59
+ scenarios.forEach((scenario, idx) => {
60
+ const safeTitle = scenario.title.replace(/['"]/g, "'");
61
+ spec += ` test('${safeTitle}', async () => {\n`;
62
+ spec += ` // TODO: Implement test steps from plan\n`;
63
+ spec += ` // Reference: specs/${planFileName} (Scenario ${idx + 3}.1)\n`;
64
+ spec += ` // \n`;
65
+
66
+ scenario.steps.forEach((step, si) => {
67
+ const truncated = step.length > 100 ? step.substring(0, 97) + '...' : step;
68
+ spec += ` // Step ${si + 1}: ${truncated}\n`;
69
+ });
70
+
71
+ spec += ` // \n`;
72
+ spec += ` // Expected: ${scenario.expected || 'Workflow completes successfully'}\n`;
73
+ spec += ` \n`;
74
+ spec += ` });\n\n`;
75
+ });
76
+
77
+ spec += `});\n`;
78
+
79
+ return spec;
80
+ }
81
+
82
+ function extractScenarios(content) {
83
+ const scenarios = [];
84
+ const lines = content.split('\n');
85
+ let current = null;
86
+
87
+ for (let i = 0; i < lines.length; i++) {
88
+ const line = lines[i];
89
+
90
+ const scenarioMatch = line.match(/###\s+\d+\.\d+\s+Scenario:\s*(.+)/i);
91
+ if (scenarioMatch) {
92
+ if (current) scenarios.push(current);
93
+ current = { title: scenarioMatch[1].trim(), steps: [], expected: '' };
94
+ continue;
95
+ }
96
+
97
+ if (current) {
98
+ const stepMatch = line.match(/^\d+\.\s+(.+)/);
99
+ if (stepMatch && !line.match(/^\d+\.\s+\*\*/)) {
100
+ current.steps.push(stepMatch[1].trim());
101
+ }
102
+
103
+ const expectedMatch = line.match(/Expected Results?[:\]]\s*(.+)/i);
104
+ if (expectedMatch) {
105
+ current.expected = expectedMatch[1].trim();
106
+ }
107
+
108
+ if (line.match(/---/) && current.steps.length > 0) {
109
+ scenarios.push(current);
110
+ current = null;
111
+ }
112
+ }
113
+ }
114
+
115
+ if (current) scenarios.push(current);
116
+
117
+ return scenarios;
118
+ }
119
+
120
+ if (require.main === module) {
121
+ const planName = process.argv[2];
122
+ if (!planName) {
123
+ console.log('Usage: node scripts/generator.js <test-plan-file.md>');
124
+ console.log(`Available: ${require('./utils').listTestPlans().join(', ')}`);
125
+ process.exit(1);
126
+ }
127
+ generateTestSpec(planName);
128
+ }
129
+
130
+ module.exports = { generateTestSpec };
@@ -0,0 +1,207 @@
1
+ const { DIRS, ensureDir, timestamp, log } = require('./utils');
2
+ const path = require('path');
3
+ const fs = require('fs');
4
+
5
+ const KNOWN_BUG_PATTERNS = [
6
+ { pattern: /element\(s\) not found/i, type: 'selector' },
7
+ { pattern: /strict mode violation/i, type: 'selector' },
8
+ { pattern: /Target closed/i, type: 'environment' },
9
+ { pattern: /net::ERR_/i, type: 'environment' },
10
+ { pattern: /timeout/i, type: 'timing' },
11
+ { pattern: /page\.goto/i, type: 'navigation' },
12
+ { pattern: /Cannot find module/i, type: 'test-syntax' },
13
+ ];
14
+
15
+ function classifyFailure(errorMessage) {
16
+ if (!errorMessage) return { type: 'unknown', fixable: false };
17
+ for (const rule of KNOWN_BUG_PATTERNS) {
18
+ if (rule.pattern.test(errorMessage)) {
19
+ return { type: rule.type, fixable: rule.type !== 'environment' };
20
+ }
21
+ }
22
+ return { type: 'unknown', fixable: false };
23
+ }
24
+
25
+ function selfHeal(runId, projectName, options = {}) {
26
+ const { maxAttempts = 2 } = options;
27
+
28
+ const runDir = runId
29
+ ? path.join(DIRS.testResults, runId)
30
+ : findLatestRunDir();
31
+
32
+ if (!runDir || !fs.existsSync(runDir)) {
33
+ log('HEALER', 'No execution results found to heal');
34
+ return { healed: [], remaining: [], defects: [] };
35
+ }
36
+
37
+ const resultPath = path.join(runDir, 'execution-result.json');
38
+ if (!fs.existsSync(resultPath)) {
39
+ log('HEALER', 'No execution result found');
40
+ return { healed: [], remaining: [], defects: [] };
41
+ }
42
+
43
+ const result = JSON.parse(fs.readFileSync(resultPath, 'utf-8'));
44
+
45
+ if (result.success) {
46
+ log('HEALER', 'All tests passed - nothing to heal');
47
+ return { healed: [], remaining: [], defects: [] };
48
+ }
49
+
50
+ const screenshotDir = path.join(DIRS.testResults, 'screenshots', projectName || 'unknown');
51
+ ensureDir(screenshotDir);
52
+
53
+ const defects = [];
54
+ let remainingFailures = [...(result.failedTests || [])];
55
+
56
+ log('HEALER', `Found ${remainingFailures.length} failed test(s)`);
57
+ log('HEALER', `Screenshots will be saved to: test-results/screenshots/${projectName || 'unknown'}/`);
58
+
59
+ remainingFailures = remainingFailures.filter(failure => {
60
+ const classification = classifyFailure(failure.error);
61
+ const screenshotPath = path.join(screenshotDir, `${slugify(failure.test || 'failure')}.png`);
62
+
63
+ defects.push({
64
+ test: failure.test || failure.file,
65
+ error: failure.error ? failure.error.substring(0, 200) : 'Unknown',
66
+ classification: classification.type,
67
+ verdict: classification.fixable ? 'FIXABLE' : 'BUG',
68
+ action: classification.fixable
69
+ ? 'Attempting auto-heal'
70
+ : 'Cannot auto-fix — reporting as defect',
71
+ screenshot: screenshotPath,
72
+ });
73
+ log('HEALER', ` ${classification.fixable ? '⚠️' : '⛔'} ${failure.test || failure.file} — ${classification.type}${classification.fixable ? '' : ' (BUG)'}`);
74
+
75
+ if (!classification.fixable) return false;
76
+ return true;
77
+ });
78
+
79
+ const healingLog = [];
80
+ let attempt = 0;
81
+
82
+ while (attempt < maxAttempts && remainingFailures.length > 0) {
83
+ attempt++;
84
+ log('HEALER', `Healing attempt ${attempt}/${maxAttempts}`);
85
+
86
+ const healed = [];
87
+ const stillFailing = [];
88
+
89
+ for (const failure of remainingFailures) {
90
+ log('HEALER', ` Re-running: ${failure.test || failure.file}`);
91
+ const healedResult = attemptHealStrategy(failure, attempt, runDir);
92
+
93
+ if (healedResult.success) {
94
+ healed.push({ ...failure, healedOnAttempt: attempt, strategy: healedResult.strategy });
95
+ const defect = defects.find(d => d.test === failure.test);
96
+ if (defect) defect.healed = true;
97
+ log('HEALER', ` ✓ Healed: ${failure.test || failure.file}`);
98
+ } else {
99
+ stillFailing.push(failure);
100
+ log('HEALER', ` ✗ Still failing: ${failure.test || failure.file}`);
101
+ const defect = defects.find(d => d.test === failure.test);
102
+ if (defect) {
103
+ defect.verdict = 'UNSTABLE';
104
+ defect.action = 'Requires manual investigation';
105
+ }
106
+ }
107
+ }
108
+
109
+ healingLog.push({ attempt, healed, stillFailing, timestamp: new Date().toISOString() });
110
+ remainingFailures = stillFailing;
111
+ }
112
+
113
+ remainingFailures.forEach(f => {
114
+ if (!defects.find(d => d.test === f.test)) {
115
+ defects.push({
116
+ test: f.test || f.file,
117
+ error: f.error ? f.error.substring(0, 200) : 'Failed after healing',
118
+ classification: 'unresolved',
119
+ verdict: 'UNSTABLE',
120
+ action: 'Requires manual investigation',
121
+ });
122
+ }
123
+ });
124
+
125
+ const healingReport = {
126
+ projectName: projectName || 'unknown',
127
+ screenshotDir: `test-results/screenshots/${projectName || 'unknown'}/`,
128
+ runId: path.basename(runDir),
129
+ originalResult: { total: result.failedTests ? result.failedTests.length : 0 },
130
+ healingAttempts: healingLog,
131
+ totalHealed: healingLog.reduce((sum, a) => sum + a.healed.length, 0),
132
+ totalRemaining: remainingFailures.length,
133
+ totalDefects: defects.length,
134
+ defects,
135
+ timestamp: new Date().toISOString(),
136
+ finalStatus: remainingFailures.length === 0 && defects.filter(d => d.verdict !== 'FIXABLE').length === 0 ? 'all-passed'
137
+ : remainingFailures.length === 0 ? 'all-passed-with-defects'
138
+ : 'partial',
139
+ };
140
+
141
+ const reportPath = path.join(runDir, 'healing-report.json');
142
+ fs.writeFileSync(reportPath, JSON.stringify(healingReport, null, 2), 'utf-8');
143
+
144
+ log('HEALER', `Healing complete:`);
145
+ log('HEALER', ` Healed: ${healingReport.totalHealed}`);
146
+ log('HEALER', ` Defects found: ${defects.filter(d => d.verdict === 'BUG').length}`);
147
+ log('HEALER', ` Screenshots: test-results/screenshots/${projectName || 'unknown'}/`);
148
+
149
+ if (defects.length > 0) {
150
+ console.log('\n Defects:');
151
+ defects.forEach(d => {
152
+ const icon = d.verdict === 'BUG' ? '⛔ BUG' : d.verdict === 'UNSTABLE' ? '⚠️ UNSTABLE' : '✓ FIXED';
153
+ console.log(` ${icon} ${d.test}`);
154
+ console.log(` → ${d.classification}: ${d.error.substring(0, 100)}`);
155
+ console.log(` → Screenshot: ${d.screenshot}`);
156
+ });
157
+ }
158
+
159
+ return { healed: healingLog.flatMap(a => a.healed), remaining: remainingFailures, defects, reportPath };
160
+ }
161
+
162
+ function attemptHealStrategy(failure, attempt, runDir) {
163
+ const strategies = [
164
+ { name: 'retry-standard' },
165
+ { name: 'retry-longer-timeout', timeout: 60000 },
166
+ ];
167
+
168
+ const strategy = strategies[Math.min(attempt - 1, strategies.length - 1)];
169
+ const testFile = failure.file || failure.test || '';
170
+
171
+ try {
172
+ const args = ['npx', 'playwright', 'test', testFile, '--reporter=list'];
173
+ if (strategy.timeout) args.push(`--timeout=${strategy.timeout}`);
174
+
175
+ require('child_process').execSync(args.join(' '), {
176
+ cwd: DIRS.ROOT,
177
+ encoding: 'utf-8',
178
+ timeout: 120000,
179
+ stdio: 'pipe',
180
+ });
181
+
182
+ return { success: true, strategy: strategy.name };
183
+ } catch {
184
+ return { success: false, strategy: strategy.name };
185
+ }
186
+ }
187
+
188
+ function slugify(text) {
189
+ return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').substring(0, 80);
190
+ }
191
+
192
+ function findLatestRunDir() {
193
+ if (!fs.existsSync(DIRS.testResults)) return null;
194
+ const dirs = fs.readdirSync(DIRS.testResults)
195
+ .map(d => ({ name: d, path: path.join(DIRS.testResults, d) }))
196
+ .filter(d => fs.statSync(d.path).isDirectory())
197
+ .sort((a, b) => fs.statSync(b.path).mtimeMs - fs.statSync(a.path).mtimeMs);
198
+ return dirs.length > 0 ? dirs[0].path : null;
199
+ }
200
+
201
+ if (require.main === module) {
202
+ const runId = process.argv[2];
203
+ const projectName = process.argv[3];
204
+ selfHeal(runId, projectName);
205
+ }
206
+
207
+ module.exports = { selfHeal };
@@ -0,0 +1,142 @@
1
+ const { DIRS, CONFIG, parseUserStory, readMarkdown, writeMarkdown, slugify, log } = require('./utils');
2
+ const path = require('path');
3
+
4
+ function generateTestPlan(storyName) {
5
+ const storyPath = path.join(DIRS.userStory, storyName);
6
+ if (!require('fs').existsSync(storyPath)) {
7
+ console.error(`User story not found: ${storyPath}`);
8
+ console.log(`Available stories: ${require('./utils').listUserStories().join(', ')}`);
9
+ process.exit(1);
10
+ }
11
+
12
+ const story = parseUserStory(storyPath);
13
+ const planId = slugify(story.id || story.title || storyName.replace('.md', ''));
14
+ const planPath = path.join(DIRS.specs, `${planId}-test-plan.md`);
15
+
16
+ log('PLANNER', `Generating test plan from: ${storyName}`);
17
+ log('PLANNER', `Story: ${story.metaTitle || story.title}`);
18
+
19
+ const plan = generatePlanContent(story, planId);
20
+
21
+ writeMarkdown(planPath, plan);
22
+ log('PLANNER', `Test plan saved: specs/${planId}-test-plan.md`);
23
+
24
+ return { planId, planPath };
25
+ }
26
+
27
+ function generatePlanContent(story, planId) {
28
+ const date = new Date().toISOString().split('T')[0];
29
+
30
+ let plan = `# Test Plan: ${story.metaTitle || story.title}
31
+
32
+ **ID**: PLAN-${(story.id || planId).toUpperCase()}
33
+ **Feature**: ${story.title || story.metaTitle || 'N/A'}
34
+ **Status**: Draft
35
+ **User Story Ref**: ${story.id || 'N/A'}
36
+ **Generated**: ${date}
37
+
38
+ ---
39
+
40
+ ## 1. Overview
41
+
42
+ ${story.description || 'Automated test plan derived from user story.'}
43
+
44
+ ---
45
+
46
+ ## 2. Preconditions
47
+
48
+ `;
49
+
50
+ if (story.preconditions && story.preconditions.length > 0) {
51
+ story.preconditions.forEach(p => { plan += `- ${p}\n`; });
52
+ } else {
53
+ plan += `- Environment: ${CONFIG.project.url}\n`;
54
+ plan += `- Browser: ${CONFIG.browser.type === 'edge' ? 'Microsoft Edge via CDP' : 'Chromium'} (Port ${CONFIG.browser.cdpPort})\n`;
55
+ plan += `- User credentials as specified in user story\n`;
56
+ }
57
+
58
+ plan += `
59
+
60
+ ---
61
+
62
+ ## 3. Test Scenarios
63
+
64
+ `;
65
+
66
+ if (story.acceptanceCriteria) {
67
+ const sections = story.acceptanceCriteria.split(/(?=###\s+\d)/);
68
+ sections.forEach((section, idx) => {
69
+ const trimmed = section.trim();
70
+ if (!trimmed) return;
71
+
72
+ const titleMatch = trimmed.match(/###\s+\d+\.\s*(.+?)(?:\n|$)/);
73
+ const title = titleMatch ? titleMatch[1].trim() : `Scenario ${idx + 1}`;
74
+
75
+ const steps = parseSteps(trimmed);
76
+
77
+ plan += `### ${idx + 3}.1 Scenario: ${title}\n`;
78
+ plan += `**Steps**:\n`;
79
+ steps.forEach((s, i) => { plan += `${i + 1}. ${s}\n`; });
80
+ plan += `\n**Expected Results**:\n`;
81
+ plan += `- Feature behaves as described in acceptance criteria\n`;
82
+ plan += `- No errors or unexpected behavior\n`;
83
+ plan += `\n---\n\n`;
84
+ });
85
+ } else {
86
+ plan += `### 3.1 Scenario: Happy Path\n`;
87
+ plan += `**Steps**:\n`;
88
+ plan += `1. Login with the specified user credentials\n`;
89
+ plan += `2. Navigate to the feature\n`;
90
+ plan += `3. Execute the primary workflow\n`;
91
+ plan += `4. Verify success state\n\n`;
92
+ plan += `**Expected Results**:\n`;
93
+ plan += `- Workflow completes successfully\n\n`;
94
+ plan += `---\n\n`;
95
+ }
96
+
97
+ plan += `## 4. Success Criteria\n`;
98
+ plan += `- All scenarios pass without errors\n`;
99
+ plan += `- Screenshots are captured for key verification points\n`;
100
+ plan += `- Allure report is generated with test results\n\n`;
101
+
102
+ plan += `---\n\n`;
103
+ plan += `> **AI Instructions**: Review this test plan. Verify scenarios cover all acceptance criteria.\n`;
104
+ plan += `> Add edge cases, negative scenarios, and boundary tests where applicable.\n`;
105
+ plan += `> Update status from "Draft" to "Reviewed" once validated.\n`;
106
+
107
+ return plan;
108
+ }
109
+
110
+ function parseSteps(text) {
111
+ const steps = [];
112
+ const lines = text.split('\n');
113
+ let inSteps = false;
114
+
115
+ for (const line of lines) {
116
+ const trimmed = line.trim();
117
+ if (/^\d+\.\s+/.test(trimmed)) {
118
+ inSteps = true;
119
+ steps.push(trimmed.replace(/^\d+\.\s+/, ''));
120
+ } else if (inSteps && trimmed.startsWith('-')) {
121
+ steps.push(trimmed.replace(/^- /, ''));
122
+ } else if (inSteps && trimmed === '') {
123
+ continue;
124
+ } else if (inSteps) {
125
+ break;
126
+ }
127
+ }
128
+
129
+ return steps.length > 0 ? steps : ['Execute the workflow as described in the user story.'];
130
+ }
131
+
132
+ if (require.main === module) {
133
+ const storyName = process.argv[2];
134
+ if (!storyName) {
135
+ console.log('Usage: node scripts/planner.js <user-story-file.md>');
136
+ console.log(`Available: ${require('./utils').listUserStories().join(', ')}`);
137
+ process.exit(1);
138
+ }
139
+ generateTestPlan(storyName);
140
+ }
141
+
142
+ module.exports = { generateTestPlan };