@ai-qa/workflow 2.0.2 → 2.0.3

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.
@@ -1,6 +1,7 @@
1
+ // Auto-refresh for status pages (disabled by default, activate with data-autorefresh attribute)
1
2
  document.addEventListener('DOMContentLoaded', () => {
2
- document.querySelectorAll('.auto-refresh').forEach(el => {
3
- const interval = el.dataset.interval || 10000;
3
+ document.querySelectorAll('[data-autorefresh]').forEach(el => {
4
+ const interval = el.dataset.autorefresh || 10000;
4
5
  setInterval(() => { location.reload(); }, interval);
5
6
  });
6
7
  });
@@ -35,25 +35,44 @@ router.get('/report/:runId', (req, res) => {
35
35
  const html = `<!DOCTYPE html>
36
36
  <html><head><meta charset="UTF-8"><title>QA Report - ${runId}</title>
37
37
  <style>
38
- body { font-family: Arial; max-width: 800px; margin: 2rem auto; padding: 1rem; color: #333; }
38
+ body { font-family: Arial, sans-serif; max-width: 800px; margin: 2rem auto; padding: 1rem; color: #333; line-height: 1.5; }
39
39
  h1 { color: #6366f1; border-bottom: 2px solid #6366f1; padding-bottom: 0.5rem; }
40
- .badge { display: inline-block; padding: 4px 12px; border-radius: 12px; font-size: 14px; font-weight: bold; }
41
- .pass { background: #dcfce7; color: #166534; }
42
- .fail { background: #fecaca; color: #991b1b; }
43
- table { width: 100%; border-collapse: collapse; margin: 1rem 0; }
44
- td, th { border: 1px solid #ddd; padding: 8px; text-align: left; }
45
- th { background: #f4f4f5; }
46
- .section { margin: 2rem 0; }
47
- .stats { display: flex; gap: 1rem; flex-wrap: wrap; }
48
- .stat-card { border: 1px solid #ddd; border-radius: 8px; padding: 1rem; min-width: 120px; text-align: center; }
49
- .stat-num { font-size: 24px; font-weight: bold; color: #6366f1; }
50
- .stat-label { font-size: 12px; color: #666; }
51
- .footer { margin-top: 2rem; font-size: 12px; color: #999; border-top: 1px solid #eee; padding-top: 1rem; }
40
+ .badge { display: inline-block; padding: 6px 14px; border-radius: 20px; font-size: 14px; font-weight: bold; text-transform: uppercase; }
41
+ .pass { background: #dcfce7; color: #166534; border: 1px solid #bbf7d0; }
42
+ .fail { background: #fecaca; color: #991b1b; border: 1px solid #fca5a5; }
43
+ table { width: 100%; border-collapse: collapse; margin: 1.5rem 0; }
44
+ td, th { border: 1px solid #ddd; padding: 10px; text-align: left; }
45
+ th { background: #f4f4f5; font-weight: 600; }
46
+ .section { margin: 2.5rem 0; }
47
+ .stats { display: flex; gap: 1.5rem; flex-wrap: wrap; margin-top: 1rem; }
48
+ .stat-card { border: 1px solid #e4e4e7; border-radius: 8px; padding: 1.25rem; min-width: 140px; text-align: center; background: #fafafa; }
49
+ .stat-num { font-size: 28px; font-weight: bold; color: #6366f1; }
50
+ .stat-label { font-size: 13px; color: #71717a; font-weight: 500; text-transform: uppercase; letter-spacing: 0.5px; }
51
+ .footer { margin-top: 3rem; font-size: 12px; color: #999; border-top: 1px solid #eee; padding-top: 1rem; text-align: center; }
52
+ pre { white-space: pre-wrap; word-wrap: break-word; background: #f4f4f5; padding: 1.25rem; border-radius: 8px; font-size: 13px; font-family: monospace; border: 1px solid #e4e4e7; }
53
+
54
+ /* Print actions style */
55
+ .actions-bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; background: #f4f4f5; padding: 1rem; border-radius: 8px; border: 1px solid #e4e4e7; }
56
+ .btn-print { background: #6366f1; color: #fff; border: none; padding: 10px 20px; border-radius: 6px; font-weight: bold; cursor: pointer; display: inline-flex; align-items: center; gap: 8px; font-size: 14px; transition: background 0.2s; }
57
+ .btn-print:hover { background: #4f46e5; }
58
+
59
+ @media print {
60
+ .actions-bar { display: none; }
61
+ body { max-width: 100%; margin: 0; padding: 0; }
62
+ .stat-card { background: #fff !important; border: 1px solid #ddd; }
63
+ }
52
64
  </style></head><body>
65
+ <div class="actions-bar">
66
+ <span style="font-weight: 600; color: #4b5563;">Rapport de Test Automatisé</span>
67
+ <button onclick="window.print()" class="btn-print">
68
+ 🖨️ Enregistrer en PDF / Imprimer
69
+ </button>
70
+ </div>
71
+
53
72
  <h1>AI QA Execution Report</h1>
54
- <p>Run: ${runId} | ${new Date().toLocaleDateString()}</p>
73
+ <p style="color: #6b7280; font-size: 14px;">Run ID: <strong>${runId}</strong> | Date de génération : ${new Date().toLocaleString()}</p>
55
74
  <span class="badge ${result.success ? 'pass' : 'fail'}">${result.success ? 'PASSED' : 'FAILED'}</span>
56
-
75
+
57
76
  <div class="section">
58
77
  <h2>Summary</h2>
59
78
  <div class="stats">
@@ -74,7 +93,7 @@ router.get('/report/:runId', (req, res) => {
74
93
 
75
94
  <div class="section">
76
95
  <h2>Full Report</h2>
77
- <pre style="background:#f4f4f5;padding:1rem;border-radius:8px;font-size:12px;max-height:400px;overflow:auto;">${(report || result.output || 'No detailed report').substring(0, 5000)}</pre>
96
+ <pre>${report || result.output || 'No detailed report'}</pre>
78
97
  </div>
79
98
 
80
99
  <div class="footer">
@@ -4,7 +4,44 @@ const pm = require('../services/project-manager');
4
4
 
5
5
  router.get('/', (req, res) => {
6
6
  const projects = pm.getAll();
7
- res.render('index', { projects });
7
+
8
+ // Single-project mode: always use the first (and only) registered project
9
+ const project = projects.length > 0 ? projects[0] : null;
10
+
11
+ let stats = {
12
+ stories: 0,
13
+ plans: 0,
14
+ specs: 0,
15
+ runs: 0,
16
+ passedRuns: 0,
17
+ passRate: 0,
18
+ lastRunStatus: null, // 'passed' | 'failed' | null
19
+ };
20
+
21
+ if (project) {
22
+ const bridge = pm.getBridge(project.id);
23
+ const list = bridge.listAll();
24
+ const runDirs = bridge.getRunDirs();
25
+
26
+ stats.stories = list.stories.length;
27
+ stats.plans = list.plans.length;
28
+ stats.specs = list.specs.length;
29
+ stats.runs = runDirs.length;
30
+
31
+ if (runDirs.length > 0) {
32
+ const results = runDirs.map(d => bridge.getRunResult(d.name)).filter(Boolean);
33
+ stats.passedRuns = results.filter(r => r.success).length;
34
+ stats.passRate = results.length > 0
35
+ ? Math.round((stats.passedRuns / results.length) * 100)
36
+ : 0;
37
+
38
+ // Most recent run result
39
+ const latest = bridge.getRunResult(runDirs[0].name);
40
+ if (latest) stats.lastRunStatus = latest.success ? 'passed' : 'failed';
41
+ }
42
+ }
43
+
44
+ res.render('index', { projects, project, stats });
8
45
  });
9
46
 
10
47
  module.exports = router;
@@ -1,6 +1,8 @@
1
1
  const express = require('express');
2
2
  const router = express.Router();
3
3
  const pm = require('../services/project-manager');
4
+ const fs = require('fs');
5
+ const path = require('path');
4
6
 
5
7
  router.get('/', (req, res) => {
6
8
  const projectId = req.query.project;
@@ -19,8 +21,8 @@ router.get('/', (req, res) => {
19
21
  timestamp: d.name.replace('run-', '').replace(/-/g, ':').slice(0, 19),
20
22
  success: result ? result.success : null,
21
23
  duration: result ? result.duration : null,
22
- failedCount: result && result.failedTests ? result.failedTests.length : 0,
23
- passedCount: result && result.passedTests ? result.passedTests.length : 0,
24
+ failedCount: result && Array.isArray(result.failedTests) ? result.failedTests.length : 0,
25
+ passedCount: result && Array.isArray(result.passedTests) ? result.passedTests.length : 0,
24
26
  healedCount: healing ? healing.totalHealed || 0 : 0,
25
27
  hasReport: !!bridge.getFinalReport(d.name),
26
28
  hasAllure,
@@ -37,8 +39,34 @@ router.get('/:runId', (req, res) => {
37
39
  if (!project) return res.redirect('/projects');
38
40
 
39
41
  const bridge = pm.getBridge(project.id);
40
- const result = bridge.getRunResult(req.params.runId);
41
- if (!result) return res.status(404).render('error', { message: 'Run not found' });
42
+
43
+ // Validation de l'existence du dossier de run
44
+ const runDirs = bridge.getRunDirs();
45
+ const runExists = runDirs.some(d => d.name === req.params.runId);
46
+ if (!runExists) return res.status(404).render('error', { message: 'Run not found' });
47
+
48
+ // Récupération des résultats ou fallback dynamique (en cours d'exécution)
49
+ let result = bridge.getRunResult(req.params.runId);
50
+ if (!result) {
51
+ const outputPath = path.join(bridge.projectPath, 'test-results', req.params.runId, 'execution-output.json');
52
+ let outputText = 'No console output captured yet.';
53
+ if (fs.existsSync(outputPath)) {
54
+ try {
55
+ outputText = fs.readFileSync(outputPath, 'utf-8');
56
+ } catch (err) {}
57
+ }
58
+
59
+ result = {
60
+ runId: req.params.runId,
61
+ success: null,
62
+ duration: null,
63
+ test: 'Running / In Progress...',
64
+ timestamp: new Date().toISOString(),
65
+ failedTests: [],
66
+ passedTests: [],
67
+ output: outputText
68
+ };
69
+ }
42
70
 
43
71
  const healing = bridge.getHealingReport(req.params.runId);
44
72
  const report = bridge.getFinalReport(req.params.runId);
@@ -57,9 +85,33 @@ router.get('/:runId/compare/:otherRunId', (req, res) => {
57
85
  const result2 = bridge.getRunResult(req.params.otherRunId);
58
86
  if (!result1 || !result2) return res.status(404).render('error', { message: 'Run not found' });
59
87
 
88
+ const hasAllure = bridge.hasAllureReport();
89
+
60
90
  res.render('runs', { project, runs: [
61
- { id: req.params.runId, success: result1.success, duration: result1.duration, testName: result1.test, failedCount: result1.failedTests ? result1.failedTests.length : 0, healedCount: 0, hasReport: !!bridge.getFinalReport(req.params.runId), timestamp: '' },
62
- { id: req.params.otherRunId, success: result2.success, duration: result2.duration, testName: result2.test, failedCount: result2.failedTests ? result2.failedTests.length : 0, healedCount: 0, hasReport: !!bridge.getFinalReport(req.params.otherRunId), timestamp: '' },
91
+ {
92
+ id: req.params.runId,
93
+ success: result1.success,
94
+ duration: result1.duration,
95
+ testName: result1.test || 'Unknown',
96
+ failedCount: Array.isArray(result1.failedTests) ? result1.failedTests.length : 0,
97
+ passedCount: Array.isArray(result1.passedTests) ? result1.passedTests.length : 0,
98
+ healedCount: 0,
99
+ hasReport: !!bridge.getFinalReport(req.params.runId),
100
+ hasAllure,
101
+ timestamp: result1.timestamp ? result1.timestamp.replace('T', ' ').slice(0, 19) : ''
102
+ },
103
+ {
104
+ id: req.params.otherRunId,
105
+ success: result2.success,
106
+ duration: result2.duration,
107
+ testName: result2.test || 'Unknown',
108
+ failedCount: Array.isArray(result2.failedTests) ? result2.failedTests.length : 0,
109
+ passedCount: Array.isArray(result2.passedTests) ? result2.passedTests.length : 0,
110
+ healedCount: 0,
111
+ hasReport: !!bridge.getFinalReport(req.params.otherRunId),
112
+ hasAllure,
113
+ timestamp: result2.timestamp ? result2.timestamp.replace('T', ' ').slice(0, 19) : ''
114
+ },
63
115
  ] });
64
116
  });
65
117
 
@@ -47,55 +47,99 @@ router.get('/:storyName', (req, res) => {
47
47
  res.render('story', { project, story: { name: req.params.storyName, content, plan, planName, spec, specName } });
48
48
  });
49
49
 
50
+ // Helper to stream CLI command output asynchronously to client
51
+ function streamCommand(res, req, bridge, command) {
52
+ if (bridge.isRunning) {
53
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
54
+ res.write("Erreur : Un pipeline de test est déjà en cours d'exécution sur ce projet. Veuillez patienter.\n");
55
+ return res.end();
56
+ }
57
+
58
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
59
+ res.setHeader('Transfer-Encoding', 'chunked');
60
+
61
+ let child;
62
+ try {
63
+ child = bridge.runAsync(
64
+ command,
65
+ (data) => {
66
+ res.write(data);
67
+ },
68
+ (success) => {
69
+ res.write(`\n\n--- [FINISHED: ${success ? 'SUCCESS' : 'FAILED'}] ---\n`);
70
+ res.end();
71
+ }
72
+ );
73
+ } catch (err) {
74
+ res.write(`Error: ${err.message}\n`);
75
+ return res.end();
76
+ }
77
+
78
+ // Prevent zombies: kill child process if user cancels/disconnects request
79
+ req.on('close', () => {
80
+ if (child && !child.killed) {
81
+ child.kill('SIGINT');
82
+ }
83
+ });
84
+ }
85
+
50
86
  router.post('/:storyName/plan', (req, res) => {
51
87
  const projectId = req.query.project;
52
88
  const project = projectId ? pm.get(projectId) : pm.getAll()[0];
53
- if (!project) return res.status(404).json({ error: 'Project not found' });
89
+ if (!project) return res.status(404).send('Project not found');
54
90
 
55
91
  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 });
92
+ const command = `node ai-qa-workflow.js plan "${req.params.storyName}"`;
93
+ streamCommand(res, req, bridge, command);
58
94
  });
59
95
 
60
96
  router.post('/:storyName/generate', (req, res) => {
61
97
  const projectId = req.query.project;
62
98
  const project = projectId ? pm.get(projectId) : pm.getAll()[0];
63
- if (!project) return res.status(404).json({ error: 'Project not found' });
99
+ if (!project) return res.status(404).send('Project not found');
64
100
 
65
101
  const bridge = pm.getBridge(project.id);
66
102
  const baseName = req.params.storyName.replace(/\.md$/i, '');
67
103
  const plans = bridge.listTestPlans();
68
104
  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 });
105
+ if (!planName) {
106
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
107
+ res.write('Error: No test plan found. Run plan first.\n');
108
+ return res.end();
109
+ }
110
+
111
+ const command = `node ai-qa-workflow.js generate "${planName}"`;
112
+ streamCommand(res, req, bridge, command);
73
113
  });
74
114
 
75
115
  router.post('/:storyName/execute', (req, res) => {
76
116
  const projectId = req.query.project;
77
117
  const project = projectId ? pm.get(projectId) : pm.getAll()[0];
78
- if (!project) return res.status(404).json({ error: 'Project not found' });
118
+ if (!project) return res.status(404).send('Project not found');
79
119
 
80
120
  const bridge = pm.getBridge(project.id);
81
121
  const baseName = req.params.storyName.replace(/\.md$/i, '');
82
122
  const specs = bridge.listTestSpecs();
83
123
  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.` });
124
+ if (!specName) {
125
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
126
+ res.write(`Error: No test spec found for "${req.params.storyName}". Generate one first.\n`);
127
+ return res.end();
128
+ }
85
129
 
86
130
  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 });
