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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +358 -2
  3. package/VERSION +1 -0
  4. package/dist/cli.js +8184 -0
  5. package/package.json +120 -4
  6. package/scripts/postinstall.mjs +47 -0
  7. package/src/core/browser-launch.ts +60 -0
  8. package/src/core/browser-pool.ts +267 -0
  9. package/src/core/concurrency-resolver.ts +154 -0
  10. package/src/core/finding-classifier.ts +346 -0
  11. package/src/core/index.ts +8 -0
  12. package/src/core/profile-manager.ts +499 -0
  13. package/src/core/report-store.ts +117 -0
  14. package/src/core/screenshot-manager.ts +312 -0
  15. package/src/core/system-capability.ts +161 -0
  16. package/src/core/visual-diff.ts +230 -0
  17. package/src/engine.ts +473 -0
  18. package/src/profiles/accessibility-cognitive.ts +261 -0
  19. package/src/profiles/accessibility-keyboard.ts +225 -0
  20. package/src/profiles/accessibility-screenreader.ts +243 -0
  21. package/src/profiles/accessibility-visual.ts +257 -0
  22. package/src/profiles/broken-links.ts +261 -0
  23. package/src/profiles/browser-edge.ts +192 -0
  24. package/src/profiles/browser-firefox.ts +254 -0
  25. package/src/profiles/browser-legacy.ts +213 -0
  26. package/src/profiles/console-errors.ts +192 -0
  27. package/src/profiles/desktop-4k.ts +273 -0
  28. package/src/profiles/desktop.ts +192 -0
  29. package/src/profiles/font-failure.ts +148 -0
  30. package/src/profiles/form-resilience.ts +285 -0
  31. package/src/profiles/i18n-cjk.ts +218 -0
  32. package/src/profiles/i18n-mixed.ts +255 -0
  33. package/src/profiles/i18n-rtl.ts +236 -0
  34. package/src/profiles/image-failure.ts +178 -0
  35. package/src/profiles/index.ts +42 -0
  36. package/src/profiles/js-disabled.ts +179 -0
  37. package/src/profiles/mobile.ts +175 -0
  38. package/src/profiles/network-resilience.ts +208 -0
  39. package/src/profiles/offline.ts +165 -0
  40. package/src/profiles/performance.ts +244 -0
  41. package/src/profiles/reduced-motion.ts +144 -0
  42. package/src/profiles/security-hygiene.ts +206 -0
  43. package/src/profiles/tablet.ts +176 -0
  44. package/src/reports/factory.ts +56 -0
  45. package/src/reports/html-generator.ts +1105 -0
  46. package/src/reports/index.ts +9 -0
  47. package/src/reports/markdown-generator.ts +349 -0
  48. package/src/reports/types.ts +34 -0
  49. package/src/reports/zip-generator.ts +85 -0
  50. package/src/types/contracts.ts +238 -0
  51. package/src/types/finding-utils.ts +121 -0
  52. package/src/types/index.ts +8 -0
  53. package/src/types/profiles.ts +175 -0
  54. package/src/types/results.ts +135 -0
  55. package/src/version.ts +5 -0
