@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,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 };
@@ -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 };