qai-cli 3.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.
- package/.claude/mcp-config.json +12 -0
- package/.claude/qa-engineer-prompt.md +194 -0
- package/.eslintrc.json +69 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +79 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +50 -0
- package/.github/ISSUE_TEMPLATE/security.md +43 -0
- package/.github/dependabot.yml +51 -0
- package/.github/pull_request_template.md +11 -0
- package/.github/workflows/lint.yml +35 -0
- package/.github/workflows/playwright-qa.yml +223 -0
- package/.github/workflows/qa-engineer.yml +309 -0
- package/.github/workflows/visual-regression.yml +192 -0
- package/.prettierrc.json +10 -0
- package/README.md +111 -0
- package/action.yml +149 -0
- package/docs/BUGS.md +43 -0
- package/docs/app.js +101 -0
- package/docs/index.html +129 -0
- package/docs/style.css +315 -0
- package/examples/workflow-local.yml +22 -0
- package/examples/workflow-with-vercel.yml +40 -0
- package/package.json +83 -0
- package/qa-report-agent.md +30 -0
- package/qa-report-kudos.md +35 -0
- package/scripts/aria-snapshot.js +328 -0
- package/scripts/page-utils.js +357 -0
- package/scripts/visual-regression.cjs +339 -0
- package/src/analyze.js +365 -0
- package/src/capture.js +133 -0
- package/src/index.js +204 -0
- package/src/providers/anthropic.js +59 -0
- package/src/providers/base.js +164 -0
- package/src/providers/gemini.js +42 -0
- package/src/providers/index.js +132 -0
- package/src/providers/ollama.js +49 -0
- package/src/providers/openai.js +54 -0
- package/src/types.d.ts +148 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ARIA Snapshot Utility for qaie
|
|
3
|
+
* Generates AI-friendly DOM snapshots with element references
|
|
4
|
+
* Adapted from dev-browser patterns
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Browser-executable script that generates ARIA snapshots
|
|
9
|
+
* This gets injected into the page via page.evaluate()
|
|
10
|
+
*/
|
|
11
|
+
const SNAPSHOT_SCRIPT = `
|
|
12
|
+
(function() {
|
|
13
|
+
// Element reference counter
|
|
14
|
+
let refCounter = 0;
|
|
15
|
+
const refs = {};
|
|
16
|
+
|
|
17
|
+
// Store refs on window for persistence
|
|
18
|
+
window.__qaRefs = window.__qaRefs || {};
|
|
19
|
+
|
|
20
|
+
// Interactive roles that should get refs
|
|
21
|
+
const INTERACTIVE_ROLES = new Set([
|
|
22
|
+
'button', 'link', 'textbox', 'checkbox', 'radio', 'combobox',
|
|
23
|
+
'listbox', 'option', 'menuitem', 'tab', 'switch', 'slider',
|
|
24
|
+
'spinbutton', 'searchbox', 'menu', 'menubar', 'dialog',
|
|
25
|
+
'alertdialog', 'listitem', 'treeitem', 'gridcell', 'row',
|
|
26
|
+
'columnheader', 'rowheader'
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
// Elements that are inherently interactive
|
|
30
|
+
const INTERACTIVE_TAGS = new Set([
|
|
31
|
+
'A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'DETAILS', 'SUMMARY'
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
// Get computed accessible role
|
|
35
|
+
function getRole(el) {
|
|
36
|
+
// Explicit role
|
|
37
|
+
const explicit = el.getAttribute('role');
|
|
38
|
+
if (explicit) return explicit;
|
|
39
|
+
|
|
40
|
+
// Implicit role based on tag
|
|
41
|
+
const tag = el.tagName;
|
|
42
|
+
switch (tag) {
|
|
43
|
+
case 'A': return el.href ? 'link' : null;
|
|
44
|
+
case 'BUTTON': return 'button';
|
|
45
|
+
case 'INPUT':
|
|
46
|
+
const type = el.type || 'text';
|
|
47
|
+
switch (type) {
|
|
48
|
+
case 'button':
|
|
49
|
+
case 'submit':
|
|
50
|
+
case 'reset':
|
|
51
|
+
case 'image': return 'button';
|
|
52
|
+
case 'checkbox': return 'checkbox';
|
|
53
|
+
case 'radio': return 'radio';
|
|
54
|
+
case 'range': return 'slider';
|
|
55
|
+
case 'search': return 'searchbox';
|
|
56
|
+
default: return 'textbox';
|
|
57
|
+
}
|
|
58
|
+
case 'SELECT': return el.multiple ? 'listbox' : 'combobox';
|
|
59
|
+
case 'TEXTAREA': return 'textbox';
|
|
60
|
+
case 'IMG': return 'img';
|
|
61
|
+
case 'NAV': return 'navigation';
|
|
62
|
+
case 'MAIN': return 'main';
|
|
63
|
+
case 'HEADER': return 'banner';
|
|
64
|
+
case 'FOOTER': return 'contentinfo';
|
|
65
|
+
case 'ASIDE': return 'complementary';
|
|
66
|
+
case 'SECTION':
|
|
67
|
+
return el.getAttribute('aria-label') || el.getAttribute('aria-labelledby')
|
|
68
|
+
? 'region' : null;
|
|
69
|
+
case 'FORM': return 'form';
|
|
70
|
+
case 'TABLE': return 'table';
|
|
71
|
+
case 'TH': return 'columnheader';
|
|
72
|
+
case 'TD': return 'cell';
|
|
73
|
+
case 'TR': return 'row';
|
|
74
|
+
case 'UL':
|
|
75
|
+
case 'OL': return 'list';
|
|
76
|
+
case 'LI': return 'listitem';
|
|
77
|
+
case 'H1':
|
|
78
|
+
case 'H2':
|
|
79
|
+
case 'H3':
|
|
80
|
+
case 'H4':
|
|
81
|
+
case 'H5':
|
|
82
|
+
case 'H6': return 'heading';
|
|
83
|
+
case 'DIALOG': return 'dialog';
|
|
84
|
+
default: return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Get accessible name
|
|
89
|
+
function getAccessibleName(el) {
|
|
90
|
+
// aria-label first
|
|
91
|
+
const ariaLabel = el.getAttribute('aria-label');
|
|
92
|
+
if (ariaLabel) return ariaLabel.trim();
|
|
93
|
+
|
|
94
|
+
// aria-labelledby
|
|
95
|
+
const labelledBy = el.getAttribute('aria-labelledby');
|
|
96
|
+
if (labelledBy) {
|
|
97
|
+
const labels = labelledBy.split(' ')
|
|
98
|
+
.map(id => document.getElementById(id))
|
|
99
|
+
.filter(Boolean)
|
|
100
|
+
.map(e => e.textContent)
|
|
101
|
+
.join(' ');
|
|
102
|
+
if (labels) return labels.trim();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Label element for form controls
|
|
106
|
+
if (el.labels && el.labels.length) {
|
|
107
|
+
return Array.from(el.labels).map(l => l.textContent).join(' ').trim();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Alt text for images
|
|
111
|
+
if (el.tagName === 'IMG' && el.alt) return el.alt;
|
|
112
|
+
|
|
113
|
+
// Title attribute
|
|
114
|
+
if (el.title) return el.title;
|
|
115
|
+
|
|
116
|
+
// Text content for simple elements
|
|
117
|
+
const text = el.textContent?.trim();
|
|
118
|
+
if (text && text.length < 100) return text;
|
|
119
|
+
|
|
120
|
+
// Placeholder for inputs
|
|
121
|
+
if (el.placeholder) return el.placeholder;
|
|
122
|
+
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Check if element is hidden
|
|
127
|
+
function isHidden(el) {
|
|
128
|
+
if (el.hidden || el.getAttribute('aria-hidden') === 'true') return true;
|
|
129
|
+
const style = getComputedStyle(el);
|
|
130
|
+
return style.display === 'none' || style.visibility === 'hidden';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Check if element should get a ref
|
|
134
|
+
function shouldHaveRef(el, role) {
|
|
135
|
+
if (INTERACTIVE_TAGS.has(el.tagName)) return true;
|
|
136
|
+
if (role && INTERACTIVE_ROLES.has(role)) return true;
|
|
137
|
+
if (el.onclick || el.getAttribute('onclick')) return true;
|
|
138
|
+
if (el.tabIndex >= 0) return true;
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Get element state
|
|
143
|
+
function getState(el) {
|
|
144
|
+
const states = [];
|
|
145
|
+
if (el.disabled) states.push('disabled');
|
|
146
|
+
if (el.checked) states.push('checked');
|
|
147
|
+
if (el.selected) states.push('selected');
|
|
148
|
+
if (el.getAttribute('aria-expanded') === 'true') states.push('expanded');
|
|
149
|
+
if (el.getAttribute('aria-expanded') === 'false') states.push('collapsed');
|
|
150
|
+
if (el.getAttribute('aria-pressed') === 'true') states.push('pressed');
|
|
151
|
+
if (el.getAttribute('aria-current')) states.push('current');
|
|
152
|
+
if (el.required) states.push('required');
|
|
153
|
+
if (el.readOnly) states.push('readonly');
|
|
154
|
+
return states;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Generate ref for element
|
|
158
|
+
function getRef(el) {
|
|
159
|
+
// Check if we already have a ref
|
|
160
|
+
for (const [ref, element] of Object.entries(window.__qaRefs)) {
|
|
161
|
+
if (element === el) return ref;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Generate new ref
|
|
165
|
+
const ref = 'e' + (++refCounter);
|
|
166
|
+
window.__qaRefs[ref] = el;
|
|
167
|
+
refs[ref] = el;
|
|
168
|
+
return ref;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Build snapshot tree
|
|
172
|
+
function buildSnapshot(el, indent = 0) {
|
|
173
|
+
if (!el || isHidden(el)) return '';
|
|
174
|
+
|
|
175
|
+
const role = getRole(el);
|
|
176
|
+
const name = getAccessibleName(el);
|
|
177
|
+
const states = getState(el);
|
|
178
|
+
const hasRef = shouldHaveRef(el, role);
|
|
179
|
+
|
|
180
|
+
let lines = [];
|
|
181
|
+
const prefix = ' '.repeat(indent);
|
|
182
|
+
|
|
183
|
+
// Build this element's line if it has a role or is interactive
|
|
184
|
+
if (role || hasRef) {
|
|
185
|
+
let line = prefix + '- ';
|
|
186
|
+
|
|
187
|
+
if (role) {
|
|
188
|
+
line += role;
|
|
189
|
+
} else {
|
|
190
|
+
line += el.tagName.toLowerCase();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (name) {
|
|
194
|
+
// Escape quotes and limit length
|
|
195
|
+
const safeName = name.replace(/"/g, '\\\\"').substring(0, 60);
|
|
196
|
+
line += ' "' + safeName + '"';
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (hasRef) {
|
|
200
|
+
line += ' [ref=' + getRef(el) + ']';
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
states.forEach(s => {
|
|
204
|
+
line += ' [' + s + ']';
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// Add properties for some elements
|
|
208
|
+
if (el.tagName === 'A' && el.href) {
|
|
209
|
+
lines.push(line);
|
|
210
|
+
lines.push(prefix + ' - /url: "' + el.href.substring(0, 100) + '"');
|
|
211
|
+
} else if (el.tagName === 'IMG' && el.src) {
|
|
212
|
+
lines.push(line);
|
|
213
|
+
lines.push(prefix + ' - /src: "' + el.src.substring(0, 100) + '"');
|
|
214
|
+
} else if (el.placeholder) {
|
|
215
|
+
lines.push(line);
|
|
216
|
+
lines.push(prefix + ' - /placeholder: "' + el.placeholder + '"');
|
|
217
|
+
} else if (el.value && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA')) {
|
|
218
|
+
lines.push(line);
|
|
219
|
+
lines.push(prefix + ' - /value: "' + el.value.substring(0, 50) + '"');
|
|
220
|
+
} else {
|
|
221
|
+
lines.push(line);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Process children
|
|
226
|
+
const childIndent = (role || hasRef) ? indent + 1 : indent;
|
|
227
|
+
for (const child of el.children) {
|
|
228
|
+
const childSnapshot = buildSnapshot(child, childIndent);
|
|
229
|
+
if (childSnapshot) {
|
|
230
|
+
lines.push(childSnapshot);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return lines.join('\\n');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Generate full snapshot
|
|
238
|
+
const snapshot = buildSnapshot(document.body);
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
snapshot,
|
|
242
|
+
refCount: refCounter,
|
|
243
|
+
timestamp: new Date().toISOString()
|
|
244
|
+
};
|
|
245
|
+
})()
|
|
246
|
+
`;
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Get ARIA snapshot of the current page
|
|
250
|
+
*
|
|
251
|
+
* @param {import('playwright').Page} page - Playwright page object
|
|
252
|
+
* @returns {Promise<{snapshot: string, refCount: number, timestamp: string}>}
|
|
253
|
+
*/
|
|
254
|
+
async function getAriaSnapshot(page) {
|
|
255
|
+
return await page.evaluate(SNAPSHOT_SCRIPT);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Select an element by its ref
|
|
260
|
+
*
|
|
261
|
+
* @param {import('playwright').Page} page - Playwright page object
|
|
262
|
+
* @param {string} ref - Element ref (e.g., 'e5')
|
|
263
|
+
* @returns {Promise<import('playwright').ElementHandle|null>}
|
|
264
|
+
*/
|
|
265
|
+
async function selectByRef(page, ref) {
|
|
266
|
+
return await page.evaluateHandle((ref) => {
|
|
267
|
+
// eslint-disable-next-line no-undef
|
|
268
|
+
return window.__qaRefs?.[ref] || null;
|
|
269
|
+
}, ref);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Click an element by its ref
|
|
274
|
+
*
|
|
275
|
+
* @param {import('playwright').Page} page - Playwright page object
|
|
276
|
+
* @param {string} ref - Element ref
|
|
277
|
+
*/
|
|
278
|
+
async function clickByRef(page, ref) {
|
|
279
|
+
const element = await selectByRef(page, ref);
|
|
280
|
+
if (element) {
|
|
281
|
+
await element.click();
|
|
282
|
+
} else {
|
|
283
|
+
throw new Error(`Element with ref ${ref} not found`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Type into an element by its ref
|
|
289
|
+
*
|
|
290
|
+
* @param {import('playwright').Page} page - Playwright page object
|
|
291
|
+
* @param {string} ref - Element ref
|
|
292
|
+
* @param {string} text - Text to type
|
|
293
|
+
*/
|
|
294
|
+
async function typeByRef(page, ref, text) {
|
|
295
|
+
const element = await selectByRef(page, ref);
|
|
296
|
+
if (element) {
|
|
297
|
+
await element.fill(text);
|
|
298
|
+
} else {
|
|
299
|
+
throw new Error(`Element with ref ${ref} not found`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Get a compact snapshot suitable for AI analysis
|
|
305
|
+
*
|
|
306
|
+
* @param {import('playwright').Page} page - Playwright page object
|
|
307
|
+
* @returns {Promise<string>} Formatted snapshot
|
|
308
|
+
*/
|
|
309
|
+
async function getCompactSnapshot(page) {
|
|
310
|
+
const result = await getAriaSnapshot(page);
|
|
311
|
+
|
|
312
|
+
let output = `# Page Snapshot (${result.timestamp})\n`;
|
|
313
|
+
output += `Interactive elements: ${result.refCount}\n\n`;
|
|
314
|
+
output += '```yaml\n';
|
|
315
|
+
output += result.snapshot;
|
|
316
|
+
output += '\n```\n';
|
|
317
|
+
|
|
318
|
+
return output;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
module.exports = {
|
|
322
|
+
getAriaSnapshot,
|
|
323
|
+
selectByRef,
|
|
324
|
+
clickByRef,
|
|
325
|
+
typeByRef,
|
|
326
|
+
getCompactSnapshot,
|
|
327
|
+
SNAPSHOT_SCRIPT,
|
|
328
|
+
};
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smart Page Utilities for qaie
|
|
3
|
+
* Adapted from dev-browser patterns for reliable page load detection
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Ad/tracking domains to ignore when waiting for network idle
|
|
7
|
+
const IGNORED_DOMAINS = [
|
|
8
|
+
'doubleclick.net',
|
|
9
|
+
'googlesyndication.com',
|
|
10
|
+
'googleadservices.com',
|
|
11
|
+
'google-analytics.com',
|
|
12
|
+
'googletagmanager.com',
|
|
13
|
+
'facebook.net',
|
|
14
|
+
'facebook.com/tr',
|
|
15
|
+
'hotjar.com',
|
|
16
|
+
'intercom.io',
|
|
17
|
+
'segment.io',
|
|
18
|
+
'segment.com',
|
|
19
|
+
'mixpanel.com',
|
|
20
|
+
'amplitude.com',
|
|
21
|
+
'sentry.io',
|
|
22
|
+
'newrelic.com',
|
|
23
|
+
'nr-data.net',
|
|
24
|
+
'fullstory.com',
|
|
25
|
+
'clarity.ms',
|
|
26
|
+
'bing.com/bat',
|
|
27
|
+
'ads.linkedin.com',
|
|
28
|
+
'analytics.twitter.com',
|
|
29
|
+
'px.ads.linkedin.com',
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
// Non-critical resource types that shouldn't block page ready
|
|
33
|
+
const NON_CRITICAL_TYPES = ['image', 'font', 'media', 'stylesheet'];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Check if a URL should be ignored for load detection
|
|
37
|
+
*/
|
|
38
|
+
function shouldIgnoreRequest(url) {
|
|
39
|
+
try {
|
|
40
|
+
const parsed = new URL(url);
|
|
41
|
+
|
|
42
|
+
// Ignore data URLs
|
|
43
|
+
if (parsed.protocol === 'data:') return true;
|
|
44
|
+
|
|
45
|
+
// Ignore very long URLs (usually tracking pixels)
|
|
46
|
+
if (url.length > 2000) return true;
|
|
47
|
+
|
|
48
|
+
// Ignore known ad/tracking domains
|
|
49
|
+
return IGNORED_DOMAINS.some((domain) => parsed.hostname.includes(domain));
|
|
50
|
+
} catch {
|
|
51
|
+
return true; // Invalid URLs are ignored
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Wait for page to be truly ready (not just DOM loaded)
|
|
57
|
+
*
|
|
58
|
+
* @param {import('playwright').Page} page - Playwright page object
|
|
59
|
+
* @param {Object} options - Configuration options
|
|
60
|
+
* @param {number} options.timeout - Max wait time in ms (default: 30000)
|
|
61
|
+
* @param {number} options.networkIdleTime - Time with no requests to consider idle (default: 500)
|
|
62
|
+
* @param {number} options.nonCriticalTimeout - Extra time to wait for non-critical resources (default: 3000)
|
|
63
|
+
* @returns {Promise<{ready: boolean, pendingRequests: string[], loadTime: number}>}
|
|
64
|
+
*/
|
|
65
|
+
async function waitForPageReady(page, options = {}) {
|
|
66
|
+
const { timeout = 30000, networkIdleTime = 500, nonCriticalTimeout = 3000 } = options;
|
|
67
|
+
|
|
68
|
+
const startTime = Date.now();
|
|
69
|
+
const pendingRequests = new Map();
|
|
70
|
+
|
|
71
|
+
// Track network requests
|
|
72
|
+
const onRequest = (request) => {
|
|
73
|
+
const url = request.url();
|
|
74
|
+
if (!shouldIgnoreRequest(url)) {
|
|
75
|
+
pendingRequests.set(request, {
|
|
76
|
+
url,
|
|
77
|
+
type: request.resourceType(),
|
|
78
|
+
startTime: Date.now(),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const onResponse = (response) => {
|
|
84
|
+
pendingRequests.delete(response.request());
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const onRequestFailed = (request) => {
|
|
88
|
+
pendingRequests.delete(request);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
page.on('request', onRequest);
|
|
92
|
+
page.on('response', onResponse);
|
|
93
|
+
page.on('requestfailed', onRequestFailed);
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
// Wait for DOM content loaded first
|
|
97
|
+
await page.waitForLoadState('domcontentloaded', { timeout });
|
|
98
|
+
|
|
99
|
+
// Now wait for network to settle
|
|
100
|
+
while (Date.now() - startTime < timeout) {
|
|
101
|
+
// Filter to only critical pending requests
|
|
102
|
+
const criticalPending = Array.from(pendingRequests.values()).filter((req) => {
|
|
103
|
+
const elapsed = Date.now() - req.startTime;
|
|
104
|
+
|
|
105
|
+
// Non-critical resources get extra grace period
|
|
106
|
+
if (NON_CRITICAL_TYPES.includes(req.type)) {
|
|
107
|
+
return elapsed < nonCriticalTimeout;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return true;
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
if (criticalPending.length === 0) {
|
|
114
|
+
// No critical requests pending, wait for idle time
|
|
115
|
+
await new Promise((r) => setTimeout(r, networkIdleTime));
|
|
116
|
+
|
|
117
|
+
// Check again after idle time
|
|
118
|
+
const stillPending = Array.from(pendingRequests.values()).filter((req) => {
|
|
119
|
+
const elapsed = Date.now() - req.startTime;
|
|
120
|
+
if (NON_CRITICAL_TYPES.includes(req.type)) {
|
|
121
|
+
return elapsed < nonCriticalTimeout;
|
|
122
|
+
}
|
|
123
|
+
return true;
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
if (stillPending.length === 0) {
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const loadTime = Date.now() - startTime;
|
|
135
|
+
const remaining = Array.from(pendingRequests.values()).map((r) => r.url);
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
ready: remaining.length === 0,
|
|
139
|
+
pendingRequests: remaining,
|
|
140
|
+
loadTime,
|
|
141
|
+
};
|
|
142
|
+
} finally {
|
|
143
|
+
page.off('request', onRequest);
|
|
144
|
+
page.off('response', onResponse);
|
|
145
|
+
page.off('requestfailed', onRequestFailed);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Network request logger for detecting failed API calls
|
|
151
|
+
*
|
|
152
|
+
* @param {import('playwright').Page} page - Playwright page object
|
|
153
|
+
* @returns {Object} Logger object with methods to get results
|
|
154
|
+
*/
|
|
155
|
+
function createNetworkLogger(page) {
|
|
156
|
+
const requests = [];
|
|
157
|
+
const failures = [];
|
|
158
|
+
const slowRequests = [];
|
|
159
|
+
const SLOW_THRESHOLD = 3000;
|
|
160
|
+
|
|
161
|
+
const onRequest = (request) => {
|
|
162
|
+
requests.push({
|
|
163
|
+
url: request.url(),
|
|
164
|
+
method: request.method(),
|
|
165
|
+
type: request.resourceType(),
|
|
166
|
+
startTime: Date.now(),
|
|
167
|
+
});
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const onResponse = (response) => {
|
|
171
|
+
const request = response.request();
|
|
172
|
+
const entry = requests.find((r) => r.url === request.url() && !r.endTime);
|
|
173
|
+
|
|
174
|
+
if (entry) {
|
|
175
|
+
entry.endTime = Date.now();
|
|
176
|
+
entry.duration = entry.endTime - entry.startTime;
|
|
177
|
+
entry.status = response.status();
|
|
178
|
+
|
|
179
|
+
// Track slow requests
|
|
180
|
+
if (entry.duration > SLOW_THRESHOLD) {
|
|
181
|
+
slowRequests.push(entry);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Track failed requests (4xx, 5xx)
|
|
185
|
+
if (response.status() >= 400) {
|
|
186
|
+
failures.push({
|
|
187
|
+
...entry,
|
|
188
|
+
statusText: response.statusText(),
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const onRequestFailed = (request) => {
|
|
195
|
+
const entry = requests.find((r) => r.url === request.url() && !r.endTime);
|
|
196
|
+
if (entry) {
|
|
197
|
+
entry.endTime = Date.now();
|
|
198
|
+
entry.duration = entry.endTime - entry.startTime;
|
|
199
|
+
entry.failed = true;
|
|
200
|
+
entry.error = request.failure()?.errorText || 'Unknown error';
|
|
201
|
+
failures.push(entry);
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
page.on('request', onRequest);
|
|
206
|
+
page.on('response', onResponse);
|
|
207
|
+
page.on('requestfailed', onRequestFailed);
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
/**
|
|
211
|
+
* Get summary of network activity
|
|
212
|
+
*/
|
|
213
|
+
getSummary() {
|
|
214
|
+
return {
|
|
215
|
+
totalRequests: requests.length,
|
|
216
|
+
failedRequests: failures.length,
|
|
217
|
+
slowRequests: slowRequests.length,
|
|
218
|
+
failures: failures.map((f) => ({
|
|
219
|
+
url: f.url,
|
|
220
|
+
method: f.method,
|
|
221
|
+
status: f.status,
|
|
222
|
+
error: f.error,
|
|
223
|
+
duration: f.duration,
|
|
224
|
+
})),
|
|
225
|
+
slow: slowRequests.map((s) => ({
|
|
226
|
+
url: s.url,
|
|
227
|
+
duration: s.duration,
|
|
228
|
+
})),
|
|
229
|
+
};
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Get formatted report for QA output
|
|
234
|
+
*/
|
|
235
|
+
getReport() {
|
|
236
|
+
const summary = this.getSummary();
|
|
237
|
+
let report = '### Network Summary\n';
|
|
238
|
+
report += `- Total requests: ${summary.totalRequests}\n`;
|
|
239
|
+
report += `- Failed requests: ${summary.failedRequests}\n`;
|
|
240
|
+
report += `- Slow requests (>${SLOW_THRESHOLD}ms): ${summary.slowRequests}\n\n`;
|
|
241
|
+
|
|
242
|
+
if (summary.failures.length > 0) {
|
|
243
|
+
report += '#### Failed Requests\n';
|
|
244
|
+
summary.failures.forEach((f) => {
|
|
245
|
+
report += `- \`${f.method} ${f.url}\`\n`;
|
|
246
|
+
report += ` - Status: ${f.status || 'N/A'}\n`;
|
|
247
|
+
if (f.error) report += ` - Error: ${f.error}\n`;
|
|
248
|
+
});
|
|
249
|
+
report += '\n';
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (summary.slow.length > 0) {
|
|
253
|
+
report += '#### Slow Requests\n';
|
|
254
|
+
summary.slow.forEach((s) => {
|
|
255
|
+
report += `- \`${s.url}\` (${s.duration}ms)\n`;
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return report;
|
|
260
|
+
},
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Stop logging and clean up
|
|
264
|
+
*/
|
|
265
|
+
stop() {
|
|
266
|
+
page.off('request', onRequest);
|
|
267
|
+
page.off('response', onResponse);
|
|
268
|
+
page.off('requestfailed', onRequestFailed);
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Get console errors from the page
|
|
275
|
+
*
|
|
276
|
+
* @param {import('playwright').Page} page - Playwright page object
|
|
277
|
+
* @returns {Object} Console logger with methods to get results
|
|
278
|
+
*/
|
|
279
|
+
function createConsoleLogger(page) {
|
|
280
|
+
const errors = [];
|
|
281
|
+
const warnings = [];
|
|
282
|
+
|
|
283
|
+
const onConsole = (msg) => {
|
|
284
|
+
const type = msg.type();
|
|
285
|
+
const entry = {
|
|
286
|
+
type,
|
|
287
|
+
text: msg.text(),
|
|
288
|
+
location: msg.location(),
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
if (type === 'error') {
|
|
292
|
+
errors.push(entry);
|
|
293
|
+
} else if (type === 'warning') {
|
|
294
|
+
warnings.push(entry);
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const onPageError = (error) => {
|
|
299
|
+
errors.push({
|
|
300
|
+
type: 'pageerror',
|
|
301
|
+
text: error.message,
|
|
302
|
+
stack: error.stack,
|
|
303
|
+
});
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
page.on('console', onConsole);
|
|
307
|
+
page.on('pageerror', onPageError);
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
getErrors() {
|
|
311
|
+
return errors;
|
|
312
|
+
},
|
|
313
|
+
|
|
314
|
+
getWarnings() {
|
|
315
|
+
return warnings;
|
|
316
|
+
},
|
|
317
|
+
|
|
318
|
+
getReport() {
|
|
319
|
+
let report = '### Console Output\n';
|
|
320
|
+
report += `- Errors: ${errors.length}\n`;
|
|
321
|
+
report += `- Warnings: ${warnings.length}\n\n`;
|
|
322
|
+
|
|
323
|
+
if (errors.length > 0) {
|
|
324
|
+
report += '#### Errors\n';
|
|
325
|
+
errors.forEach((e, i) => {
|
|
326
|
+
report += `${i + 1}. \`${e.text}\`\n`;
|
|
327
|
+
if (e.location?.url) {
|
|
328
|
+
report += ` - Location: ${e.location.url}:${e.location.lineNumber}\n`;
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
report += '\n';
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (warnings.length > 0) {
|
|
335
|
+
report += '#### Warnings\n';
|
|
336
|
+
warnings.forEach((w, i) => {
|
|
337
|
+
report += `${i + 1}. \`${w.text}\`\n`;
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return report;
|
|
342
|
+
},
|
|
343
|
+
|
|
344
|
+
stop() {
|
|
345
|
+
page.off('console', onConsole);
|
|
346
|
+
page.off('pageerror', onPageError);
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
module.exports = {
|
|
352
|
+
waitForPageReady,
|
|
353
|
+
createNetworkLogger,
|
|
354
|
+
createConsoleLogger,
|
|
355
|
+
shouldIgnoreRequest,
|
|
356
|
+
IGNORED_DOMAINS,
|
|
357
|
+
};
|