skopix 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.
@@ -0,0 +1,282 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+
4
+ export class ReportGenerator {
5
+ constructor(outputDir, sessionId) {
6
+ this.outputDir = outputDir;
7
+ this.sessionId = sessionId;
8
+ }
9
+
10
+ async generate({ sessionId, url, goal, steps, issues, goalAchieved, stuck, videoPath, duration, provider, model }) {
11
+ const reportPath = path.join(this.outputDir, 'report.html');
12
+ const jsonPath = path.join(this.outputDir, 'report.json');
13
+
14
+ // Save raw JSON
15
+ await fs.writeJson(jsonPath, {
16
+ sessionId, url, goal, steps, issues, goalAchieved, stuck,
17
+ duration, provider, model, generatedAt: new Date().toISOString(),
18
+ }, { spaces: 2 });
19
+
20
+ // Build HTML report
21
+ const html = this._buildHTML({ sessionId, url, goal, steps, issues, goalAchieved, stuck, videoPath, duration, provider, model });
22
+ await fs.writeFile(reportPath, html);
23
+
24
+ return reportPath;
25
+ }
26
+
27
+ _buildHTML({ sessionId, url, goal, steps, issues, goalAchieved, stuck, videoPath, duration, provider, model }) {
28
+ const status = goalAchieved ? 'PASSED' : stuck ? 'STUCK' : 'FAILED';
29
+ const statusClass = goalAchieved ? 'passed' : stuck ? 'stuck' : 'failed';
30
+ const durationStr = this._formatDuration(duration);
31
+ const videoFilename = videoPath ? path.basename(videoPath) : null;
32
+
33
+ const stepsHtml = steps.map((s, i) => this._stepHtml(s, i)).join('');
34
+ const issuesHtml = issues.length > 0
35
+ ? issues.map((iss) => this._issueHtml(iss)).join('')
36
+ : '<p class="no-issues">No issues detected in this session.</p>';
37
+
38
+ return `<!DOCTYPE html>
39
+ <html lang="en">
40
+ <head>
41
+ <meta charset="UTF-8">
42
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
43
+ <title>Skopix Report — ${sessionId}</title>
44
+ <style>
45
+ :root {
46
+ --bg: #0a0a0f;
47
+ --surface: #111118;
48
+ --border: #1e1e2e;
49
+ --accent: #00d4ff;
50
+ --accent2: #7c3aed;
51
+ --text: #e2e8f0;
52
+ --muted: #64748b;
53
+ --passed: #10b981;
54
+ --failed: #ef4444;
55
+ --stuck: #f59e0b;
56
+ --critical: #ef4444;
57
+ --high: #f97316;
58
+ --medium: #f59e0b;
59
+ --low: #3b82f6;
60
+ }
61
+ * { box-sizing: border-box; margin: 0; padding: 0; }
62
+ body { background: var(--bg); color: var(--text); font-family: 'SF Mono', 'Fira Code', monospace; font-size: 14px; line-height: 1.6; }
63
+ .container { max-width: 1100px; margin: 0 auto; padding: 40px 24px; }
64
+
65
+ header { border-bottom: 1px solid var(--border); padding-bottom: 32px; margin-bottom: 32px; }
66
+ .brand { display: flex; align-items: center; gap: 12px; margin-bottom: 24px; }
67
+ .brand-name { font-size: 22px; font-weight: 700; color: var(--accent); letter-spacing: 0.05em; }
68
+ .brand-sub { color: var(--muted); font-size: 12px; }
69
+
70
+ .meta-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; }
71
+ .meta-card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 16px; }
72
+ .meta-label { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 6px; }
73
+ .meta-value { color: var(--text); font-size: 14px; word-break: break-all; }
74
+ .meta-value.accent { color: var(--accent); }
75
+
76
+ .status-badge { display: inline-flex; align-items: center; gap: 8px; padding: 8px 20px; border-radius: 100px; font-weight: 700; font-size: 13px; letter-spacing: 0.05em; }
77
+ .status-badge.passed { background: rgba(16,185,129,0.15); color: var(--passed); border: 1px solid rgba(16,185,129,0.3); }
78
+ .status-badge.failed { background: rgba(239,68,68,0.15); color: var(--failed); border: 1px solid rgba(239,68,68,0.3); }
79
+ .status-badge.stuck { background: rgba(245,158,11,0.15); color: var(--stuck); border: 1px solid rgba(245,158,11,0.3); }
80
+
81
+ section { margin-bottom: 40px; }
82
+ .section-title { font-size: 16px; font-weight: 600; color: var(--accent); margin-bottom: 20px; padding-bottom: 8px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 10px; }
83
+ .badge { background: var(--accent2); color: white; font-size: 11px; padding: 2px 8px; border-radius: 100px; }
84
+
85
+ .issue-card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 20px; margin-bottom: 12px; border-left: 3px solid; }
86
+ .issue-card.critical { border-left-color: var(--critical); }
87
+ .issue-card.high { border-left-color: var(--high); }
88
+ .issue-card.medium { border-left-color: var(--medium); }
89
+ .issue-card.low { border-left-color: var(--low); }
90
+ .issue-header { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
91
+ .severity-tag { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; padding: 2px 8px; border-radius: 4px; }
92
+ .severity-tag.critical { background: rgba(239,68,68,0.2); color: var(--critical); }
93
+ .severity-tag.high { background: rgba(249,115,22,0.2); color: var(--high); }
94
+ .severity-tag.medium { background: rgba(245,158,11,0.2); color: var(--stuck); }
95
+ .severity-tag.low { background: rgba(59,130,246,0.2); color: var(--low); }
96
+ .issue-title { font-weight: 600; color: var(--text); }
97
+ .issue-desc { color: var(--muted); font-size: 13px; margin-bottom: 8px; }
98
+ .issue-meta { font-size: 12px; color: var(--muted); }
99
+
100
+ .step-card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; margin-bottom: 8px; overflow: hidden; }
101
+ .step-header { display: flex; align-items: center; gap: 12px; padding: 14px 16px; cursor: pointer; }
102
+ .step-header:hover { background: rgba(255,255,255,0.02); }
103
+ .step-num { color: var(--muted); font-size: 12px; min-width: 40px; }
104
+ .step-action { color: var(--accent); font-weight: 600; font-size: 12px; min-width: 80px; }
105
+ .step-target { color: var(--text); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
106
+ .step-status { font-size: 16px; }
107
+ .conf-bar { display: flex; gap: 2px; }
108
+ .conf-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--border); }
109
+ .conf-dot.filled { background: var(--accent); }
110
+ .step-body { padding: 0 16px 16px; border-top: 1px solid var(--border); display: none; }
111
+ .step-body.open { display: block; }
112
+ .step-field { margin-top: 12px; }
113
+ .step-field-label { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 4px; }
114
+ .step-field-value { color: var(--text); font-size: 13px; }
115
+ .step-screenshot { width: 100%; border-radius: 6px; margin-top: 12px; border: 1px solid var(--border); cursor: pointer; }
116
+
117
+ .video-wrap { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 20px; }
118
+ video { width: 100%; border-radius: 6px; }
119
+
120
+ .no-issues { color: var(--muted); font-style: italic; padding: 16px 0; }
121
+
122
+ footer { border-top: 1px solid var(--border); padding-top: 24px; margin-top: 40px; display: flex; justify-content: space-between; align-items: center; color: var(--muted); font-size: 12px; }
123
+
124
+ .lightbox { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.9); z-index: 9999; align-items: center; justify-content: center; }
125
+ .lightbox.open { display: flex; }
126
+ .lightbox img { max-width: 95vw; max-height: 95vh; border-radius: 8px; }
127
+ .lightbox-close { position: absolute; top: 20px; right: 20px; color: white; font-size: 24px; cursor: pointer; background: none; border: none; }
128
+ </style>
129
+ </head>
130
+ <body>
131
+ <div class="container">
132
+ <header>
133
+ <div class="brand">
134
+ <div>
135
+ <div class="brand-name">SKOPIX</div>
136
+ <div class="brand-sub">AI QA Agent Report</div>
137
+ </div>
138
+ <div style="margin-left: auto;">
139
+ <span class="status-badge ${statusClass}">${status === 'PASSED' ? '✓' : status === 'STUCK' ? '⚠' : '✗'} ${status}</span>
140
+ </div>
141
+ </div>
142
+ <div class="meta-grid">
143
+ <div class="meta-card">
144
+ <div class="meta-label">Session ID</div>
145
+ <div class="meta-value accent">${sessionId}</div>
146
+ </div>
147
+ <div class="meta-card">
148
+ <div class="meta-label">Target URL</div>
149
+ <div class="meta-value"><a href="${url}" style="color: var(--accent)" target="_blank">${url}</a></div>
150
+ </div>
151
+ <div class="meta-card">
152
+ <div class="meta-label">Goal</div>
153
+ <div class="meta-value">${escapeHtml(goal)}</div>
154
+ </div>
155
+ <div class="meta-card">
156
+ <div class="meta-label">Steps / Issues</div>
157
+ <div class="meta-value">${steps.length} steps · ${issues.length} issues</div>
158
+ </div>
159
+ <div class="meta-card">
160
+ <div class="meta-label">Duration</div>
161
+ <div class="meta-value">${durationStr}</div>
162
+ </div>
163
+ <div class="meta-card">
164
+ <div class="meta-label">AI Model</div>
165
+ <div class="meta-value">${provider} / ${model}</div>
166
+ </div>
167
+ </div>
168
+ </header>
169
+
170
+ ${issues.length > 0 ? `
171
+ <section>
172
+ <div class="section-title">Issues Detected <span class="badge">${issues.length}</span></div>
173
+ ${issuesHtml}
174
+ </section>
175
+ ` : ''}
176
+
177
+ <section>
178
+ <div class="section-title">Test Steps <span class="badge">${steps.length}</span></div>
179
+ ${stepsHtml}
180
+ </section>
181
+
182
+ ${videoFilename ? `
183
+ <section>
184
+ <div class="section-title">Session Recording</div>
185
+ <div class="video-wrap">
186
+ <video controls src="${videoFilename}"></video>
187
+ </div>
188
+ </section>
189
+ ` : ''}
190
+
191
+ <footer>
192
+ <span>Generated by Skopix · ${new Date().toUTCString()}</span>
193
+ <span>skopix.dev</span>
194
+ </footer>
195
+ </div>
196
+
197
+ <div class="lightbox" id="lightbox" onclick="closeLightbox()">
198
+ <button class="lightbox-close" onclick="closeLightbox()">✕</button>
199
+ <img id="lightbox-img" src="" alt="screenshot">
200
+ </div>
201
+
202
+ <script>
203
+ function toggleStep(el) {
204
+ const body = el.nextElementSibling;
205
+ body.classList.toggle('open');
206
+ }
207
+ function openLightbox(src) {
208
+ document.getElementById('lightbox-img').src = src;
209
+ document.getElementById('lightbox').classList.add('open');
210
+ }
211
+ function closeLightbox() {
212
+ document.getElementById('lightbox').classList.remove('open');
213
+ }
214
+ </script>
215
+ </body>
216
+ </html>`;
217
+ }
218
+
219
+ _stepHtml(step, i) {
220
+ const statusIcon = step.success ? '✓' : '✗';
221
+ const confDots = Array.from({ length: 5 }, (_, j) =>
222
+ `<div class="conf-dot ${j < Math.round(step.confidence / 2) ? 'filled' : ''}"></div>`
223
+ ).join('');
224
+
225
+ const issuesInStep = step.issues && step.issues.length > 0
226
+ ? `<div class="step-field"><div class="step-field-label">Issues</div>${step.issues.map(iss =>
227
+ `<div><span class="severity-tag ${iss.severity}">${iss.severity}</span> ${escapeHtml(iss.title)}</div>`
228
+ ).join('')}</div>`
229
+ : '';
230
+
231
+ const screenshotHtml = step.screenshot
232
+ ? `<img class="step-screenshot" src="${step.screenshot}" alt="Step ${i + 1}" onclick="openLightbox('${step.screenshot}')" loading="lazy">`
233
+ : '';
234
+
235
+ return `
236
+ <div class="step-card">
237
+ <div class="step-header" onclick="toggleStep(this)">
238
+ <span class="step-num">#${i + 1}</span>
239
+ <span class="step-action">${step.action}</span>
240
+ <span class="step-target">${escapeHtml(step.target || step.value || '—')}</span>
241
+ <div class="conf-bar">${confDots}</div>
242
+ <span class="step-status">${step.success ? '✓' : '✗'}</span>
243
+ </div>
244
+ <div class="step-body">
245
+ ${step.reasoning ? `<div class="step-field"><div class="step-field-label">Reasoning</div><div class="step-field-value">${escapeHtml(step.reasoning)}</div></div>` : ''}
246
+ ${step.observation ? `<div class="step-field"><div class="step-field-label">Observation</div><div class="step-field-value">${escapeHtml(step.observation)}</div></div>` : ''}
247
+ ${step.error ? `<div class="step-field"><div class="step-field-label">Error</div><div class="step-field-value" style="color:var(--failed)">${escapeHtml(step.error)}</div></div>` : ''}
248
+ ${issuesInStep}
249
+ ${screenshotHtml}
250
+ </div>
251
+ </div>`;
252
+ }
253
+
254
+ _issueHtml(issue) {
255
+ const sev = (issue.severity || 'low').toLowerCase();
256
+ return `
257
+ <div class="issue-card ${sev}">
258
+ <div class="issue-header">
259
+ <span class="severity-tag ${sev}">${sev}</span>
260
+ <span class="issue-title">${escapeHtml(issue.title)}</span>
261
+ </div>
262
+ <div class="issue-desc">${escapeHtml(issue.description)}</div>
263
+ <div class="issue-meta">Step ${issue.step} · ${escapeHtml(issue.url)}${issue.type ? ` · ${issue.type}` : ''}</div>
264
+ </div>`;
265
+ }
266
+
267
+ _formatDuration(ms) {
268
+ if (ms < 1000) return `${ms}ms`;
269
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
270
+ return `${Math.floor(ms / 60000)}m ${Math.round((ms % 60000) / 1000)}s`;
271
+ }
272
+ }
273
+
274
+ function escapeHtml(str) {
275
+ if (!str) return '';
276
+ return String(str)
277
+ .replace(/&/g, '&amp;')
278
+ .replace(/</g, '&lt;')
279
+ .replace(/>/g, '&gt;')
280
+ .replace(/"/g, '&quot;')
281
+ .replace(/'/g, '&#39;');
282
+ }