@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,101 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const router = express.Router();
|
|
3
|
+
const pm = require('../services/project-manager');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
router.get('/', (req, res) => {
|
|
8
|
+
const projectId = req.query.project;
|
|
9
|
+
const project = projectId ? pm.get(projectId) : pm.getAll()[0];
|
|
10
|
+
if (!project) return res.redirect('/projects');
|
|
11
|
+
|
|
12
|
+
const bridge = pm.getBridge(project.id);
|
|
13
|
+
const stories = bridge.listUserStories().map(s => {
|
|
14
|
+
const baseName = s.replace(/\.md$/i, '');
|
|
15
|
+
const plans = bridge.listTestPlans();
|
|
16
|
+
const specs = bridge.listTestSpecs();
|
|
17
|
+
return {
|
|
18
|
+
name: s,
|
|
19
|
+
content: bridge.getStoryContent(s),
|
|
20
|
+
hasPlan: plans.some(p => p.toLowerCase().includes(baseName.toLowerCase())),
|
|
21
|
+
planName: plans.find(p => p.toLowerCase().includes(baseName.toLowerCase())),
|
|
22
|
+
hasSpec: specs.some(sp => sp.toLowerCase().includes(baseName.toLowerCase())),
|
|
23
|
+
};
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
res.render('stories', { project, stories });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
router.get('/:storyName', (req, res) => {
|
|
30
|
+
const projectId = req.query.project;
|
|
31
|
+
const project = projectId ? pm.get(projectId) : pm.getAll()[0];
|
|
32
|
+
if (!project) return res.redirect('/projects');
|
|
33
|
+
|
|
34
|
+
const bridge = pm.getBridge(project.id);
|
|
35
|
+
const content = bridge.getStoryContent(req.params.storyName);
|
|
36
|
+
if (!content) return res.status(404).render('error', { message: 'Story not found' });
|
|
37
|
+
|
|
38
|
+
const baseName = req.params.storyName.replace(/\.md$/i, '');
|
|
39
|
+
const plans = bridge.listTestPlans();
|
|
40
|
+
const specs = bridge.listTestSpecs();
|
|
41
|
+
const planName = plans.find(p => p.toLowerCase().includes(baseName.toLowerCase()));
|
|
42
|
+
const specName = specs.find(sp => sp.toLowerCase().includes(baseName.toLowerCase()));
|
|
43
|
+
|
|
44
|
+
const plan = planName ? bridge.getPlanContent(planName) : null;
|
|
45
|
+
const spec = specName ? bridge.getSpecContent(specName) : null;
|
|
46
|
+
|
|
47
|
+
res.render('story', { project, story: { name: req.params.storyName, content, plan, planName, spec, specName } });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
router.post('/:storyName/plan', (req, res) => {
|
|
51
|
+
const projectId = req.query.project;
|
|
52
|
+
const project = projectId ? pm.get(projectId) : pm.getAll()[0];
|
|
53
|
+
if (!project) return res.status(404).json({ error: 'Project not found' });
|
|
54
|
+
|
|
55
|
+
const bridge = pm.getBridge(project.id);
|
|
56
|
+
const result = bridge.plan(req.params.storyName);
|
|
57
|
+
res.json({ success: result.success, output: result.output, error: result.error });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
router.post('/:storyName/generate', (req, res) => {
|
|
61
|
+
const projectId = req.query.project;
|
|
62
|
+
const project = projectId ? pm.get(projectId) : pm.getAll()[0];
|
|
63
|
+
if (!project) return res.status(404).json({ error: 'Project not found' });
|
|
64
|
+
|
|
65
|
+
const bridge = pm.getBridge(project.id);
|
|
66
|
+
const baseName = req.params.storyName.replace(/\.md$/i, '');
|
|
67
|
+
const plans = bridge.listTestPlans();
|
|
68
|
+
const planName = plans.find(p => p.toLowerCase().includes(baseName.toLowerCase()));
|
|
69
|
+
if (!planName) return res.json({ success: false, error: 'No test plan found. Run plan first.' });
|
|
70
|
+
|
|
71
|
+
const result = bridge.generate(planName);
|
|
72
|
+
res.json({ success: result.success, output: result.output, error: result.error });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
router.post('/:storyName/execute', (req, res) => {
|
|
76
|
+
const projectId = req.query.project;
|
|
77
|
+
const project = projectId ? pm.get(projectId) : pm.getAll()[0];
|
|
78
|
+
if (!project) return res.status(404).json({ error: 'Project not found' });
|
|
79
|
+
|
|
80
|
+
const bridge = pm.getBridge(project.id);
|
|
81
|
+
const baseName = req.params.storyName.replace(/\.md$/i, '');
|
|
82
|
+
const specs = bridge.listTestSpecs();
|
|
83
|
+
const specName = specs.find(sp => sp.toLowerCase().includes(baseName.toLowerCase()));
|
|
84
|
+
if (!specName) return res.json({ success: false, error: `No test spec found for "${req.params.storyName}". Generate one first.` });
|
|
85
|
+
|
|
86
|
+
const testName = specName.replace(/\.spec\.ts$/i, '');
|
|
87
|
+
const result = bridge.execute(testName);
|
|
88
|
+
res.json({ success: result.success, output: result.output, error: result.error });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
router.post('/:storyName/full-pipeline', (req, res) => {
|
|
92
|
+
const projectId = req.query.project;
|
|
93
|
+
const project = projectId ? pm.get(projectId) : pm.getAll()[0];
|
|
94
|
+
if (!project) return res.status(404).json({ error: 'Project not found' });
|
|
95
|
+
|
|
96
|
+
const bridge = pm.getBridge(project.id);
|
|
97
|
+
const result = bridge.runFull(req.params.storyName);
|
|
98
|
+
res.json({ success: result.success, output: result.output, error: result.error });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
module.exports = router;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const router = express.Router();
|
|
3
|
+
const pm = require('../services/project-manager');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
const DATA_DIR = path.join(__dirname, '..', 'data');
|
|
8
|
+
|
|
9
|
+
function ensureDir(dir) {
|
|
10
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
router.get('/', (req, res) => {
|
|
14
|
+
const projects = pm.getAll();
|
|
15
|
+
const projectId = req.query.project || (projects.length > 0 ? projects[0].id : null);
|
|
16
|
+
const project = projects.find(p => p.id === projectId);
|
|
17
|
+
|
|
18
|
+
const dataFiles = [];
|
|
19
|
+
if (fs.existsSync(DATA_DIR)) {
|
|
20
|
+
fs.readdirSync(DATA_DIR).forEach(f => {
|
|
21
|
+
const fp = path.join(DATA_DIR, f);
|
|
22
|
+
dataFiles.push({ name: f, size: fs.statSync(fp).size, modified: fs.statSync(fp).mtime });
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
res.render('test-data', { project, projects, dataFiles });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
router.post('/generate', (req, res) => {
|
|
30
|
+
const { type, count } = req.body;
|
|
31
|
+
const num = parseInt(count) || 5;
|
|
32
|
+
|
|
33
|
+
ensureDir(DATA_DIR);
|
|
34
|
+
|
|
35
|
+
if (type === 'users') {
|
|
36
|
+
const users = [];
|
|
37
|
+
for (let i = 0; i < num; i++) {
|
|
38
|
+
users.push({
|
|
39
|
+
id: `user_${i + 1}`,
|
|
40
|
+
name: `Test User ${i + 1}`,
|
|
41
|
+
email: `testuser${i + 1}@test.com`,
|
|
42
|
+
phone: `060000${String(i + 1).padStart(4, '0')}`,
|
|
43
|
+
role: i % 2 === 0 ? 'admin' : 'user',
|
|
44
|
+
createdAt: new Date().toISOString(),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
const filePath = path.join(DATA_DIR, `test-users-${Date.now()}.json`);
|
|
48
|
+
fs.writeFileSync(filePath, JSON.stringify(users, null, 2));
|
|
49
|
+
res.json({ success: true, file: path.basename(filePath), count: num });
|
|
50
|
+
} else if (type === 'transactions') {
|
|
51
|
+
const txns = [];
|
|
52
|
+
const amounts = [25, 50, 75, 100, 150, 200];
|
|
53
|
+
for (let i = 0; i < num; i++) {
|
|
54
|
+
txns.push({
|
|
55
|
+
id: `TXN${String(i + 1).padStart(6, '0')}`,
|
|
56
|
+
amount: amounts[Math.floor(Math.random() * amounts.length)],
|
|
57
|
+
currency: 'EUR',
|
|
58
|
+
status: ['completed', 'pending', 'failed'][Math.floor(Math.random() * 3)],
|
|
59
|
+
sender: `user_${Math.floor(Math.random() * 10) + 1}`,
|
|
60
|
+
recipient: `user_${Math.floor(Math.random() * 10) + 11}`,
|
|
61
|
+
timestamp: new Date(Date.now() - Math.random() * 86400000 * 30).toISOString(),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
const filePath = path.join(DATA_DIR, `test-transactions-${Date.now()}.json`);
|
|
65
|
+
fs.writeFileSync(filePath, JSON.stringify(txns, null, 2));
|
|
66
|
+
res.json({ success: true, file: path.basename(filePath), count: num });
|
|
67
|
+
} else {
|
|
68
|
+
res.json({ success: false, error: 'Unknown data type. Use: users, transactions' });
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
router.delete('/:file', (req, res) => {
|
|
73
|
+
const filePath = path.join(DATA_DIR, req.params.file);
|
|
74
|
+
if (fs.existsSync(filePath)) {
|
|
75
|
+
fs.unlinkSync(filePath);
|
|
76
|
+
res.json({ success: true });
|
|
77
|
+
} else {
|
|
78
|
+
res.json({ success: false, error: 'File not found' });
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
module.exports = router;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
const { execSync } = require('child_process');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
|
|
5
|
+
class CliBridge {
|
|
6
|
+
constructor(projectPath) {
|
|
7
|
+
this.projectPath = projectPath;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
_run(command) {
|
|
11
|
+
try {
|
|
12
|
+
const output = execSync(command, {
|
|
13
|
+
cwd: this.projectPath,
|
|
14
|
+
encoding: 'utf-8',
|
|
15
|
+
timeout: 300000,
|
|
16
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
17
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
18
|
+
});
|
|
19
|
+
return { success: true, output, error: null };
|
|
20
|
+
} catch (err) {
|
|
21
|
+
return {
|
|
22
|
+
success: false,
|
|
23
|
+
output: err.stdout || '',
|
|
24
|
+
error: err.message,
|
|
25
|
+
stderr: err.stderr || '',
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
status() {
|
|
31
|
+
return this._run('node ai-qa-workflow.js status');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
plan(storyName) {
|
|
35
|
+
return this._run(`node ai-qa-workflow.js plan "${storyName}"`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
generate(planName) {
|
|
39
|
+
return this._run(`node ai-qa-workflow.js generate "${planName}"`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
execute(testName) {
|
|
43
|
+
return this._run(`node ai-qa-workflow.js execute ${testName}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
heal(runId) {
|
|
47
|
+
return this._run(`node ai-qa-workflow.js heal ${runId || ''}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
report(runId) {
|
|
51
|
+
return this._run(`node ai-qa-workflow.js report ${runId || ''}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
runFull(storyName) {
|
|
55
|
+
return this._run(`node ai-qa-workflow.js run "${storyName}"`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
_readDir(subDir, extension) {
|
|
59
|
+
const dirPath = path.join(this.projectPath, subDir);
|
|
60
|
+
if (!fs.existsSync(dirPath)) return [];
|
|
61
|
+
return fs.readdirSync(dirPath)
|
|
62
|
+
.filter(f => f.endsWith(extension))
|
|
63
|
+
.sort();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
listUserStories() {
|
|
67
|
+
return this._readDir('user-story', '.md');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
listTestPlans() {
|
|
71
|
+
return this._readDir('specs', '.md');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
listTestSpecs() {
|
|
75
|
+
return this._readDir('tests', '.spec.ts');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
listAll() {
|
|
79
|
+
return {
|
|
80
|
+
stories: this.listUserStories(),
|
|
81
|
+
plans: this.listTestPlans(),
|
|
82
|
+
specs: this.listTestSpecs(),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
getStoryContent(storyName) {
|
|
87
|
+
const storyPath = path.join(this.projectPath, 'user-story', storyName);
|
|
88
|
+
if (!fs.existsSync(storyPath)) return null;
|
|
89
|
+
return fs.readFileSync(storyPath, 'utf-8');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
getPlanContent(planName) {
|
|
93
|
+
const planPath = path.join(this.projectPath, 'specs', planName);
|
|
94
|
+
if (!fs.existsSync(planPath)) return null;
|
|
95
|
+
return fs.readFileSync(planPath, 'utf-8');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
getSpecContent(specName) {
|
|
99
|
+
const specPath = path.join(this.projectPath, 'tests', specName);
|
|
100
|
+
if (!fs.existsSync(specPath)) return null;
|
|
101
|
+
return fs.readFileSync(specPath, 'utf-8');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
getRunDirs() {
|
|
105
|
+
const resultsDir = path.join(this.projectPath, 'test-results');
|
|
106
|
+
if (!fs.existsSync(resultsDir)) return [];
|
|
107
|
+
const dirs = fs.readdirSync(resultsDir)
|
|
108
|
+
.map(d => ({ name: d, path: path.join(resultsDir, d) }))
|
|
109
|
+
.filter(d => fs.statSync(d.path).isDirectory())
|
|
110
|
+
.sort((a, b) => fs.statSync(b.path).mtimeMs - fs.statSync(a.path).mtimeMs);
|
|
111
|
+
return dirs;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
getRunResult(runDirName) {
|
|
115
|
+
const resultPath = path.join(this.projectPath, 'test-results', runDirName, 'execution-result.json');
|
|
116
|
+
if (!fs.existsSync(resultPath)) return null;
|
|
117
|
+
return JSON.parse(fs.readFileSync(resultPath, 'utf-8'));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
getHealingReport(runDirName) {
|
|
121
|
+
const reportPath = path.join(this.projectPath, 'test-results', runDirName, 'healing-report.json');
|
|
122
|
+
if (!fs.existsSync(reportPath)) return null;
|
|
123
|
+
return JSON.parse(fs.readFileSync(reportPath, 'utf-8'));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
getFinalReport(runDirName) {
|
|
127
|
+
const reportPath = path.join(this.projectPath, 'test-results', runDirName, 'final-test-report.md');
|
|
128
|
+
if (!fs.existsSync(reportPath)) return null;
|
|
129
|
+
return fs.readFileSync(reportPath, 'utf-8');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
hasAllureReport() {
|
|
133
|
+
return fs.existsSync(path.join(this.projectPath, 'allure-report', 'index.html'));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
getAllureResultsDir() {
|
|
137
|
+
const dir = path.join(this.projectPath, 'allure-results');
|
|
138
|
+
if (!fs.existsSync(dir)) return null;
|
|
139
|
+
return dir;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
module.exports = CliBridge;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const CliBridge = require('./cli-bridge');
|
|
4
|
+
|
|
5
|
+
const DATA_FILE = path.join(__dirname, '..', 'data', 'projects.json');
|
|
6
|
+
|
|
7
|
+
class ProjectManager {
|
|
8
|
+
constructor() {
|
|
9
|
+
this.projects = [];
|
|
10
|
+
this._load();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
_load() {
|
|
14
|
+
if (fs.existsSync(DATA_FILE)) {
|
|
15
|
+
try {
|
|
16
|
+
this.projects = JSON.parse(fs.readFileSync(DATA_FILE, 'utf-8'));
|
|
17
|
+
} catch { this.projects = []; }
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
_save() {
|
|
22
|
+
fs.writeFileSync(DATA_FILE, JSON.stringify(this.projects, null, 2), 'utf-8');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
getAll() {
|
|
26
|
+
return this.projects;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get(id) {
|
|
30
|
+
return this.projects.find(p => p.id === id);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
add(name, path) {
|
|
34
|
+
const id = name.toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
|
35
|
+
const existing = this.projects.find(p => p.id === id);
|
|
36
|
+
if (existing) {
|
|
37
|
+
existing.name = name;
|
|
38
|
+
existing.path = path;
|
|
39
|
+
existing.updatedAt = new Date().toISOString();
|
|
40
|
+
this._save();
|
|
41
|
+
return existing;
|
|
42
|
+
}
|
|
43
|
+
const project = { id, name, path, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() };
|
|
44
|
+
this.projects.push(project);
|
|
45
|
+
this._save();
|
|
46
|
+
return project;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
remove(id) {
|
|
50
|
+
this.projects = this.projects.filter(p => p.id !== id);
|
|
51
|
+
this._save();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
getBridge(id) {
|
|
55
|
+
const project = this.get(id);
|
|
56
|
+
if (!project) return null;
|
|
57
|
+
return new CliBridge(project.path);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = new ProjectManager();
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
<% layout = 'layouts/main' %>
|
|
2
|
+
<% title = project ? 'Analytics - ' + project.name : 'Analytics Dashboard' %>
|
|
3
|
+
|
|
4
|
+
<div class="page-header">
|
|
5
|
+
<h1>Analytics Dashboard</h1>
|
|
6
|
+
<div class="header-actions">
|
|
7
|
+
<% if (typeof projects !== 'undefined' && projects.length > 1) { %>
|
|
8
|
+
<select id="project-select" class="form-input" style="width:auto;" onchange="window.location='/analytics?project='+this.value">
|
|
9
|
+
<% projects.forEach(p => { %>
|
|
10
|
+
<option value="<%= p.id %>" <%= project && project.id === p.id ? 'selected' : '' %>><%= p.name %></option>
|
|
11
|
+
<% }) %>
|
|
12
|
+
</select>
|
|
13
|
+
<% } %>
|
|
14
|
+
<% if (project) { %>
|
|
15
|
+
<a href="/projects/<%= project.id %>" class="btn">Back to Project</a>
|
|
16
|
+
<% } %>
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<% if (!project) { %>
|
|
21
|
+
<div class="empty-state">
|
|
22
|
+
<h3>No project selected</h3>
|
|
23
|
+
<p>Add a project first or select one from the dropdown</p>
|
|
24
|
+
</div>
|
|
25
|
+
<% } else if (stats.totalRuns === 0) { %>
|
|
26
|
+
<div class="empty-state">
|
|
27
|
+
<h3>No runs for <%= project.name %></h3>
|
|
28
|
+
<p>Execute the pipeline to see analytics</p>
|
|
29
|
+
<a href="/projects/<%= project.id %>" class="btn btn-primary">Go to Project</a>
|
|
30
|
+
</div>
|
|
31
|
+
<% } else { %>
|
|
32
|
+
|
|
33
|
+
<div class="row">
|
|
34
|
+
<div class="col">
|
|
35
|
+
<div class="card stat-card">
|
|
36
|
+
<div class="stat-number"><%= stats.totalRuns %></div>
|
|
37
|
+
<div class="stat-label">Total Runs</div>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
<div class="col">
|
|
41
|
+
<div class="card stat-card">
|
|
42
|
+
<div class="stat-number <%= stats.passRate > 80 ? 'text-success' : stats.passRate > 50 ? 'text-warning' : 'text-danger' %>"><%= stats.passRate %>%</div>
|
|
43
|
+
<div class="stat-label">Pass Rate</div>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
<div class="col">
|
|
47
|
+
<div class="card stat-card">
|
|
48
|
+
<div class="stat-number"><%= stats.totalHealed %></div>
|
|
49
|
+
<div class="stat-label">Tests Healed</div>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
<div class="col">
|
|
53
|
+
<div class="card stat-card">
|
|
54
|
+
<div class="stat-number"><%= stats.healRate %>%</div>
|
|
55
|
+
<div class="stat-label">Heal Rate</div>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
<div class="col">
|
|
59
|
+
<div class="card stat-card">
|
|
60
|
+
<div class="stat-number"><%= stats.totalDefects %></div>
|
|
61
|
+
<div class="stat-label">Defects Found</div>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
<div class="col">
|
|
65
|
+
<div class="card stat-card">
|
|
66
|
+
<div class="stat-number"><%= stats.avgDuration > 0 ? (stats.avgDuration / 1000).toFixed(1) + 's' : 'N/A' %></div>
|
|
67
|
+
<div class="stat-label">Avg Duration</div>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<div class="row">
|
|
73
|
+
<div class="col-60">
|
|
74
|
+
<div class="card">
|
|
75
|
+
<h3>Pass Rate Trend</h3>
|
|
76
|
+
<canvas id="passRateChart" height="200"></canvas>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
<div class="col-40">
|
|
80
|
+
<div class="card">
|
|
81
|
+
<h3>Failure Breakdown</h3>
|
|
82
|
+
<canvas id="failureChart" height="200"></canvas>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<div class="row">
|
|
88
|
+
<div class="col-50">
|
|
89
|
+
<div class="card">
|
|
90
|
+
<h3>Duration Trend</h3>
|
|
91
|
+
<canvas id="durationChart" height="150"></canvas>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
<div class="col-50">
|
|
95
|
+
<div class="card">
|
|
96
|
+
<h3>Healing vs Defects</h3>
|
|
97
|
+
<canvas id="healingChart" height="150"></canvas>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<div class="card">
|
|
103
|
+
<h3>Run History</h3>
|
|
104
|
+
<table class="info-table">
|
|
105
|
+
<tr>
|
|
106
|
+
<th>Run</th><th>Test</th><th>Status</th><th>Duration</th><th>Failures</th><th>Healed</th><th>Defects</th><th>Export</th>
|
|
107
|
+
</tr>
|
|
108
|
+
<% runs.forEach(r => { %>
|
|
109
|
+
<tr>
|
|
110
|
+
<td><code><%= r.id.substring(0, 20) %>...</code></td>
|
|
111
|
+
<td><%= r.testName %></td>
|
|
112
|
+
<td><span class="badge <%= r.success ? 'badge-pass' : 'badge-fail' %>"><%= r.success ? 'PASS' : 'FAIL' %></span></td>
|
|
113
|
+
<td><%= r.duration ? (r.duration / 1000).toFixed(1) + 's' : 'N/A' %></td>
|
|
114
|
+
<td><%= r.failures %></td>
|
|
115
|
+
<td><%= r.healed %></td>
|
|
116
|
+
<td><%= r.defects %></td>
|
|
117
|
+
<td>
|
|
118
|
+
<a href="/export/report/<%= r.id %>?project=<%= project.id %>" class="btn btn-sm">HTML</a>
|
|
119
|
+
<a href="/export/pdf/<%= r.id %>?project=<%= project.id %>" class="btn btn-sm">TXT</a>
|
|
120
|
+
</td>
|
|
121
|
+
</tr>
|
|
122
|
+
<% }) %>
|
|
123
|
+
</table>
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
<script>
|
|
127
|
+
const runLabels = <%= JSON.stringify(runs.map(r => r.id.substring(0, 10))) %>;
|
|
128
|
+
const passRates = <%= JSON.stringify(runs.map(r => r.total > 0 ? ((r.total - r.failures) / r.total * 100).toFixed(1) : 0)) %>;
|
|
129
|
+
const failures = <%= JSON.stringify(runs.map(r => r.failures)) %>;
|
|
130
|
+
const durations = <%= JSON.stringify(runs.map(r => r.duration ? (r.duration / 1000).toFixed(1) : 0)) %>;
|
|
131
|
+
const healed = <%= JSON.stringify(runs.map(r => r.healed)) %>;
|
|
132
|
+
const defects = <%= JSON.stringify(runs.map(r => r.defects)) %>;
|
|
133
|
+
|
|
134
|
+
new Chart(document.getElementById('passRateChart'), {
|
|
135
|
+
type: 'line',
|
|
136
|
+
data: {
|
|
137
|
+
labels: runLabels,
|
|
138
|
+
datasets: [{
|
|
139
|
+
label: 'Pass Rate %',
|
|
140
|
+
data: passRates,
|
|
141
|
+
borderColor: '#22c55e',
|
|
142
|
+
backgroundColor: 'rgba(34,197,94,0.1)',
|
|
143
|
+
fill: true,
|
|
144
|
+
tension: 0.4,
|
|
145
|
+
}]
|
|
146
|
+
},
|
|
147
|
+
options: { responsive: true, plugins: { legend: { labels: { color: '#e4e6f0' } } }, scales: { x: { ticks: { color: '#8b8fa3' } }, y: { ticks: { color: '#8b8fa3' }, min: 0, max: 100 } } }
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
new Chart(document.getElementById('failureChart'), {
|
|
151
|
+
type: 'pie',
|
|
152
|
+
data: {
|
|
153
|
+
labels: ['Passed', 'Healed', 'Defects'],
|
|
154
|
+
datasets: [{
|
|
155
|
+
data: [<%= stats.passedRuns %>, <%= stats.totalHealed %>, <%= stats.totalDefects %>],
|
|
156
|
+
backgroundColor: ['#22c55e', '#f59e0b', '#ef4444'],
|
|
157
|
+
}]
|
|
158
|
+
},
|
|
159
|
+
options: { responsive: true, plugins: { legend: { labels: { color: '#e4e6f0' } } } }
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
new Chart(document.getElementById('durationChart'), {
|
|
163
|
+
type: 'bar',
|
|
164
|
+
data: {
|
|
165
|
+
labels: runLabels,
|
|
166
|
+
datasets: [{
|
|
167
|
+
label: 'Duration (s)',
|
|
168
|
+
data: durations,
|
|
169
|
+
backgroundColor: '#6366f1',
|
|
170
|
+
}]
|
|
171
|
+
},
|
|
172
|
+
options: { responsive: true, plugins: { legend: { labels: { color: '#e4e6f0' } } }, scales: { x: { ticks: { color: '#8b8fa3' } }, y: { ticks: { color: '#8b8fa3' } } } }
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
new Chart(document.getElementById('healingChart'), {
|
|
176
|
+
type: 'bar',
|
|
177
|
+
data: {
|
|
178
|
+
labels: runLabels,
|
|
179
|
+
datasets: [
|
|
180
|
+
{ label: 'Healed', data: healed, backgroundColor: '#f59e0b' },
|
|
181
|
+
{ label: 'Defects', data: defects, backgroundColor: '#ef4444' }
|
|
182
|
+
]
|
|
183
|
+
},
|
|
184
|
+
options: { responsive: true, plugins: { legend: { labels: { color: '#e4e6f0' } } }, scales: { x: { ticks: { color: '#8b8fa3' } }, y: { ticks: { color: '#8b8fa3' } } } }
|
|
185
|
+
});
|
|
186
|
+
</script>
|
|
187
|
+
|
|
188
|
+
<% } %>
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<% layout = 'layouts/main' %>
|
|
2
|
+
<% title = 'Dashboard' %>
|
|
3
|
+
|
|
4
|
+
<div class="hero">
|
|
5
|
+
<h1>AI QA Pipeline</h1>
|
|
6
|
+
<p>User Story → Test Plan → Test Generation → Execution → Self-Healing → Report</p>
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
<div class="stats-row">
|
|
10
|
+
<div class="stat-card">
|
|
11
|
+
<div class="stat-number"><%= projects.length %></div>
|
|
12
|
+
<div class="stat-label">Projects</div>
|
|
13
|
+
</div>
|
|
14
|
+
<div class="stat-card">
|
|
15
|
+
<div class="stat-number">5</div>
|
|
16
|
+
<div class="stat-label">Pipeline Steps</div>
|
|
17
|
+
</div>
|
|
18
|
+
<div class="stat-card">
|
|
19
|
+
<div class="stat-number">AI</div>
|
|
20
|
+
<div class="stat-label">Agentic QA</div>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<div class="pipeline-flow">
|
|
25
|
+
<h2>Pipeline</h2>
|
|
26
|
+
<div class="flow-steps">
|
|
27
|
+
<div class="flow-step">
|
|
28
|
+
<div class="step-icon">📝</div>
|
|
29
|
+
<div class="step-name">User Story</div>
|
|
30
|
+
<div class="step-desc">Input business requirement</div>
|
|
31
|
+
</div>
|
|
32
|
+
<div class="flow-arrow">→</div>
|
|
33
|
+
<div class="flow-step">
|
|
34
|
+
<div class="step-icon">📋</div>
|
|
35
|
+
<div class="step-name">Plan</div>
|
|
36
|
+
<div class="step-desc">AI generates test plan</div>
|
|
37
|
+
</div>
|
|
38
|
+
<div class="flow-arrow">→</div>
|
|
39
|
+
<div class="flow-step">
|
|
40
|
+
<div class="step-icon">🧪</div>
|
|
41
|
+
<div class="step-name">Generate</div>
|
|
42
|
+
<div class="step-desc">AI writes test code</div>
|
|
43
|
+
</div>
|
|
44
|
+
<div class="flow-arrow">→</div>
|
|
45
|
+
<div class="flow-step">
|
|
46
|
+
<div class="step-icon">⚡</div>
|
|
47
|
+
<div class="step-name">Execute</div>
|
|
48
|
+
<div class="step-desc">Playwright runs tests</div>
|
|
49
|
+
</div>
|
|
50
|
+
<div class="flow-arrow">→</div>
|
|
51
|
+
<div class="flow-step">
|
|
52
|
+
<div class="step-icon">🛠️</div>
|
|
53
|
+
<div class="step-name">Heal</div>
|
|
54
|
+
<div class="step-desc">Auto-fix failures</div>
|
|
55
|
+
</div>
|
|
56
|
+
<div class="flow-arrow">→</div>
|
|
57
|
+
<div class="flow-step">
|
|
58
|
+
<div class="step-icon">📊</div>
|
|
59
|
+
<div class="step-name">Report</div>
|
|
60
|
+
<div class="step-desc">Comprehensive results</div>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<% if (projects.length > 0) { %>
|
|
66
|
+
<div class="section">
|
|
67
|
+
<h2>Projects</h2>
|
|
68
|
+
<div class="project-grid">
|
|
69
|
+
<% projects.forEach(p => { %>
|
|
70
|
+
<a href="/projects/<%= p.id %>" class="project-card">
|
|
71
|
+
<div class="project-name"><%= p.name %></div>
|
|
72
|
+
<div class="project-path"><%= p.path %></div>
|
|
73
|
+
</a>
|
|
74
|
+
<% }) %>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
<% } else { %>
|
|
78
|
+
<div class="empty-state">
|
|
79
|
+
<h3>No projects configured</h3>
|
|
80
|
+
<p>Add your test project to get started</p>
|
|
81
|
+
<a href="/projects/add" class="btn btn-primary">Add Project</a>
|
|
82
|
+
</div>
|
|
83
|
+
<% } %>
|