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.
@@ -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
+ };