hauntview 0.0.0-stage → 0.0.6
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/LICENSE +21 -0
- package/README.md +358 -2
- package/VERSION +1 -0
- package/dist/cli.js +8184 -0
- package/package.json +120 -4
- package/scripts/postinstall.mjs +47 -0
- package/src/core/browser-launch.ts +60 -0
- package/src/core/browser-pool.ts +267 -0
- package/src/core/concurrency-resolver.ts +154 -0
- package/src/core/finding-classifier.ts +346 -0
- package/src/core/index.ts +8 -0
- package/src/core/profile-manager.ts +499 -0
- package/src/core/report-store.ts +117 -0
- package/src/core/screenshot-manager.ts +312 -0
- package/src/core/system-capability.ts +161 -0
- package/src/core/visual-diff.ts +230 -0
- package/src/engine.ts +473 -0
- package/src/profiles/accessibility-cognitive.ts +261 -0
- package/src/profiles/accessibility-keyboard.ts +225 -0
- package/src/profiles/accessibility-screenreader.ts +243 -0
- package/src/profiles/accessibility-visual.ts +257 -0
- package/src/profiles/broken-links.ts +261 -0
- package/src/profiles/browser-edge.ts +192 -0
- package/src/profiles/browser-firefox.ts +254 -0
- package/src/profiles/browser-legacy.ts +213 -0
- package/src/profiles/console-errors.ts +192 -0
- package/src/profiles/desktop-4k.ts +273 -0
- package/src/profiles/desktop.ts +192 -0
- package/src/profiles/font-failure.ts +148 -0
- package/src/profiles/form-resilience.ts +285 -0
- package/src/profiles/i18n-cjk.ts +218 -0
- package/src/profiles/i18n-mixed.ts +255 -0
- package/src/profiles/i18n-rtl.ts +236 -0
- package/src/profiles/image-failure.ts +178 -0
- package/src/profiles/index.ts +42 -0
- package/src/profiles/js-disabled.ts +179 -0
- package/src/profiles/mobile.ts +175 -0
- package/src/profiles/network-resilience.ts +208 -0
- package/src/profiles/offline.ts +165 -0
- package/src/profiles/performance.ts +244 -0
- package/src/profiles/reduced-motion.ts +144 -0
- package/src/profiles/security-hygiene.ts +206 -0
- package/src/profiles/tablet.ts +176 -0
- package/src/reports/factory.ts +56 -0
- package/src/reports/html-generator.ts +1105 -0
- package/src/reports/index.ts +9 -0
- package/src/reports/markdown-generator.ts +349 -0
- package/src/reports/types.ts +34 -0
- package/src/reports/zip-generator.ts +85 -0
- package/src/types/contracts.ts +238 -0
- package/src/types/finding-utils.ts +121 -0
- package/src/types/index.ts +8 -0
- package/src/types/profiles.ts +175 -0
- package/src/types/results.ts +135 -0
- package/src/version.ts +5 -0
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProfileManager - Orchestrates execution of all 16 testing profiles
|
|
3
|
+
* Manages profile registration, configuration, and execution coordination
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { ProfileConfig, ProfileContext } from '../types';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The 16 HauntView testing profiles
|
|
10
|
+
*/
|
|
11
|
+
const PROFILES: ProfileConfig[] = [
|
|
12
|
+
// Device profiles (5)
|
|
13
|
+
{
|
|
14
|
+
id: 'mobile-iphone15',
|
|
15
|
+
name: 'Mobile - iPhone 15',
|
|
16
|
+
description: 'Apple iPhone 15 Pro (390x844)',
|
|
17
|
+
enabled: true,
|
|
18
|
+
viewport: { width: 390, height: 844, deviceScaleFactor: 3, isMobile: true, hasTouch: true },
|
|
19
|
+
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15',
|
|
20
|
+
timeout: 30000,
|
|
21
|
+
retries: 2,
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: 'mobile-android',
|
|
25
|
+
name: 'Mobile - Android',
|
|
26
|
+
description: 'Samsung Galaxy S23 (360x800)',
|
|
27
|
+
enabled: true,
|
|
28
|
+
viewport: { width: 360, height: 800, deviceScaleFactor: 2, isMobile: true, hasTouch: true },
|
|
29
|
+
userAgent: 'Mozilla/5.0 (Linux; Android 13; SM-S910B) AppleWebKit/537.36',
|
|
30
|
+
timeout: 30000,
|
|
31
|
+
retries: 2,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: 'tablet-ipad',
|
|
35
|
+
name: 'Tablet - iPad Pro',
|
|
36
|
+
description: 'iPad Pro 12.9" (1024x1366)',
|
|
37
|
+
enabled: true,
|
|
38
|
+
viewport: { width: 1024, height: 1366, deviceScaleFactor: 2, isMobile: false, hasTouch: true },
|
|
39
|
+
userAgent: 'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15',
|
|
40
|
+
timeout: 30000,
|
|
41
|
+
retries: 2,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: 'desktop-1080p',
|
|
45
|
+
name: 'Desktop - 1080p',
|
|
46
|
+
description: 'Standard desktop (1920x1080)',
|
|
47
|
+
enabled: true,
|
|
48
|
+
viewport: { width: 1920, height: 1080, isMobile: false, hasTouch: false },
|
|
49
|
+
userAgent:
|
|
50
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0',
|
|
51
|
+
timeout: 30000,
|
|
52
|
+
retries: 1,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: 'desktop-4k',
|
|
56
|
+
name: 'Desktop - 4K',
|
|
57
|
+
description: 'High-resolution desktop (3840x2160)',
|
|
58
|
+
enabled: true,
|
|
59
|
+
viewport: { width: 3840, height: 2160, isMobile: false, hasTouch: false },
|
|
60
|
+
userAgent:
|
|
61
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0',
|
|
62
|
+
timeout: 30000,
|
|
63
|
+
retries: 1,
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
// Accessibility profiles (4)
|
|
67
|
+
{
|
|
68
|
+
id: 'a11y-screenreader',
|
|
69
|
+
name: 'Accessibility - Screen Reader',
|
|
70
|
+
description: 'Screen reader compatibility check',
|
|
71
|
+
enabled: true,
|
|
72
|
+
viewport: { width: 1920, height: 1080, isMobile: false, hasTouch: false },
|
|
73
|
+
locale: 'en-US',
|
|
74
|
+
timeout: 30000,
|
|
75
|
+
retries: 2,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: 'a11y-keyboard',
|
|
79
|
+
name: 'Accessibility - Keyboard Navigation',
|
|
80
|
+
description: 'Keyboard-only navigation support',
|
|
81
|
+
enabled: true,
|
|
82
|
+
viewport: { width: 1920, height: 1080, isMobile: false, hasTouch: false },
|
|
83
|
+
timeout: 30000,
|
|
84
|
+
retries: 2,
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
id: 'a11y-visual',
|
|
88
|
+
name: 'Accessibility - Visual Impairment',
|
|
89
|
+
description: 'Visual impairment simulation (high contrast)',
|
|
90
|
+
enabled: true,
|
|
91
|
+
viewport: { width: 1920, height: 1080, isMobile: false, hasTouch: false },
|
|
92
|
+
timeout: 30000,
|
|
93
|
+
retries: 2,
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: 'a11y-cognitive',
|
|
97
|
+
name: 'Accessibility - Cognitive Load',
|
|
98
|
+
description: 'Cognitive accessibility check',
|
|
99
|
+
enabled: true,
|
|
100
|
+
viewport: { width: 1920, height: 1080, isMobile: false, hasTouch: false },
|
|
101
|
+
timeout: 30000,
|
|
102
|
+
retries: 2,
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
// Localization profiles (3)
|
|
106
|
+
{
|
|
107
|
+
id: 'i18n-rtl',
|
|
108
|
+
name: 'Internationalization - RTL',
|
|
109
|
+
description: 'Right-to-left language support (Arabic)',
|
|
110
|
+
enabled: true,
|
|
111
|
+
viewport: { width: 1920, height: 1080, isMobile: false, hasTouch: false },
|
|
112
|
+
locale: 'ar-SA',
|
|
113
|
+
timezone: 'Asia/Riyadh',
|
|
114
|
+
timeout: 30000,
|
|
115
|
+
retries: 2,
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
id: 'i18n-cjk',
|
|
119
|
+
name: 'Internationalization - CJK',
|
|
120
|
+
description: 'CJK language support (Japanese)',
|
|
121
|
+
enabled: true,
|
|
122
|
+
viewport: { width: 1920, height: 1080, isMobile: false, hasTouch: false },
|
|
123
|
+
locale: 'ja-JP',
|
|
124
|
+
timezone: 'Asia/Tokyo',
|
|
125
|
+
timeout: 30000,
|
|
126
|
+
retries: 2,
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
id: 'i18n-mixed',
|
|
130
|
+
name: 'Internationalization - Mixed Scripts',
|
|
131
|
+
description: 'Mixed script support (Devanagari)',
|
|
132
|
+
enabled: true,
|
|
133
|
+
viewport: { width: 1920, height: 1080, isMobile: false, hasTouch: false },
|
|
134
|
+
locale: 'hi-IN',
|
|
135
|
+
timezone: 'Asia/Kolkata',
|
|
136
|
+
timeout: 30000,
|
|
137
|
+
retries: 2,
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
// Browser/Engine profiles (4)
|
|
141
|
+
{
|
|
142
|
+
id: 'browser-webkit',
|
|
143
|
+
name: 'Browser - WebKit',
|
|
144
|
+
description: 'WebKit engine compatibility',
|
|
145
|
+
enabled: true,
|
|
146
|
+
viewport: { width: 1920, height: 1080, isMobile: false, hasTouch: false },
|
|
147
|
+
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15',
|
|
148
|
+
timeout: 30000,
|
|
149
|
+
retries: 1,
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
id: 'browser-firefox',
|
|
153
|
+
name: 'Browser - Firefox',
|
|
154
|
+
description: 'Firefox engine compatibility',
|
|
155
|
+
enabled: true,
|
|
156
|
+
viewport: { width: 1920, height: 1080, isMobile: false, hasTouch: false },
|
|
157
|
+
userAgent: 'Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0',
|
|
158
|
+
timeout: 30000,
|
|
159
|
+
retries: 1,
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
id: 'browser-edge',
|
|
163
|
+
name: 'Browser - Edge',
|
|
164
|
+
description: 'Microsoft Edge (Chromium) compatibility',
|
|
165
|
+
enabled: true,
|
|
166
|
+
viewport: { width: 1920, height: 1080, isMobile: false, hasTouch: false },
|
|
167
|
+
userAgent:
|
|
168
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0',
|
|
169
|
+
timeout: 30000,
|
|
170
|
+
retries: 1,
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
id: 'browser-legacy',
|
|
174
|
+
name: 'Browser - Legacy',
|
|
175
|
+
description: 'Legacy browser support detection',
|
|
176
|
+
enabled: true,
|
|
177
|
+
viewport: { width: 1920, height: 1080, isMobile: false, hasTouch: false },
|
|
178
|
+
userAgent: 'Mozilla/5.0 (Windows NT 6.3; rv:11.0) like Gecko',
|
|
179
|
+
timeout: 30000,
|
|
180
|
+
retries: 1,
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
// Performance profile
|
|
184
|
+
{
|
|
185
|
+
id: 'performance',
|
|
186
|
+
name: 'Performance',
|
|
187
|
+
description: 'Core Web Vitals and performance metrics',
|
|
188
|
+
enabled: true,
|
|
189
|
+
viewport: { width: 1920, height: 1080, isMobile: false, hasTouch: false },
|
|
190
|
+
timeout: 30000,
|
|
191
|
+
retries: 1,
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
// Canonical rite profiles — desktop viewport (1280×800 per AGENTS.md §4.5)
|
|
195
|
+
{
|
|
196
|
+
id: 'image-failure',
|
|
197
|
+
name: 'Image Failure',
|
|
198
|
+
description: 'Blocks all images; checks missing alt attributes and missing width/height',
|
|
199
|
+
enabled: true,
|
|
200
|
+
viewport: { width: 1280, height: 800, deviceScaleFactor: 1, isMobile: false, hasTouch: false },
|
|
201
|
+
timeout: 30000,
|
|
202
|
+
retries: 1,
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
id: 'font-failure',
|
|
206
|
+
name: 'Font Failure',
|
|
207
|
+
description: 'Blocks web fonts; detects layout shift caused by fallback font substitution',
|
|
208
|
+
enabled: true,
|
|
209
|
+
viewport: { width: 1280, height: 800, deviceScaleFactor: 1, isMobile: false, hasTouch: false },
|
|
210
|
+
timeout: 30000,
|
|
211
|
+
retries: 1,
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
id: 'js-disabled',
|
|
215
|
+
name: 'JS Disabled',
|
|
216
|
+
description: 'Disables JavaScript; checks for meaningful content and <noscript> fallbacks',
|
|
217
|
+
enabled: true,
|
|
218
|
+
viewport: { width: 1280, height: 800, deviceScaleFactor: 1, isMobile: false, hasTouch: false },
|
|
219
|
+
timeout: 30000,
|
|
220
|
+
retries: 1,
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
id: 'reduced-motion',
|
|
224
|
+
name: 'Reduced Motion',
|
|
225
|
+
description: 'Sets prefers-reduced-motion: reduce; detects animations still running',
|
|
226
|
+
enabled: true,
|
|
227
|
+
viewport: { width: 1280, height: 800, deviceScaleFactor: 1, isMobile: false, hasTouch: false },
|
|
228
|
+
timeout: 30000,
|
|
229
|
+
retries: 1,
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
id: 'offline',
|
|
233
|
+
name: 'Offline',
|
|
234
|
+
description: 'Goes offline after first load; checks for service worker and offline fallback',
|
|
235
|
+
enabled: true,
|
|
236
|
+
viewport: { width: 1280, height: 800, deviceScaleFactor: 1, isMobile: false, hasTouch: false },
|
|
237
|
+
timeout: 45000,
|
|
238
|
+
retries: 1,
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
id: 'broken-links',
|
|
242
|
+
name: 'Broken Links',
|
|
243
|
+
description: 'Probes same-origin anchor hrefs (cap 100); flags 404/410/5xx links',
|
|
244
|
+
enabled: true,
|
|
245
|
+
viewport: { width: 1280, height: 800, deviceScaleFactor: 1, isMobile: false, hasTouch: false },
|
|
246
|
+
timeout: 60000,
|
|
247
|
+
retries: 1,
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
id: 'network-resilience',
|
|
251
|
+
name: 'Network Resilience',
|
|
252
|
+
description: 'Aborts all XHR/fetch requests; checks page degrades gracefully',
|
|
253
|
+
enabled: true,
|
|
254
|
+
viewport: { width: 1280, height: 800, deviceScaleFactor: 1, isMobile: false, hasTouch: false },
|
|
255
|
+
timeout: 45000,
|
|
256
|
+
retries: 1,
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
id: 'security-hygiene',
|
|
260
|
+
name: 'Security Hygiene',
|
|
261
|
+
description: 'Passively inspects response headers for CSP, HSTS, X-Frame-Options, etc.',
|
|
262
|
+
enabled: true,
|
|
263
|
+
viewport: { width: 1280, height: 800, deviceScaleFactor: 1, isMobile: false, hasTouch: false },
|
|
264
|
+
timeout: 30000,
|
|
265
|
+
retries: 1,
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
id: 'form-resilience',
|
|
269
|
+
name: 'Form Resilience',
|
|
270
|
+
description: 'Submits empty forms; checks whether validation feedback is shown',
|
|
271
|
+
enabled: true,
|
|
272
|
+
viewport: { width: 1280, height: 800, deviceScaleFactor: 1, isMobile: false, hasTouch: false },
|
|
273
|
+
timeout: 45000,
|
|
274
|
+
retries: 1,
|
|
275
|
+
},
|
|
276
|
+
|
|
277
|
+
// Canonical viewport aliases (same class, canonical IDs)
|
|
278
|
+
{
|
|
279
|
+
id: 'mobile',
|
|
280
|
+
name: 'Mobile',
|
|
281
|
+
description: 'Canonical mobile viewport (390×844)',
|
|
282
|
+
enabled: true,
|
|
283
|
+
viewport: { width: 390, height: 844, deviceScaleFactor: 1, isMobile: true, hasTouch: true },
|
|
284
|
+
timeout: 30000,
|
|
285
|
+
retries: 2,
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
id: 'tablet',
|
|
289
|
+
name: 'Tablet',
|
|
290
|
+
description: 'Canonical tablet viewport (820×1180)',
|
|
291
|
+
enabled: true,
|
|
292
|
+
viewport: { width: 820, height: 1180, deviceScaleFactor: 1, isMobile: false, hasTouch: true },
|
|
293
|
+
timeout: 30000,
|
|
294
|
+
retries: 2,
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
id: 'desktop',
|
|
298
|
+
name: 'Desktop',
|
|
299
|
+
description: 'Canonical desktop viewport (1280×800)',
|
|
300
|
+
enabled: true,
|
|
301
|
+
viewport: { width: 1280, height: 800, deviceScaleFactor: 1, isMobile: false, hasTouch: false },
|
|
302
|
+
timeout: 30000,
|
|
303
|
+
retries: 1,
|
|
304
|
+
},
|
|
305
|
+
];
|
|
306
|
+
|
|
307
|
+
export class ProfileManager {
|
|
308
|
+
private profiles: Map<string, ProfileConfig> = new Map();
|
|
309
|
+
private executionHistory: Map<string, number[]> = new Map(); // profileId -> durations
|
|
310
|
+
|
|
311
|
+
constructor(customProfiles?: ProfileConfig[]) {
|
|
312
|
+
// Initialize with default profiles
|
|
313
|
+
for (const profile of PROFILES) {
|
|
314
|
+
this.profiles.set(profile.id, profile);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Override with custom profiles if provided
|
|
318
|
+
if (customProfiles) {
|
|
319
|
+
for (const profile of customProfiles) {
|
|
320
|
+
this.profiles.set(profile.id, profile);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Get all profiles
|
|
327
|
+
*/
|
|
328
|
+
getAllProfiles(): ProfileConfig[] {
|
|
329
|
+
return Array.from(this.profiles.values());
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Get enabled profiles
|
|
334
|
+
*/
|
|
335
|
+
getEnabledProfiles(): ProfileConfig[] {
|
|
336
|
+
return this.getAllProfiles().filter((p) => p.enabled);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Get profile by ID
|
|
341
|
+
*/
|
|
342
|
+
getProfile(id: string): ProfileConfig | undefined {
|
|
343
|
+
return this.profiles.get(id);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Get profiles by category (device, accessibility, etc.)
|
|
348
|
+
*/
|
|
349
|
+
getProfilesByCategory(category: string): ProfileConfig[] {
|
|
350
|
+
return this.getAllProfiles().filter((p) => p.id.startsWith(category));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Create execution context from profile
|
|
355
|
+
*/
|
|
356
|
+
createExecutionContext(profileId: string): ProfileContext | null {
|
|
357
|
+
const profile = this.getProfile(profileId);
|
|
358
|
+
if (!profile) {
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return {
|
|
363
|
+
profileId: profile.id,
|
|
364
|
+
profileName: profile.name,
|
|
365
|
+
viewport: profile.viewport,
|
|
366
|
+
userAgent: profile.userAgent,
|
|
367
|
+
locale: profile.locale,
|
|
368
|
+
timezone: profile.timezone,
|
|
369
|
+
strictMode: profile.strictMode ?? false,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Enable profile
|
|
375
|
+
*/
|
|
376
|
+
enableProfile(id: string): boolean {
|
|
377
|
+
const profile = this.profiles.get(id);
|
|
378
|
+
if (!profile) {
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
profile.enabled = true;
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Disable profile
|
|
388
|
+
*/
|
|
389
|
+
disableProfile(id: string): boolean {
|
|
390
|
+
const profile = this.profiles.get(id);
|
|
391
|
+
if (!profile) {
|
|
392
|
+
return false;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
profile.enabled = false;
|
|
396
|
+
return true;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Record profile execution
|
|
401
|
+
*/
|
|
402
|
+
recordExecution(profileId: string, duration: number): void {
|
|
403
|
+
if (!this.executionHistory.has(profileId)) {
|
|
404
|
+
this.executionHistory.set(profileId, []);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
this.executionHistory.get(profileId)!.push(duration);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Get profile execution statistics
|
|
412
|
+
*/
|
|
413
|
+
getExecutionStats(profileId: string): {
|
|
414
|
+
executions: number;
|
|
415
|
+
averageDuration: number;
|
|
416
|
+
minDuration: number;
|
|
417
|
+
maxDuration: number;
|
|
418
|
+
} | null {
|
|
419
|
+
const durations = this.executionHistory.get(profileId);
|
|
420
|
+
if (!durations || durations.length === 0) {
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const sum = durations.reduce((a, b) => a + b, 0);
|
|
425
|
+
const average = sum / durations.length;
|
|
426
|
+
|
|
427
|
+
return {
|
|
428
|
+
executions: durations.length,
|
|
429
|
+
averageDuration: average,
|
|
430
|
+
minDuration: Math.min(...durations),
|
|
431
|
+
maxDuration: Math.max(...durations),
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Get optimal profile execution order for parallelization
|
|
437
|
+
*/
|
|
438
|
+
getOptimalExecutionOrder(): ProfileConfig[] {
|
|
439
|
+
const enabled = this.getEnabledProfiles();
|
|
440
|
+
|
|
441
|
+
// Sort by average duration (descending) for better parallelization
|
|
442
|
+
return enabled.sort((a, b) => {
|
|
443
|
+
const statsA = this.getExecutionStats(a.id);
|
|
444
|
+
const statsB = this.getExecutionStats(b.id);
|
|
445
|
+
|
|
446
|
+
const durationA = statsA?.averageDuration ?? (a.timeout || 30000);
|
|
447
|
+
const durationB = statsB?.averageDuration ?? (b.timeout || 30000);
|
|
448
|
+
|
|
449
|
+
return durationB - durationA;
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Validate profile configuration
|
|
455
|
+
*/
|
|
456
|
+
validateProfileConfig(profile: ProfileConfig): { valid: boolean; errors: string[] } {
|
|
457
|
+
const errors: string[] = [];
|
|
458
|
+
|
|
459
|
+
if (!profile.id) errors.push('Profile ID is required');
|
|
460
|
+
if (!profile.name) errors.push('Profile name is required');
|
|
461
|
+
if (!profile.viewport) errors.push('Viewport configuration is required');
|
|
462
|
+
if (profile.viewport && profile.viewport.width < 320)
|
|
463
|
+
errors.push('Viewport width must be at least 320px');
|
|
464
|
+
if (profile.viewport && profile.viewport.height < 240)
|
|
465
|
+
errors.push('Viewport height must be at least 240px');
|
|
466
|
+
|
|
467
|
+
return {
|
|
468
|
+
valid: errors.length === 0,
|
|
469
|
+
errors,
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Add custom profile
|
|
475
|
+
*/
|
|
476
|
+
addProfile(profile: ProfileConfig): { success: boolean; error?: string } {
|
|
477
|
+
const validation = this.validateProfileConfig(profile);
|
|
478
|
+
if (!validation.valid) {
|
|
479
|
+
return { success: false, error: validation.errors.join(', ') };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
this.profiles.set(profile.id, profile);
|
|
483
|
+
return { success: true };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Get profile count
|
|
488
|
+
*/
|
|
489
|
+
getProfileCount(): { total: number; enabled: number; disabled: number } {
|
|
490
|
+
const total = this.profiles.size;
|
|
491
|
+
const enabled = this.getEnabledProfiles().length;
|
|
492
|
+
|
|
493
|
+
return {
|
|
494
|
+
total,
|
|
495
|
+
enabled,
|
|
496
|
+
disabled: total - enabled,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { TestReport } from '../types';
|
|
4
|
+
|
|
5
|
+
export interface TestReportSummary {
|
|
6
|
+
id: string;
|
|
7
|
+
url: string;
|
|
8
|
+
startTime: number;
|
|
9
|
+
duration: number;
|
|
10
|
+
totalProfiles: number;
|
|
11
|
+
successfulProfiles: number;
|
|
12
|
+
failedProfiles: number;
|
|
13
|
+
totalFindings: number;
|
|
14
|
+
errorCount: number;
|
|
15
|
+
warningCount: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const RESULTS_DIR = path.join(process.cwd(), '.hauntview', 'results');
|
|
19
|
+
|
|
20
|
+
// In-memory fallback/cache
|
|
21
|
+
const memoryStore = new Map<string, TestReport>();
|
|
22
|
+
|
|
23
|
+
export class ReportStore {
|
|
24
|
+
private static async ensureDir(): Promise<void> {
|
|
25
|
+
try {
|
|
26
|
+
await fs.mkdir(RESULTS_DIR, { recursive: true });
|
|
27
|
+
} catch {
|
|
28
|
+
// Ignore if exists
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
static async saveReport(report: TestReport): Promise<void> {
|
|
33
|
+
memoryStore.set(report.id, report);
|
|
34
|
+
try {
|
|
35
|
+
await this.ensureDir();
|
|
36
|
+
const filePath = path.join(RESULTS_DIR, `${report.id}.json`);
|
|
37
|
+
await fs.writeFile(filePath, JSON.stringify(report, null, 2), 'utf8');
|
|
38
|
+
} catch (err) {
|
|
39
|
+
console.warn('Failed to persist report to disk, saved in memory only:', err);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
static async getReport(id: string): Promise<TestReport | null> {
|
|
44
|
+
if (memoryStore.has(id)) {
|
|
45
|
+
return memoryStore.get(id)!;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const filePath = path.join(RESULTS_DIR, `${id}.json`);
|
|
50
|
+
const data = await fs.readFile(filePath, 'utf8');
|
|
51
|
+
const parsed = JSON.parse(data) as TestReport;
|
|
52
|
+
memoryStore.set(parsed.id, parsed);
|
|
53
|
+
return parsed;
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
static async listReports(): Promise<TestReportSummary[]> {
|
|
60
|
+
const summaries: TestReportSummary[] = [];
|
|
61
|
+
const seenIds = new Set<string>();
|
|
62
|
+
|
|
63
|
+
// Load from memory first
|
|
64
|
+
for (const report of memoryStore.values()) {
|
|
65
|
+
seenIds.add(report.id);
|
|
66
|
+
summaries.push({
|
|
67
|
+
id: report.id,
|
|
68
|
+
url: report.url,
|
|
69
|
+
startTime: report.startTime,
|
|
70
|
+
duration: report.duration,
|
|
71
|
+
totalProfiles: report.summary.totalProfiles,
|
|
72
|
+
successfulProfiles: report.summary.successfulProfiles,
|
|
73
|
+
failedProfiles: report.summary.failedProfiles,
|
|
74
|
+
totalFindings: report.summary.totalFindings,
|
|
75
|
+
errorCount: report.summary.findingsBySeverity.error,
|
|
76
|
+
warningCount: report.summary.findingsBySeverity.warning,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Load from disk
|
|
81
|
+
try {
|
|
82
|
+
await this.ensureDir();
|
|
83
|
+
const files = await fs.readdir(RESULTS_DIR);
|
|
84
|
+
for (const file of files) {
|
|
85
|
+
if (!file.endsWith('.json')) continue;
|
|
86
|
+
const id = file.replace('.json', '');
|
|
87
|
+
if (seenIds.has(id)) continue;
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const filePath = path.join(RESULTS_DIR, file);
|
|
91
|
+
const data = await fs.readFile(filePath, 'utf8');
|
|
92
|
+
const report = JSON.parse(data) as TestReport;
|
|
93
|
+
summaries.push({
|
|
94
|
+
id: report.id,
|
|
95
|
+
url: report.url,
|
|
96
|
+
startTime: report.startTime,
|
|
97
|
+
duration: report.duration,
|
|
98
|
+
totalProfiles: report.summary.totalProfiles,
|
|
99
|
+
successfulProfiles: report.summary.successfulProfiles,
|
|
100
|
+
failedProfiles: report.summary.failedProfiles,
|
|
101
|
+
totalFindings: report.summary.totalFindings,
|
|
102
|
+
errorCount: report.summary.findingsBySeverity.error,
|
|
103
|
+
warningCount: report.summary.findingsBySeverity.warning,
|
|
104
|
+
});
|
|
105
|
+
seenIds.add(id);
|
|
106
|
+
} catch {
|
|
107
|
+
// Skip corrupt files
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
// Ignore if dir read fails
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Sort descending by start time
|
|
115
|
+
return summaries.sort((a, b) => b.startTime - a.startTime);
|
|
116
|
+
}
|
|
117
|
+
}
|