package/src/engine.ts ADDED
@@ -0,0 +1,473 @@
1
+ /**
2
+ * HauntView Testing Engine - Main orchestrator
3
+ * Coordinates browser pool, profile execution, and result collection
4
+ */
5
+
6
+ import { chromium, webkit, firefox, Browser, Page } from 'playwright';
7
+ import { getChromiumLaunchOptions } from './core/browser-launch';
8
+ import { ProfileManager } from './core/profile-manager';
9
+ import { BrowserPool } from './core/browser-pool';
10
+ import { FindingClassifier } from './core/finding-classifier';
11
+ import { ScreenshotManager } from './core/screenshot-manager';
12
+ import {
13
+ TestExecutionOptions,
14
+ TestReport,
15
+ ProfileResult,
16
+ ReportGenerationOptions,
17
+ ProfileContext,
18
+ } from './types';
19
+ import { ReportFactory } from './reports/factory';
20
+ import { resolveConcurrency, executeWithConcurrency } from './core/concurrency-resolver';
21
+ import { MobileProfile } from './profiles/mobile';
22
+ import { DesktopProfile } from './profiles/desktop';
23
+ import { Desktop4KProfile } from './profiles/desktop-4k';
24
+ import { ConsoleErrorsProfile } from './profiles/console-errors';
25
+ import { TabletProfile } from './profiles/tablet';
26
+ import { PerformanceProfile } from './profiles/performance';
27
+ import { AccessibilityScreenReaderProfile } from './profiles/accessibility-screenreader';
28
+ import { AccessibilityKeyboardProfile } from './profiles/accessibility-keyboard';
29
+ import { AccessibilityVisualProfile } from './profiles/accessibility-visual';
30
+ import { AccessibilityCognitiveProfile } from './profiles/accessibility-cognitive';
31
+ import { I18nRTLProfile } from './profiles/i18n-rtl';
32
+ import { I18nCJKProfile } from './profiles/i18n-cjk';
33
+ import { I18nMixedProfile } from './profiles/i18n-mixed';
34
+ import { BrowserFirefoxProfile } from './profiles/browser-firefox';
35
+ import { BrowserEdgeProfile } from './profiles/browser-edge';
36
+ import { BrowserLegacyProfile } from './profiles/browser-legacy';
37
+ import { ImageFailureProfile } from './profiles/image-failure';
38
+ import { FontFailureProfile } from './profiles/font-failure';
39
+ import { JsDisabledProfile } from './profiles/js-disabled';
40
+ import { ReducedMotionProfile } from './profiles/reduced-motion';
41
+ import { OfflineProfile } from './profiles/offline';
42
+ import { BrokenLinksProfile } from './profiles/broken-links';
43
+ import { NetworkResilienceProfile } from './profiles/network-resilience';
44
+ import { SecurityHygieneProfile } from './profiles/security-hygiene';
45
+ import { FormResilienceProfile } from './profiles/form-resilience';
46
+
47
+ interface ExecutableProfile {
48
+ execute(page: Page, url: string, context?: ProfileContext): Promise<ProfileResult>;
49
+ }
50
+
51
+ /** Profiles that manage their own BrowserContext and accept a Browser instance. */
52
+ export interface BrowserExecutableProfile {
53
+ execute(
54
+ browser: Browser,
55
+ url: string,
56
+ context?: ProfileContext,
57
+ screenshotManager?: ScreenshotManager
58
+ ): Promise<ProfileResult>;
59
+ }
60
+
61
+ export class HauntViewEngine {
62
+ private profileManager: ProfileManager;
63
+ private browserPool: BrowserPool;
64
+ private classifier: FindingClassifier;
65
+ private screenshotManager: ScreenshotManager;
66
+ private browsers: Map<string, Browser> = new Map();
67
+ private browserLaunchPromises: Map<string, Promise<Browser>> = new Map();
68
+
69
+ constructor() {
70
+ this.profileManager = new ProfileManager();
71
+ this.browserPool = new BrowserPool({
72
+ maxBrowsers: 3,
73
+ maxPagesPerBrowser: 10,
74
+ idleTimeout: 30000,
75
+ });
76
+ this.classifier = new FindingClassifier();
77
+ this.screenshotManager = new ScreenshotManager('.hauntview/screenshots');
78
+ }
79
+
80
+ /**
81
+ * Thread-safe browser launcher/acquirer to avoid race conditions during concurrent runs
82
+ */
83
+ private async getOrLaunchBrowser(browserType: string): Promise<Browser> {
84
+ const existing = this.browsers.get(browserType);
85
+ if (existing && existing.isConnected()) {
86
+ return existing;
87
+ }
88
+
89
+ if (this.browserLaunchPromises.has(browserType)) {
90
+ return this.browserLaunchPromises.get(browserType)!;
91
+ }
92
+
93
+ const launchPromise = (async () => {
94
+ let launchedBrowser: Browser;
95
+ if (browserType === 'firefox') {
96
+ try {
97
+ launchedBrowser = await firefox.launch({ headless: true });
98
+ } catch {
99
+ const options = await getChromiumLaunchOptions();
100
+ launchedBrowser = await chromium.launch({
101
+ headless: true,
102
+ executablePath: options.executablePath,
103
+ args: options.args,
104
+ });
105
+ }
106
+ } else if (browserType === 'webkit') {
107
+ try {
108
+ launchedBrowser = await webkit.launch({ headless: true });
109
+ } catch {
110
+ const options = await getChromiumLaunchOptions();
111
+ launchedBrowser = await chromium.launch({
112
+ headless: true,
113
+ executablePath: options.executablePath,
114
+ args: options.args,
115
+ });
116
+ }
117
+ } else {
118
+ const options = await getChromiumLaunchOptions();
119
+ launchedBrowser = await chromium.launch({
120
+ headless: true,
121
+ executablePath: options.executablePath,
122
+ args: options.args,
123
+ });
124
+ }
125
+
126
+ this.browsers.set(browserType, launchedBrowser);
127
+ this.browserPool.registerBrowser(browserType, launchedBrowser);
128
+ return launchedBrowser;
129
+ })().finally(() => {
130
+ this.browserLaunchPromises.delete(browserType);
131
+ });
132
+
133
+ this.browserLaunchPromises.set(browserType, launchPromise);
134
+ return launchPromise;
135
+ }
136
+
137
+ /**
138
+ * Initialize the engine
139
+ */
140
+ async initialize(): Promise<void> {
141
+ await this.screenshotManager.initialize();
142
+ }
143
+
144
+ /**
145
+ * Execute test suite
146
+ */
147
+ async executeTests(options: TestExecutionOptions): Promise<TestReport> {
148
+ const reportId = `hauntview-${Date.now()}`;
149
+ const startTime = Date.now();
150
+
151
+ // Ensure screenshot directory is ready
152
+ await this.screenshotManager.initialize();
153
+
154
+ // Determine which profiles to run
155
+ let executionOrder = this.profileManager.getOptimalExecutionOrder();
156
+ if (options.profiles && options.profiles.length > 0) {
157
+ executionOrder = executionOrder.filter((p) => options.profiles!.includes(p.id));
158
+ }
159
+
160
+ // Resolve concurrency settings & apply hosted guards
161
+ const resolved = resolveConcurrency(options);
162
+ if (resolved.downgradedDueToHosted) {
163
+ console.warn(
164
+ '[engine] Hosted environment detected (Render / HAUNT_HOSTED); concurrency was requested but downgraded to sequential to protect host resources.'
165
+ );
166
+ }
167
+
168
+ const profileResults: ProfileResult[] = [];
169
+
170
+ if (!resolved.concurrency || resolved.concurrencyAmount <= 1) {
171
+ // Sequential execution (original path)
172
+ for (const profile of executionOrder) {
173
+ try {
174
+ const context = this.profileManager.createExecutionContext(profile.id);
175
+ if (!context) continue;
176
+
177
+ const result = await this.executeProfile(profile.id, options.url, context);
178
+ profileResults.push(result);
179
+
180
+ // Classify findings
181
+ for (const finding of result.findings) {
182
+ const classified = await this.classifier.classify(finding);
183
+ finding.canonicalHash = classified.canonical.canonicalHash;
184
+ finding.isClassified = true;
185
+ }
186
+
187
+ this.profileManager.recordExecution(profile.id, result.duration);
188
+ } catch (error) {
189
+ console.error(`Error executing profile ${profile.id}:`, error);
190
+ }
191
+ }
192
+ } else {
193
+ // Concurrent execution with bounded pool and deterministic result ordering
194
+ const tasks = executionOrder.map((profile) => async () => {
195
+ const context = this.profileManager.createExecutionContext(profile.id);
196
+ if (!context) return null;
197
+
198
+ const result = await this.executeProfile(profile.id, options.url, context);
199
+
200
+ for (const finding of result.findings) {
201
+ const classified = await this.classifier.classify(finding);
202
+ finding.canonicalHash = classified.canonical.canonicalHash;
203
+ finding.isClassified = true;
204
+ }
205
+
206
+ this.profileManager.recordExecution(profile.id, result.duration);
207
+ return result;
208
+ });
209
+
210
+ const rawResults = await executeWithConcurrency(tasks, resolved.concurrencyAmount, {
211
+ onFailure: (index, error) => {
212
+ const profile = executionOrder[index];
213
+ console.error(`Error executing profile ${profile.id}:`, error);
214
+ const failedResult: ProfileResult = {
215
+ profileId: profile.id,
216
+ profileName: profile.name,
217
+ success: false,
218
+ startTime: Date.now(),
219
+ endTime: Date.now(),
220
+ duration: 0,
221
+ findings: [],
222
+ screenshots: [],
223
+ error: {
224
+ message: error instanceof Error ? error.message : String(error),
225
+ stack: error instanceof Error ? error.stack : undefined,
226
+ },
227
+ metadata: {
228
+ browserEngine: 'playwright',
229
+ browserVersion: 'unknown',
230
+ nodeVersion: process.version,
231
+ },
232
+ };
233
+ return failedResult;
234
+ },
235
+ });
236
+
237
+ for (const res of rawResults) {
238
+ if (res) {
239
+ profileResults.push(res);
240
+ }
241
+ }
242
+ }
243
+
244
+ const endTime = Date.now();
245
+
246
+ // Aggregate results
247
+ const testReport = this.aggregateResults(
248
+ reportId,
249
+ options.url,
250
+ startTime,
251
+ endTime,
252
+ profileResults
253
+ );
254
+
255
+ return testReport;
256
+ }
257
+
258
+ /**
259
+ * Execute a single profile
260
+ */
261
+ private async executeProfile(
262
+ profileId: string,
263
+ url: string,
264
+ context: ProfileContext
265
+ ): Promise<ProfileResult> {
266
+ let browserType = 'chromium';
267
+ if (profileId === 'browser-webkit') {
268
+ browserType = 'webkit';
269
+ } else if (profileId === 'browser-firefox') {
270
+ browserType = 'firefox';
271
+ }
272
+
273
+ const browser = await this.getOrLaunchBrowser(browserType);
274
+
275
+ const page = await this.browserPool.acquirePage(browserType, context, browser);
276
+
277
+ try {
278
+ let profile: ExecutableProfile | undefined;
279
+ let result: ProfileResult | undefined;
280
+
281
+ // Device profiles
282
+ if (
283
+ profileId === 'mobile-iphone15' ||
284
+ profileId === 'mobile-android' ||
285
+ profileId === 'mobile'
286
+ ) {
287
+ profile = new MobileProfile(profileId, 'Mobile');
288
+ } else if (profileId === 'tablet-ipad' || profileId === 'tablet') {
289
+ profile = new TabletProfile(profileId, 'Tablet');
290
+ } else if (profileId === 'desktop-4k') {
291
+ profile = new Desktop4KProfile(profileId, 'Desktop - 4K');
292
+ } else if (profileId === 'desktop-1080p' || profileId === 'desktop') {
293
+ profile = new DesktopProfile(profileId, 'Desktop');
294
+
295
+ // Console errors profile
296
+ } else if (profileId === 'console-errors') {
297
+ profile = new ConsoleErrorsProfile(profileId, 'Console Errors');
298
+
299
+ // Performance profile
300
+ } else if (profileId === 'performance') {
301
+ profile = new PerformanceProfile(profileId, 'Performance');
302
+
303
+ // Accessibility profiles
304
+ } else if (profileId === 'a11y-screenreader') {
305
+ profile = new AccessibilityScreenReaderProfile(profileId, 'Accessibility - Screen Reader');
306
+ } else if (profileId === 'a11y-keyboard') {
307
+ profile = new AccessibilityKeyboardProfile(profileId, 'Accessibility - Keyboard');
308
+ } else if (profileId === 'a11y-visual') {
309
+ profile = new AccessibilityVisualProfile(profileId, 'Accessibility - Visual');
310
+ } else if (profileId === 'a11y-cognitive') {
311
+ profile = new AccessibilityCognitiveProfile(profileId, 'Accessibility - Cognitive');
312
+
313
+ // Internationalization profiles
314
+ } else if (profileId === 'i18n-rtl') {
315
+ profile = new I18nRTLProfile(profileId, 'I18N - RTL (Arabic)');
316
+ } else if (profileId === 'i18n-cjk') {
317
+ profile = new I18nCJKProfile(profileId, 'I18N - CJK (Japanese)');
318
+ } else if (profileId === 'i18n-mixed') {
319
+ profile = new I18nMixedProfile(profileId, 'I18N - Mixed Scripts');
320
+
321
+ // Browser compatibility profiles
322
+ } else if (profileId === 'browser-firefox') {
323
+ profile = new BrowserFirefoxProfile(profileId, 'Browser - Firefox');
324
+ } else if (profileId === 'browser-edge') {
325
+ profile = new BrowserEdgeProfile(profileId, 'Browser - Edge');
326
+ } else if (profileId === 'browser-legacy') {
327
+ profile = new BrowserLegacyProfile(profileId, 'Browser - Legacy');
328
+ } else if (profileId.startsWith('browser-')) {
329
+ profile = new ConsoleErrorsProfile(profileId, `Browser - ${profileId}`);
330
+
331
+ // Canonical rites — Page-based
332
+ } else if (profileId === 'image-failure') {
333
+ profile = new ImageFailureProfile(profileId, 'Image Failure');
334
+ } else if (profileId === 'font-failure') {
335
+ profile = new FontFailureProfile(profileId, 'Font Failure');
336
+ } else if (profileId === 'broken-links') {
337
+ profile = new BrokenLinksProfile(profileId, 'Broken Links');
338
+ } else if (profileId === 'security-hygiene') {
339
+ profile = new SecurityHygieneProfile(profileId, 'Security Hygiene');
340
+
341
+ // Canonical rites — Browser-based (manage their own context)
342
+ } else if (profileId === 'js-disabled') {
343
+ const p = new JsDisabledProfile(profileId, 'JS Disabled');
344
+ result = await p.execute(browser, url, context, this.screenshotManager);
345
+ } else if (profileId === 'reduced-motion') {
346
+ const p = new ReducedMotionProfile(profileId, 'Reduced Motion');
347
+ result = await p.execute(browser, url, context, this.screenshotManager);
348
+ } else if (profileId === 'offline') {
349
+ const p = new OfflineProfile(profileId, 'Offline');
350
+ result = await p.execute(browser, url, context, this.screenshotManager);
351
+ } else if (profileId === 'network-resilience') {
352
+ const p = new NetworkResilienceProfile(profileId, 'Network Resilience');
353
+ result = await p.execute(browser, url, context, this.screenshotManager);
354
+ } else if (profileId === 'form-resilience') {
355
+ const p = new FormResilienceProfile(profileId, 'Form Resilience');
356
+ result = await p.execute(browser, url, context, this.screenshotManager);
357
+ } else {
358
+ // Default fallback
359
+ profile = new ConsoleErrorsProfile(profileId, profileId);
360
+ }
361
+
362
+ if (!result) {
363
+ result = await profile!.execute(page, url, context);
364
+
365
+ // Capture actual apparition screenshot plate
366
+ try {
367
+ const screenshotMeta = await this.screenshotManager.captureScreenshot(
368
+ page,
369
+ profileId,
370
+ url,
371
+ context.viewport,
372
+ { fullPage: false }
373
+ );
374
+ result.screenshots = [screenshotMeta];
375
+
376
+ // Attach visual evidence to findings
377
+ for (const finding of result.findings) {
378
+ if (!finding.details) finding.details = {};
379
+ finding.details.screenshot = screenshotMeta.fileName;
380
+ }
381
+ } catch (screenshotErr) {
382
+ console.warn(`Could not capture screenshot for profile ${profileId}:`, screenshotErr);
383
+ }
384
+ }
385
+
386
+ return result;
387
+ } finally {
388
+ this.browserPool.releasePage(page);
389
+ }
390
+ }
391
+
392
+ /**
393
+ * Aggregate profile results into test report
394
+ */
395
+ private aggregateResults(
396
+ reportId: string,
397
+ url: string,
398
+ startTime: number,
399
+ endTime: number,
400
+ profileResults: ProfileResult[]
401
+ ): TestReport {
402
+ // Deduplicate findings
403
+ const allFindings = profileResults.flatMap((r) => r.findings);
404
+ const deduplicatedFindings = this.classifier.deduplicateFindings(allFindings);
405
+
406
+ // Calculate summary
407
+ const findingsBySeverity = {
408
+ error: deduplicatedFindings.filter((f) => f.severity === 'error').length,
409
+ warning: deduplicatedFindings.filter((f) => f.severity === 'warning').length,
410
+ info: deduplicatedFindings.filter((f) => f.severity === 'info').length,
411
+ };
412
+
413
+ const findingsByCategory = new Map<string, number>();
414
+ for (const finding of deduplicatedFindings) {
415
+ const category = finding.category || 'uncategorized';
416
+ findingsByCategory.set(category, (findingsByCategory.get(category) || 0) + 1);
417
+ }
418
+
419
+ return {
420
+ id: reportId,
421
+ url,
422
+ startTime,
423
+ endTime,
424
+ duration: endTime - startTime,
425
+ profileResults,
426
+ summary: {
427
+ totalProfiles: profileResults.length,
428
+ successfulProfiles: profileResults.filter((r) => r.success).length,
429
+ failedProfiles: profileResults.filter((r) => !r.success).length,
430
+ totalFindings: deduplicatedFindings.length,
431
+ findingsBySeverity,
432
+ findingsByCategory: Object.fromEntries(findingsByCategory),
433
+ canonicalizedFindings: deduplicatedFindings.filter((f) => f.canonicalHash).length,
434
+ },
435
+ };
436
+ }
437
+
438
+ /**
439
+ * Clean up resources
440
+ */
441
+ async cleanup(): Promise<void> {
442
+ await this.browserPool.closeAll();
443
+
444
+ for (const browser of this.browsers.values()) {
445
+ await browser.close();
446
+ }
447
+
448
+ this.browsers.clear();
449
+ }
450
+
451
+ /**
452
+ * Generate report from test results
453
+ */
454
+ async generateReport(report: TestReport, options: ReportGenerationOptions) {
455
+ const generator = ReportFactory.createGenerator(options.format);
456
+ return generator.generate(report, {
457
+ format: options.format as 'html' | 'markdown' | 'json',
458
+ includeScreenshots: options.includeScreenshots ?? true,
459
+ });
460
+ }
461
+
462
+ /**
463
+ * Get engine statistics
464
+ */
465
+ getStats() {
466
+ return {
467
+ browserPool: this.browserPool.getStats(),
468
+ classifier: this.classifier.getStats(),
469
+ screenshots: this.screenshotManager.getStats(),
470
+ profiles: this.profileManager.getProfileCount(),
471
+ };
472
+ }
473
+ }