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,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accessibility: Cognitive Load Profile
|
|
3
|
+
* Validates cognitive accessibility (clarity, complexity, instructions)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Page } from 'playwright';
|
|
7
|
+
import { Finding, ProfileResult } from '../types';
|
|
8
|
+
|
|
9
|
+
export class AccessibilityCognitiveProfile {
|
|
10
|
+
private profileId: string;
|
|
11
|
+
private profileName: string;
|
|
12
|
+
|
|
13
|
+
constructor(profileId: string, profileName: string) {
|
|
14
|
+
this.profileId = profileId;
|
|
15
|
+
this.profileName = profileName;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async execute(page: Page, url: string): Promise<ProfileResult> {
|
|
19
|
+
const startTime = Date.now();
|
|
20
|
+
const findings: Finding[] = [];
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
|
|
24
|
+
|
|
25
|
+
// Analyze page complexity
|
|
26
|
+
const complexity = await page.evaluate(() => {
|
|
27
|
+
const elements = document.querySelectorAll('*');
|
|
28
|
+
return {
|
|
29
|
+
elements: elements.length,
|
|
30
|
+
forms: document.querySelectorAll('form').length,
|
|
31
|
+
inputs: document.querySelectorAll('input, select, textarea').length,
|
|
32
|
+
buttons: document.querySelectorAll('button, a[role="button"]').length,
|
|
33
|
+
animations: document.querySelectorAll('[style*="animation"], [style*="transition"]')
|
|
34
|
+
.length,
|
|
35
|
+
nested: Math.max(
|
|
36
|
+
...Array.from(elements).map((el: Element) => {
|
|
37
|
+
let depth = 0;
|
|
38
|
+
let parent = el.parentElement;
|
|
39
|
+
while (parent) {
|
|
40
|
+
depth++;
|
|
41
|
+
parent = parent.parentElement;
|
|
42
|
+
}
|
|
43
|
+
return depth;
|
|
44
|
+
})
|
|
45
|
+
),
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
if (complexity.elements > 500) {
|
|
50
|
+
findings.push({
|
|
51
|
+
id: `${this.profileId}-too-many-elements`,
|
|
52
|
+
profileId: this.profileId,
|
|
53
|
+
severity: 'warning',
|
|
54
|
+
category: 'cognitive',
|
|
55
|
+
message: `Page has ${complexity.elements} DOM elements, consider simplification`,
|
|
56
|
+
timestamp: Date.now(),
|
|
57
|
+
isClassified: false,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (complexity.nested > 15) {
|
|
62
|
+
findings.push({
|
|
63
|
+
id: `${this.profileId}-deep-nesting`,
|
|
64
|
+
profileId: this.profileId,
|
|
65
|
+
severity: 'info',
|
|
66
|
+
category: 'cognitive',
|
|
67
|
+
message: `Maximum nesting depth is ${complexity.nested} levels, consider flattening`,
|
|
68
|
+
timestamp: Date.now(),
|
|
69
|
+
isClassified: false,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (complexity.animations > 10) {
|
|
74
|
+
findings.push({
|
|
75
|
+
id: `${this.profileId}-excessive-animation`,
|
|
76
|
+
profileId: this.profileId,
|
|
77
|
+
severity: 'info',
|
|
78
|
+
category: 'cognitive',
|
|
79
|
+
message: `${complexity.animations} animated elements, may overwhelm users`,
|
|
80
|
+
timestamp: Date.now(),
|
|
81
|
+
isClassified: false,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Check for auto-playing audio/video
|
|
86
|
+
const autoplay = await page.locator('audio[autoplay], video[autoplay]').count();
|
|
87
|
+
if (autoplay > 0) {
|
|
88
|
+
findings.push({
|
|
89
|
+
id: `${this.profileId}-autoplay`,
|
|
90
|
+
profileId: this.profileId,
|
|
91
|
+
severity: 'warning',
|
|
92
|
+
category: 'cognitive',
|
|
93
|
+
message: `${autoplay} media elements auto-play, breaks cognitive accessibility`,
|
|
94
|
+
timestamp: Date.now(),
|
|
95
|
+
isClassified: false,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Check for prefers-reduced-motion
|
|
100
|
+
const reducedMotion = await page.evaluate(() => {
|
|
101
|
+
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
if (!reducedMotion) {
|
|
105
|
+
findings.push({
|
|
106
|
+
id: `${this.profileId}-no-reduced-motion`,
|
|
107
|
+
profileId: this.profileId,
|
|
108
|
+
severity: 'info',
|
|
109
|
+
category: 'cognitive',
|
|
110
|
+
message: 'Add prefers-reduced-motion media query support for animations',
|
|
111
|
+
timestamp: Date.now(),
|
|
112
|
+
isClassified: false,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Check form complexity
|
|
117
|
+
if (complexity.forms > 0) {
|
|
118
|
+
const formComplexity = await page.evaluate(() => {
|
|
119
|
+
const forms = document.querySelectorAll('form');
|
|
120
|
+
const issues = [];
|
|
121
|
+
|
|
122
|
+
for (const form of forms) {
|
|
123
|
+
const inputs = form.querySelectorAll('input, select, textarea').length;
|
|
124
|
+
const labels = form.querySelectorAll('label').length;
|
|
125
|
+
|
|
126
|
+
if (inputs > 15) {
|
|
127
|
+
issues.push(`Form with ${inputs} fields, consider multi-step`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (inputs > labels) {
|
|
131
|
+
issues.push(`${inputs - labels} form fields missing labels`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return issues;
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
if (formComplexity.length > 0) {
|
|
139
|
+
findings.push({
|
|
140
|
+
id: `${this.profileId}-form-complexity`,
|
|
141
|
+
profileId: this.profileId,
|
|
142
|
+
severity: 'info',
|
|
143
|
+
category: 'cognitive',
|
|
144
|
+
message: `Form complexity issue: ${formComplexity[0]}`,
|
|
145
|
+
timestamp: Date.now(),
|
|
146
|
+
isClassified: false,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Check for clear instructions
|
|
152
|
+
const hasInstructions = await page.evaluate(() => {
|
|
153
|
+
const text = document.body.textContent?.toLowerCase() || '';
|
|
154
|
+
return (
|
|
155
|
+
text.includes('step') ||
|
|
156
|
+
text.includes('instructions') ||
|
|
157
|
+
text.includes('how to') ||
|
|
158
|
+
text.includes('guide')
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
if (!hasInstructions) {
|
|
163
|
+
findings.push({
|
|
164
|
+
id: `${this.profileId}-no-instructions`,
|
|
165
|
+
profileId: this.profileId,
|
|
166
|
+
severity: 'info',
|
|
167
|
+
category: 'cognitive',
|
|
168
|
+
message: 'Page lacks clear instructions or guidance for users',
|
|
169
|
+
timestamp: Date.now(),
|
|
170
|
+
isClassified: false,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Check for abbreviations explanation
|
|
175
|
+
const abbreviations = await page.locator('abbr').count();
|
|
176
|
+
const abbreviationsWithTitle = await page.locator('abbr[title]').count();
|
|
177
|
+
|
|
178
|
+
if (abbreviations > 0 && abbreviationsWithTitle < abbreviations) {
|
|
179
|
+
findings.push({
|
|
180
|
+
id: `${this.profileId}-unexplained-abbr`,
|
|
181
|
+
profileId: this.profileId,
|
|
182
|
+
severity: 'info',
|
|
183
|
+
category: 'cognitive',
|
|
184
|
+
message: `${abbreviations - abbreviationsWithTitle} abbreviations lack title/explanation`,
|
|
185
|
+
timestamp: Date.now(),
|
|
186
|
+
isClassified: false,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Check for readability level (simplified check)
|
|
191
|
+
const avgWordLength = await page.evaluate(() => {
|
|
192
|
+
const text = document.body.innerText || '';
|
|
193
|
+
const words = text.split(/\s+/).filter((w) => w.length > 0);
|
|
194
|
+
if (words.length === 0) return 0;
|
|
195
|
+
const totalChars = words.reduce((sum, w) => sum + w.length, 0);
|
|
196
|
+
return totalChars / words.length;
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
if (avgWordLength > 7) {
|
|
200
|
+
findings.push({
|
|
201
|
+
id: `${this.profileId}-complex-language`,
|
|
202
|
+
profileId: this.profileId,
|
|
203
|
+
severity: 'info',
|
|
204
|
+
category: 'cognitive',
|
|
205
|
+
message: `Average word length ${avgWordLength.toFixed(1)} characters, consider simpler language`,
|
|
206
|
+
timestamp: Date.now(),
|
|
207
|
+
isClassified: false,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Check for paragraph length
|
|
212
|
+
const paragraphs = await page.evaluate(() => {
|
|
213
|
+
return Array.from(document.querySelectorAll('p')).filter((p: Element) => {
|
|
214
|
+
const words = p.textContent?.split(/\s+/).length || 0;
|
|
215
|
+
return words > 100;
|
|
216
|
+
}).length;
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
if (paragraphs > 0) {
|
|
220
|
+
findings.push({
|
|
221
|
+
id: `${this.profileId}-long-paragraphs`,
|
|
222
|
+
profileId: this.profileId,
|
|
223
|
+
severity: 'info',
|
|
224
|
+
category: 'cognitive',
|
|
225
|
+
message: `${paragraphs} paragraphs with > 100 words, consider breaking up`,
|
|
226
|
+
timestamp: Date.now(),
|
|
227
|
+
isClassified: false,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
} catch (error) {
|
|
231
|
+
findings.push({
|
|
232
|
+
id: `${this.profileId}-execution-error`,
|
|
233
|
+
profileId: this.profileId,
|
|
234
|
+
severity: 'error',
|
|
235
|
+
category: 'execution',
|
|
236
|
+
message: `Cognitive profile error: ${error instanceof Error ? error.message : String(error)}`,
|
|
237
|
+
timestamp: Date.now(),
|
|
238
|
+
isClassified: false,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const endTime = Date.now();
|
|
243
|
+
const metadata = await page.context().browser()?.version();
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
profileId: this.profileId,
|
|
247
|
+
profileName: this.profileName,
|
|
248
|
+
success: findings.filter((f) => f.severity === 'error').length === 0,
|
|
249
|
+
startTime,
|
|
250
|
+
endTime,
|
|
251
|
+
duration: endTime - startTime,
|
|
252
|
+
findings,
|
|
253
|
+
screenshots: [],
|
|
254
|
+
metadata: {
|
|
255
|
+
browserEngine: 'webkit',
|
|
256
|
+
browserVersion: metadata || 'unknown',
|
|
257
|
+
nodeVersion: process.version,
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accessibility: Keyboard Navigation Profile
|
|
3
|
+
* Validates keyboard accessibility, tab order, and focus management
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Page } from 'playwright';
|
|
7
|
+
import { Finding, ProfileResult } from '../types';
|
|
8
|
+
|
|
9
|
+
export class AccessibilityKeyboardProfile {
|
|
10
|
+
private profileId: string;
|
|
11
|
+
private profileName: string;
|
|
12
|
+
|
|
13
|
+
constructor(profileId: string, profileName: string) {
|
|
14
|
+
this.profileId = profileId;
|
|
15
|
+
this.profileName = profileName;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async execute(page: Page, url: string): Promise<ProfileResult> {
|
|
19
|
+
const startTime = Date.now();
|
|
20
|
+
const findings: Finding[] = [];
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
|
|
24
|
+
|
|
25
|
+
// Check for positive tabindex values
|
|
26
|
+
const positiveTabindex = await page.evaluate(() => {
|
|
27
|
+
const elements = Array.from(document.querySelectorAll('[tabindex]'));
|
|
28
|
+
return elements.filter(
|
|
29
|
+
(el: Element) => parseInt((el as HTMLElement).getAttribute('tabindex') || '0') > 0
|
|
30
|
+
).length;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
if (positiveTabindex > 0) {
|
|
34
|
+
findings.push({
|
|
35
|
+
id: `${this.profileId}-positive-tabindex`,
|
|
36
|
+
profileId: this.profileId,
|
|
37
|
+
severity: 'warning',
|
|
38
|
+
category: 'accessibility',
|
|
39
|
+
message: `${positiveTabindex} elements use positive tabindex, breaks tab order`,
|
|
40
|
+
timestamp: Date.now(),
|
|
41
|
+
isClassified: false,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Check for keyboard-only accessible buttons
|
|
46
|
+
const buttons = await page.locator('button, a, input').all();
|
|
47
|
+
let nonKeyboardAccessible = 0;
|
|
48
|
+
|
|
49
|
+
for (const button of buttons) {
|
|
50
|
+
const isButton = await button.evaluate((el: Element) => el.tagName === 'BUTTON');
|
|
51
|
+
const isLink = await button.evaluate((el: Element) => el.tagName === 'A');
|
|
52
|
+
const isInput = await button.evaluate(
|
|
53
|
+
(el: Element) =>
|
|
54
|
+
el.tagName === 'INPUT' &&
|
|
55
|
+
['button', 'submit', 'reset'].includes((el as HTMLInputElement).type)
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
if (!isButton && !isLink && !isInput) {
|
|
59
|
+
const onClick = await button.getAttribute('onclick');
|
|
60
|
+
if (onClick) {
|
|
61
|
+
// Div/span with onclick, check for keyboard handler
|
|
62
|
+
const onKeyDown = await button.getAttribute('onkeydown');
|
|
63
|
+
if (!onKeyDown) {
|
|
64
|
+
nonKeyboardAccessible++;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (nonKeyboardAccessible > 0) {
|
|
71
|
+
findings.push({
|
|
72
|
+
id: `${this.profileId}-non-keyboard-buttons`,
|
|
73
|
+
profileId: this.profileId,
|
|
74
|
+
severity: 'warning',
|
|
75
|
+
category: 'accessibility',
|
|
76
|
+
message: `${nonKeyboardAccessible} elements may not be keyboard accessible`,
|
|
77
|
+
timestamp: Date.now(),
|
|
78
|
+
isClassified: false,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Check for focus visible styles
|
|
83
|
+
const focusStyles = await page.evaluate(() => {
|
|
84
|
+
const styles = getComputedStyle(document.body);
|
|
85
|
+
return {
|
|
86
|
+
hasFocus: styles.outline !== 'none',
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
if (!focusStyles.hasFocus) {
|
|
91
|
+
findings.push({
|
|
92
|
+
id: `${this.profileId}-no-focus-styles`,
|
|
93
|
+
profileId: this.profileId,
|
|
94
|
+
severity: 'warning',
|
|
95
|
+
category: 'accessibility',
|
|
96
|
+
message: 'Focus indicator not visible with outline styling',
|
|
97
|
+
timestamp: Date.now(),
|
|
98
|
+
isClassified: false,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Check for keyboard shortcuts documentation
|
|
103
|
+
const hasAccessKey = await page.evaluate(() => {
|
|
104
|
+
return document.querySelectorAll('[accesskey]').length > 0;
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
if (hasAccessKey) {
|
|
108
|
+
findings.push({
|
|
109
|
+
id: `${this.profileId}-accesskey`,
|
|
110
|
+
profileId: this.profileId,
|
|
111
|
+
severity: 'info',
|
|
112
|
+
category: 'accessibility',
|
|
113
|
+
message: 'Page uses accesskey attribute, should document shortcuts',
|
|
114
|
+
timestamp: Date.now(),
|
|
115
|
+
isClassified: false,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Check for focus trap in modals
|
|
120
|
+
const modals = await page.locator('[role="dialog"], .modal').all();
|
|
121
|
+
if (modals.length > 0) {
|
|
122
|
+
const focusTrapped = await modals[0].evaluate(() => {
|
|
123
|
+
const modal = document.querySelector('[role="dialog"], .modal') as HTMLElement;
|
|
124
|
+
if (!modal) return false;
|
|
125
|
+
|
|
126
|
+
const focusableElements = modal.querySelectorAll(
|
|
127
|
+
'button, a, input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
128
|
+
) as NodeListOf<Element>;
|
|
129
|
+
return focusableElements.length > 0;
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
if (!focusTrapped) {
|
|
133
|
+
findings.push({
|
|
134
|
+
id: `${this.profileId}-modal-focus`,
|
|
135
|
+
profileId: this.profileId,
|
|
136
|
+
severity: 'warning',
|
|
137
|
+
category: 'accessibility',
|
|
138
|
+
message: 'Modal dialog has no focusable elements',
|
|
139
|
+
timestamp: Date.now(),
|
|
140
|
+
isClassified: false,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Check for Tab key handling in forms
|
|
146
|
+
const formInputs = await page.locator('input[type="text"], textarea').all();
|
|
147
|
+
let tabHandled = 0;
|
|
148
|
+
|
|
149
|
+
for (const input of formInputs) {
|
|
150
|
+
const onKeyDown = await input.getAttribute('onkeydown');
|
|
151
|
+
if (onKeyDown?.includes('Tab')) {
|
|
152
|
+
tabHandled++;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (tabHandled > 0) {
|
|
157
|
+
findings.push({
|
|
158
|
+
id: `${this.profileId}-tab-handling`,
|
|
159
|
+
profileId: this.profileId,
|
|
160
|
+
severity: 'info',
|
|
161
|
+
category: 'accessibility',
|
|
162
|
+
message: `${tabHandled} form inputs handle Tab key, ensure escape to native tab works`,
|
|
163
|
+
timestamp: Date.now(),
|
|
164
|
+
isClassified: false,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Check for Escape key to close overlays
|
|
169
|
+
const overlays = await page.locator('[role="dialog"], .modal, .popover').all();
|
|
170
|
+
if (overlays.length > 0) {
|
|
171
|
+
const hasEscapeHandler = await overlays[0].evaluate(() => {
|
|
172
|
+
const el = document.querySelector('[role="dialog"], .modal, .popover') as HTMLElement;
|
|
173
|
+
interface ChromeWindow extends Window {
|
|
174
|
+
getEventListeners?: (target: EventTarget) => {
|
|
175
|
+
keydown?: Array<{ listener: { toString(): string } }>;
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
const listeners = (window as ChromeWindow).getEventListeners?.(el);
|
|
179
|
+
return listeners?.keydown?.some((l) => l.listener.toString().includes('Escape')) ?? false;
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
if (!hasEscapeHandler) {
|
|
183
|
+
findings.push({
|
|
184
|
+
id: `${this.profileId}-escape-close`,
|
|
185
|
+
profileId: this.profileId,
|
|
186
|
+
severity: 'info',
|
|
187
|
+
category: 'accessibility',
|
|
188
|
+
message: 'Overlay/modal should close on Escape key',
|
|
189
|
+
timestamp: Date.now(),
|
|
190
|
+
isClassified: false,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
} catch (error) {
|
|
195
|
+
findings.push({
|
|
196
|
+
id: `${this.profileId}-execution-error`,
|
|
197
|
+
profileId: this.profileId,
|
|
198
|
+
severity: 'error',
|
|
199
|
+
category: 'execution',
|
|
200
|
+
message: `Keyboard profile error: ${error instanceof Error ? error.message : String(error)}`,
|
|
201
|
+
timestamp: Date.now(),
|
|
202
|
+
isClassified: false,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const endTime = Date.now();
|
|
207
|
+
const metadata = await page.context().browser()?.version();
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
profileId: this.profileId,
|
|
211
|
+
profileName: this.profileName,
|
|
212
|
+
success: findings.filter((f) => f.severity === 'error').length === 0,
|
|
213
|
+
startTime,
|
|
214
|
+
endTime,
|
|
215
|
+
duration: endTime - startTime,
|
|
216
|
+
findings,
|
|
217
|
+
screenshots: [],
|
|
218
|
+
metadata: {
|
|
219
|
+
browserEngine: 'webkit',
|
|
220
|
+
browserVersion: metadata || 'unknown',
|
|
221
|
+
nodeVersion: process.version,
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
}
|