@ai-qa/workflow 2.0.17 → 2.0.18
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/.opencode/qa-workflow-skill.md +232 -0
- package/PROJECT_GUIDE.md +114 -74
- package/install.js +43 -6
- package/opencode.json +6 -0
- package/package.json +9 -3
- package/playwright.config.ts +10 -0
- package/qa-dashboard/app.js +6 -2
- package/qa-dashboard/package.json +4 -1
- package/qa-dashboard/routes/index.js +26 -4
- package/qa-dashboard/routes/projects.js +25 -1
- package/qa-dashboard/routes/runs.js +38 -0
- package/qa-dashboard/routes/stories.js +18 -1
- package/qa-dashboard/routes/terminal.js +51 -0
- package/qa-dashboard/routes/test-data.js +46 -2
- package/qa-dashboard/services/cli-bridge.js +4 -0
- package/qa-dashboard/views/index.ejs +250 -238
- package/qa-dashboard/views/layouts/main.ejs +3 -2
- package/qa-dashboard/views/project.ejs +78 -51
- package/qa-dashboard/views/run.ejs +63 -3
- package/qa-dashboard/views/stories.ejs +36 -12
- package/qa-dashboard/views/story.ejs +125 -86
- package/qa-dashboard/views/terminal.ejs +101 -0
- package/qa-dashboard/views/test-data.ejs +59 -22
- package/router.md +27 -17
- package/test-results/run-2026-05-18T14-51-36/final-test-report.md +10 -2
|
@@ -5,7 +5,6 @@ const pm = require('../services/project-manager');
|
|
|
5
5
|
router.get('/', (req, res) => {
|
|
6
6
|
const projects = pm.getAll();
|
|
7
7
|
|
|
8
|
-
// Single-project mode: always use the first (and only) registered project
|
|
9
8
|
const project = projects.length > 0 ? projects[0] : null;
|
|
10
9
|
|
|
11
10
|
let stats = {
|
|
@@ -15,7 +14,10 @@ router.get('/', (req, res) => {
|
|
|
15
14
|
runs: 0,
|
|
16
15
|
passedRuns: 0,
|
|
17
16
|
passRate: 0,
|
|
18
|
-
lastRunStatus: null,
|
|
17
|
+
lastRunStatus: null,
|
|
18
|
+
storiesList: [],
|
|
19
|
+
latestRun: null,
|
|
20
|
+
hasAllure: false,
|
|
19
21
|
};
|
|
20
22
|
|
|
21
23
|
if (project) {
|
|
@@ -27,6 +29,18 @@ router.get('/', (req, res) => {
|
|
|
27
29
|
stats.plans = list.plans.length;
|
|
28
30
|
stats.specs = list.specs.length;
|
|
29
31
|
stats.runs = runDirs.length;
|
|
32
|
+
stats.hasAllure = bridge.hasAllureReport();
|
|
33
|
+
|
|
34
|
+
stats.storiesList = list.stories.map(s => {
|
|
35
|
+
const baseName = s.replace(/\.md$/i, '');
|
|
36
|
+
return {
|
|
37
|
+
name: s,
|
|
38
|
+
hasPlan: list.plans.some(p => p.toLowerCase().includes(baseName.toLowerCase())),
|
|
39
|
+
planName: list.plans.find(p => p.toLowerCase().includes(baseName.toLowerCase())),
|
|
40
|
+
hasSpec: list.specs.some(sp => sp.toLowerCase().includes(baseName.toLowerCase())),
|
|
41
|
+
specName: list.specs.find(sp => sp.toLowerCase().includes(baseName.toLowerCase())),
|
|
42
|
+
};
|
|
43
|
+
});
|
|
30
44
|
|
|
31
45
|
if (runDirs.length > 0) {
|
|
32
46
|
const results = runDirs.map(d => bridge.getRunResult(d.name)).filter(Boolean);
|
|
@@ -35,9 +49,17 @@ router.get('/', (req, res) => {
|
|
|
35
49
|
? Math.round((stats.passedRuns / results.length) * 100)
|
|
36
50
|
: 0;
|
|
37
51
|
|
|
38
|
-
// Most recent run result
|
|
39
52
|
const latest = bridge.getRunResult(runDirs[0].name);
|
|
40
|
-
if (latest)
|
|
53
|
+
if (latest) {
|
|
54
|
+
stats.lastRunStatus = latest.success ? 'passed' : 'failed';
|
|
55
|
+
stats.latestRun = {
|
|
56
|
+
id: runDirs[0].name,
|
|
57
|
+
duration: latest.duration,
|
|
58
|
+
passedCount: latest.passedTests ? latest.passedTests.length : 0,
|
|
59
|
+
failedCount: latest.failedTests ? latest.failedTests.length : 0,
|
|
60
|
+
testName: latest.test,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
41
63
|
}
|
|
42
64
|
}
|
|
43
65
|
|
|
@@ -29,7 +29,31 @@ router.get('/:id', (req, res) => {
|
|
|
29
29
|
const statusResult = bridge.status();
|
|
30
30
|
const list = bridge.listAll();
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
const storiesWithStatus = list.stories.map(s => {
|
|
33
|
+
const baseName = s.replace(/\.md$/i, '');
|
|
34
|
+
const planName = list.plans.find(p => p.toLowerCase().includes(baseName.toLowerCase()));
|
|
35
|
+
const specName = list.specs.find(sp => sp.toLowerCase().includes(baseName.toLowerCase()));
|
|
36
|
+
const runDirs = bridge.getRunDirs();
|
|
37
|
+
const hasRun = runDirs.some(d => {
|
|
38
|
+
const result = bridge.getRunResult(d.name);
|
|
39
|
+
return result && result.test && result.test.toLowerCase().includes(baseName.toLowerCase());
|
|
40
|
+
});
|
|
41
|
+
return {
|
|
42
|
+
name: s,
|
|
43
|
+
baseName,
|
|
44
|
+
hasPlan: !!planName,
|
|
45
|
+
planName,
|
|
46
|
+
hasSpec: !!specName,
|
|
47
|
+
specName,
|
|
48
|
+
hasRun,
|
|
49
|
+
};
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const hasAllure = bridge.hasAllureReport();
|
|
53
|
+
const runDirs = bridge.getRunDirs();
|
|
54
|
+
const latestRun = runDirs.length > 0 ? runDirs[0].name : null;
|
|
55
|
+
|
|
56
|
+
res.render('project', { project, status: statusResult, list, storiesWithStatus, hasAllure, latestRun });
|
|
33
57
|
});
|
|
34
58
|
|
|
35
59
|
router.post('/:id/remove', (req, res) => {
|
|
@@ -115,4 +115,42 @@ router.get('/:runId/compare/:otherRunId', (req, res) => {
|
|
|
115
115
|
] });
|
|
116
116
|
});
|
|
117
117
|
|
|
118
|
+
// Rerun a specific test from a previous run
|
|
119
|
+
router.post('/:runId/rerun', (req, res) => {
|
|
120
|
+
const projectId = req.query.project;
|
|
121
|
+
const project = projectId ? pm.get(projectId) : pm.getAll()[0];
|
|
122
|
+
if (!project) return res.status(404).json({ error: 'Project not found' });
|
|
123
|
+
|
|
124
|
+
const bridge = pm.getBridge(project.id);
|
|
125
|
+
const result = bridge.getRunResult(req.params.runId);
|
|
126
|
+
if (!result) return res.status(404).json({ error: 'Run not found' });
|
|
127
|
+
|
|
128
|
+
const testName = result.test || '';
|
|
129
|
+
if (!testName || testName === 'all') {
|
|
130
|
+
// Rerun all tests
|
|
131
|
+
const runResult = bridge.execute('');
|
|
132
|
+
return res.json({ success: runResult.success, output: runResult.output, error: runResult.error });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const runResult = bridge.execute(testName);
|
|
136
|
+
res.json({ success: runResult.success, output: runResult.output, error: runResult.error, runId: extractRunId(runResult.output) });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Regenerate Allure report
|
|
140
|
+
router.post('/allure-regenerate', (req, res) => {
|
|
141
|
+
const projectId = req.query.project;
|
|
142
|
+
const project = projectId ? pm.get(projectId) : pm.getAll()[0];
|
|
143
|
+
if (!project) return res.status(404).json({ error: 'Project not found' });
|
|
144
|
+
|
|
145
|
+
const bridge = pm.getBridge(project.id);
|
|
146
|
+
const result = bridge.runCommand('npx allure generate allure-results --clean -o allure-report');
|
|
147
|
+
res.json({ success: result.success, output: result.output, error: result.error });
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
function extractRunId(output) {
|
|
151
|
+
if (!output) return null;
|
|
152
|
+
const match = output.match(/run-[\d-T-]+/);
|
|
153
|
+
return match ? match[0] : null;
|
|
154
|
+
}
|
|
155
|
+
|
|
118
156
|
module.exports = router;
|
|
@@ -44,7 +44,24 @@ router.get('/:storyName', (req, res) => {
|
|
|
44
44
|
const plan = planName ? bridge.getPlanContent(planName) : null;
|
|
45
45
|
const spec = specName ? bridge.getSpecContent(specName) : null;
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
const runDirs = bridge.getRunDirs();
|
|
48
|
+
const results = runDirs.map(d => {
|
|
49
|
+
const r = bridge.getRunResult(d.name);
|
|
50
|
+
if (!r) return null;
|
|
51
|
+
const testMatch = specName ? r.test && r.test.toLowerCase().includes(baseName.toLowerCase()) : true;
|
|
52
|
+
return testMatch ? {
|
|
53
|
+
id: d.name,
|
|
54
|
+
success: r.success,
|
|
55
|
+
duration: r.duration,
|
|
56
|
+
passedCount: r.passedTests ? r.passedTests.length : 0,
|
|
57
|
+
failedCount: r.failedTests ? r.failedTests.length : 0,
|
|
58
|
+
} : null;
|
|
59
|
+
}).filter(Boolean);
|
|
60
|
+
|
|
61
|
+
const hasRun = results.length > 0;
|
|
62
|
+
const hasAllure = bridge.hasAllureReport();
|
|
63
|
+
|
|
64
|
+
res.render('story', { project, story: { name: req.params.storyName, content, plan, planName, spec, specName, results, hasRun, hasAllure } });
|
|
48
65
|
});
|
|
49
66
|
|
|
50
67
|
// Helper to stream CLI command output asynchronously to client
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const router = express.Router();
|
|
3
|
+
const pm = require('../services/project-manager');
|
|
4
|
+
const { spawn } = require('child_process');
|
|
5
|
+
|
|
6
|
+
router.get('/', (req, res) => {
|
|
7
|
+
const projects = pm.getAll();
|
|
8
|
+
const projectId = req.query.project || (projects.length > 0 ? projects[0].id : null);
|
|
9
|
+
const project = projects.find(p => p.id === projectId);
|
|
10
|
+
res.render('terminal', { project, projects });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
// Execute a command asynchronously with streaming output
|
|
14
|
+
router.post('/run', (req, res) => {
|
|
15
|
+
const projects = pm.getAll();
|
|
16
|
+
const projectId = req.query.project || (projects.length > 0 ? projects[0].id : null);
|
|
17
|
+
const project = projects.find(p => p.id === projectId);
|
|
18
|
+
if (!project) return res.status(404).send('Project not found');
|
|
19
|
+
|
|
20
|
+
const { command } = req.body;
|
|
21
|
+
if (!command) return res.status(400).send('No command provided');
|
|
22
|
+
|
|
23
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
24
|
+
res.setHeader('Transfer-Encoding', 'chunked');
|
|
25
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
26
|
+
|
|
27
|
+
const child = spawn(command, [], {
|
|
28
|
+
cwd: project.path,
|
|
29
|
+
shell: true,
|
|
30
|
+
env: { ...process.env, FORCE_COLOR: '0' },
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
child.stdout.on('data', (data) => res.write(data.toString()));
|
|
34
|
+
child.stderr.on('data', (data) => res.write(data.toString()));
|
|
35
|
+
|
|
36
|
+
child.on('close', (code) => {
|
|
37
|
+
res.write(`\n\n--- [FINISHED: ${code === 0 ? 'SUCCESS' : 'FAILED'}] (exit code: ${code}) ---\n`);
|
|
38
|
+
res.end();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
child.on('error', (err) => {
|
|
42
|
+
res.write(`\nError: ${err.message}\n`);
|
|
43
|
+
res.end();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
req.on('close', () => {
|
|
47
|
+
if (child && !child.killed) child.kill('SIGINT');
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
module.exports = router;
|
|
@@ -23,7 +23,51 @@ router.get('/', (req, res) => {
|
|
|
23
23
|
});
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
let testSpecs = [];
|
|
27
|
+
let testPlans = [];
|
|
28
|
+
if (project) {
|
|
29
|
+
const bridge = pm.getBridge(project.id);
|
|
30
|
+
testSpecs = bridge.listTestSpecs().map(specName => ({
|
|
31
|
+
name: specName,
|
|
32
|
+
content: bridge.getSpecContent(specName),
|
|
33
|
+
}));
|
|
34
|
+
testPlans = bridge.listTestPlans().map(planName => ({
|
|
35
|
+
name: planName,
|
|
36
|
+
content: bridge.getPlanContent(planName),
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
res.render('test-data', { project, projects, dataFiles, testSpecs, testPlans });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
router.get('/spec/:specName', (req, res) => {
|
|
44
|
+
const projects = pm.getAll();
|
|
45
|
+
const projectId = req.query.project || (projects.length > 0 ? projects[0].id : null);
|
|
46
|
+
const project = projects.find(p => p.id === projectId);
|
|
47
|
+
if (!project) return res.status(404).send('Project not found');
|
|
48
|
+
|
|
49
|
+
const bridge = pm.getBridge(project.id);
|
|
50
|
+
const content = bridge.getSpecContent(req.params.specName);
|
|
51
|
+
if (!content) return res.status(404).send('Spec not found');
|
|
52
|
+
|
|
53
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
54
|
+
res.setHeader('Content-Disposition', `attachment; filename="${req.params.specName}"`);
|
|
55
|
+
res.send(content);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
router.get('/plan/:planName', (req, res) => {
|
|
59
|
+
const projects = pm.getAll();
|
|
60
|
+
const projectId = req.query.project || (projects.length > 0 ? projects[0].id : null);
|
|
61
|
+
const project = projects.find(p => p.id === projectId);
|
|
62
|
+
if (!project) return res.status(404).send('Project not found');
|
|
63
|
+
|
|
64
|
+
const bridge = pm.getBridge(project.id);
|
|
65
|
+
const content = bridge.getPlanContent(req.params.planName);
|
|
66
|
+
if (!content) return res.status(404).send('Plan not found');
|
|
67
|
+
|
|
68
|
+
res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
|
|
69
|
+
res.setHeader('Content-Disposition', `attachment; filename="${req.params.planName}"`);
|
|
70
|
+
res.send(content);
|
|
27
71
|
});
|
|
28
72
|
|
|
29
73
|
router.post('/generate', (req, res) => {
|
|
@@ -79,4 +123,4 @@ router.delete('/:file', (req, res) => {
|
|
|
79
123
|
}
|
|
80
124
|
});
|
|
81
125
|
|
|
82
|
-
module.exports = router;
|
|
126
|
+
module.exports = router;
|
|
@@ -173,6 +173,10 @@ class CliBridge {
|
|
|
173
173
|
return fs.readFileSync(reportPath, 'utf-8');
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
runCommand(command) {
|
|
177
|
+
return this._run(command);
|
|
178
|
+
}
|
|
179
|
+
|
|
176
180
|
hasAllureReport() {
|
|
177
181
|
return fs.existsSync(path.join(this.projectPath, 'allure-report', 'index.html'));
|
|
178
182
|
}
|