131
+ const command = `node ai-qa-workflow.js execute "${testName}"`;
132
+ streamCommand(res, req, bridge, command);
89
133
  });
90
134
 
91
135
  router.post('/:storyName/full-pipeline', (req, res) => {
92
136
  const projectId = req.query.project;
93
137
  const project = projectId ? pm.get(projectId) : pm.getAll()[0];
94
- if (!project) return res.status(404).json({ error: 'Project not found' });
138
+ if (!project) return res.status(404).send('Project not found');
95
139
 
96
140
  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 });
141
+ const command = `node ai-qa-workflow.js run "${req.params.storyName}"`;
142
+ streamCommand(res, req, bridge, command);
99
143
  });
100
144
 
101
145
  module.exports = router;
@@ -1,10 +1,44 @@
1
- const { execSync } = require('child_process');
1
+ const { execSync, spawn } = require('child_process');
2
2
  const path = require('path');
3
3
  const fs = require('fs');
4
4
 
5
5
  class CliBridge {
6
6
  constructor(projectPath) {
7
7
  this.projectPath = projectPath;
8
+ this.isRunning = false;
9
+ }
10
+
11
+ runAsync(command, onData, onEnd) {
12
+ if (this.isRunning) {
13
+ throw new Error('Un pipeline de test est déjà en cours d\'exécution sur ce projet.');
14
+ }
15
+
16
+ this.isRunning = true;
17
+
18
+ // Split command into executable and args
19
+ const args = command.split(' ');
20
+ const cmd = args.shift();
21
+
22
+ const child = spawn(cmd, args, {
23
+ cwd: this.projectPath,
24
+ shell: true,
25
+ env: { ...process.env, FORCE_COLOR: '0' } // Disable ANSI escape codes for clean browser logs
26
+ });
27
+
28
+ child.stdout.on('data', (data) => {
29
+ onData(data.toString());
30
+ });
31
+
32
+ child.stderr.on('data', (data) => {
33
+ onData(data.toString());
34
+ });
35
+
36
+ child.on('close', (code) => {
37
+ this.isRunning = false;
38
+ onEnd(code === 0);
39
+ });
40
+
41
+ return child;
8
42
  }
9
43
 
10
44
  _run(command) {
@@ -114,13 +148,23 @@ class CliBridge {
114
148
  getRunResult(runDirName) {
115
149
  const resultPath = path.join(this.projectPath, 'test-results', runDirName, 'execution-result.json');
116
150
  if (!fs.existsSync(resultPath)) return null;
117
- return JSON.parse(fs.readFileSync(resultPath, 'utf-8'));
151
+ try {
152
+ return JSON.parse(fs.readFileSync(resultPath, 'utf-8'));
153
+ } catch (err) {
154
+ console.error(`Error parsing execution-result.json for ${runDirName}:`, err.message);
155
+ return null;
156
+ }
118
157
  }
119
158
 
120
159
  getHealingReport(runDirName) {
121
160
  const reportPath = path.join(this.projectPath, 'test-results', runDirName, 'healing-report.json');
122
161
  if (!fs.existsSync(reportPath)) return null;
123
- return JSON.parse(fs.readFileSync(reportPath, 'utf-8'));
162
+ try {
163
+ return JSON.parse(fs.readFileSync(reportPath, 'utf-8'));
164
+ } catch (err) {
165
+ console.error(`Error parsing healing-report.json for ${runDirName}:`, err.message);
166
+ return null;
167
+ }
124
168
  }
125
169
 
126
170
  getFinalReport(runDirName) {
@@ -8,6 +8,32 @@ class ProjectManager {
8
8
  constructor() {
9
9
  this.projects = [];
10
10
  this._load();
11
+ if (this.projects.length === 0) {
12
+ this._autoRegister();
13
+ }
14
+ }
15
+
16
+ _autoRegister() {
17
+ // Scan parent directories up to 3 levels for ai-qa-workflow.js
18
+ let dir = path.resolve(__dirname, '..', '..');
19
+ for (let i = 0; i < 3; i++) {
20
+ const parent = path.resolve(dir, '..');
21
+ if (parent === dir) break;
22
+ dir = parent;
23
+ }
24
+ // Check current and parent dirs
25
+ const candidates = [
26
+ path.resolve(__dirname, '..', '..'), // 2 levels up (project root from qa-dashboard/services/)
27
+ path.resolve(__dirname, '..'), // qa-dashboard/
28
+ process.cwd(), // wherever node was called from
29
+ ];
30
+ for (const c of [...new Set(candidates)]) {
31
+ if (c && fs.existsSync(path.join(c, 'ai-qa-workflow.js'))) {
32
+ const name = path.basename(c);
33
+ this.add(name, c);
34
+ return;
35
+ }
36
+ }
11
37
  }
12
38
 
13
39
  _load() {