alchemymvc 1.4.0 → 1.4.2
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/lib/app/behaviour/revision_behaviour.js +1 -1
- package/lib/app/behaviour/sluggable_behaviour.js +2 -2
- package/lib/app/datasource/mongo_datasource.js +19 -3
- package/lib/app/helper/cron.js +2 -2
- package/lib/app/helper_datasource/00-nosql_datasource.js +9 -3
- package/lib/app/helper_datasource/05-fallback_datasource.js +10 -13
- package/lib/app/helper_datasource/idb_datasource.js +7 -5
- package/lib/app/helper_datasource/remote_datasource.js +1 -1
- package/lib/app/helper_field/password_field.js +4 -2
- package/lib/app/helper_field/schema_field.js +3 -2
- package/lib/app/helper_field/time_field.js +1 -1
- package/lib/app/helper_model/00-base_criteria.js +14 -0
- package/lib/app/helper_model/05-criteria_expressions.js +30 -7
- package/lib/app/helper_model/10-model_criteria.js +47 -8
- package/lib/app/helper_model/document.js +11 -2
- package/lib/app/helper_model/model.js +6 -3
- package/lib/app/model/system_task_history_model.js +134 -0
- package/lib/class/conduit.js +5 -2
- package/lib/class/controller.js +1 -0
- package/lib/class/datasource.js +14 -2
- package/lib/class/document.js +40 -12
- package/lib/class/import_stream_parser.js +299 -0
- package/lib/class/inode_file.js +2 -0
- package/lib/class/migration.js +5 -2
- package/lib/class/model.js +12 -142
- package/lib/class/plugin.js +32 -3
- package/lib/class/postponement.js +1 -1
- package/lib/class/router.js +26 -28
- package/lib/class/schema_client.js +39 -8
- package/lib/class/sitemap.js +2 -2
- package/lib/class/task.js +42 -24
- package/lib/core/alchemy.js +110 -162
- package/lib/core/alchemy_load_functions.js +64 -5
- package/lib/core/base.js +2 -2
- package/lib/core/middleware.js +31 -5
- package/lib/core/prefix.js +1 -1
- package/lib/core/setting.js +12 -9
- package/lib/scripts/create_constants.js +5 -1
- package/lib/stages/00-load_core.js +8 -2
- package/lib/testing/browser.js +1164 -0
- package/lib/testing/harness.js +922 -0
- package/package.json +13 -6
- package/testing/browser.js +27 -0
- package/testing.js +37 -0
|
@@ -0,0 +1,1164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser Testing Helper for Alchemy projects
|
|
3
|
+
*
|
|
4
|
+
* This module provides browser testing capabilities using Puppeteer.
|
|
5
|
+
* It handles:
|
|
6
|
+
* - Puppeteer lifecycle (launch/connect/close)
|
|
7
|
+
* - Navigation with coverage tracking
|
|
8
|
+
* - DOM queries and interactions
|
|
9
|
+
* - Hawkejs client-side navigation
|
|
10
|
+
* - File uploads
|
|
11
|
+
* - Development mode (connect to existing browser)
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* const { BrowserHelper } = require('alchemymvc/testing');
|
|
15
|
+
* const browser_helper = new BrowserHelper(harness, { coverage: true });
|
|
16
|
+
* await browser_helper.load();
|
|
17
|
+
* await browser_helper.goto('/some/path');
|
|
18
|
+
* await browser_helper.click('.button');
|
|
19
|
+
* await browser_helper.close();
|
|
20
|
+
*
|
|
21
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
22
|
+
* @since 1.4.1
|
|
23
|
+
* @version 1.4.1
|
|
24
|
+
*/
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The BrowserHelper class
|
|
29
|
+
*
|
|
30
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
31
|
+
* @since 1.4.1
|
|
32
|
+
* @version 1.4.1
|
|
33
|
+
*
|
|
34
|
+
* @param {TestHarness} harness The test harness instance
|
|
35
|
+
* @param {Object} options
|
|
36
|
+
*/
|
|
37
|
+
const BrowserHelper = Function.inherits('Informer', 'Alchemy.Testing', function BrowserHelper(harness, options) {
|
|
38
|
+
|
|
39
|
+
BrowserHelper.super.call(this);
|
|
40
|
+
|
|
41
|
+
if (options == null) {
|
|
42
|
+
options = {};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Store the harness reference
|
|
46
|
+
this.harness = harness;
|
|
47
|
+
|
|
48
|
+
// Store options with defaults
|
|
49
|
+
this.options = Object.assign({
|
|
50
|
+
// Enable coverage collection (default: false)
|
|
51
|
+
coverage: false,
|
|
52
|
+
|
|
53
|
+
// Connect to existing browser instead of launching (default: false)
|
|
54
|
+
// When true, connects to 'http://127.0.0.1:9333/' for debugging
|
|
55
|
+
connect: false,
|
|
56
|
+
|
|
57
|
+
// Browser URL to connect to in development mode
|
|
58
|
+
browser_url: 'http://127.0.0.1:9333/',
|
|
59
|
+
|
|
60
|
+
// Viewport dimensions
|
|
61
|
+
viewport_width: 1680,
|
|
62
|
+
viewport_height: 1050,
|
|
63
|
+
|
|
64
|
+
// Enable devtools in connect mode
|
|
65
|
+
devtools: true,
|
|
66
|
+
|
|
67
|
+
// Log browser console messages (default: false)
|
|
68
|
+
log_console: false,
|
|
69
|
+
|
|
70
|
+
// Headless mode when launching (default: 'new')
|
|
71
|
+
headless: 'new',
|
|
72
|
+
|
|
73
|
+
}, options);
|
|
74
|
+
|
|
75
|
+
// Puppeteer instances
|
|
76
|
+
this.browser = null;
|
|
77
|
+
this.page = null;
|
|
78
|
+
|
|
79
|
+
// Coverage tracking
|
|
80
|
+
this._navigations = 0;
|
|
81
|
+
this._coverages = [];
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Get Puppeteer module (lazy loaded)
|
|
86
|
+
*
|
|
87
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
88
|
+
* @since 1.4.1
|
|
89
|
+
* @version 1.4.1
|
|
90
|
+
*
|
|
91
|
+
* @return {Object}
|
|
92
|
+
*/
|
|
93
|
+
BrowserHelper.setMethod(function getPuppeteer() {
|
|
94
|
+
|
|
95
|
+
if (!this._puppeteer) {
|
|
96
|
+
try {
|
|
97
|
+
this._puppeteer = require('puppeteer');
|
|
98
|
+
} catch (err) {
|
|
99
|
+
throw new Error('puppeteer is required for browser testing. Install it with: npm install --save-dev puppeteer');
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return this._puppeteer;
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Load/launch the browser
|
|
108
|
+
*
|
|
109
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
110
|
+
* @since 1.4.1
|
|
111
|
+
* @version 1.4.1
|
|
112
|
+
*
|
|
113
|
+
* @return {Promise<{browser: Object, page: Object}>}
|
|
114
|
+
*/
|
|
115
|
+
BrowserHelper.setMethod(async function load() {
|
|
116
|
+
|
|
117
|
+
if (this.browser) {
|
|
118
|
+
return { browser: this.browser, page: this.page };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let puppeteer = this.getPuppeteer();
|
|
122
|
+
|
|
123
|
+
if (this.options.connect) {
|
|
124
|
+
// Connect to existing browser (development mode)
|
|
125
|
+
this.browser = await puppeteer.connect({
|
|
126
|
+
browserURL: this.options.browser_url,
|
|
127
|
+
defaultViewport: {
|
|
128
|
+
width: this.options.viewport_width,
|
|
129
|
+
height: this.options.viewport_height,
|
|
130
|
+
},
|
|
131
|
+
devtools: this.options.devtools,
|
|
132
|
+
});
|
|
133
|
+
} else {
|
|
134
|
+
// Launch new browser
|
|
135
|
+
this.browser = await puppeteer.launch({
|
|
136
|
+
headless: this.options.headless,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
this.page = await this.browser.newPage();
|
|
141
|
+
|
|
142
|
+
// Set up console message handler
|
|
143
|
+
this._setupConsoleHandler();
|
|
144
|
+
|
|
145
|
+
return { browser: this.browser, page: this.page };
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Set up console message handler
|
|
150
|
+
*
|
|
151
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
152
|
+
* @since 1.4.1
|
|
153
|
+
* @version 1.4.1
|
|
154
|
+
*/
|
|
155
|
+
BrowserHelper.setMethod(function _setupConsoleHandler() {
|
|
156
|
+
|
|
157
|
+
if (!this.page) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Store console errors for debugging
|
|
162
|
+
this._console_errors = [];
|
|
163
|
+
|
|
164
|
+
// Capture page errors
|
|
165
|
+
this.page.on('pageerror', (err) => {
|
|
166
|
+
this._console_errors.push('PageError: ' + err.message + ' | Stack: ' + (err.stack || 'no stack'));
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
this.page.on('console', (msg) => {
|
|
170
|
+
|
|
171
|
+
// Always capture errors (including console.error with stack traces)
|
|
172
|
+
if (msg.type() === 'error') {
|
|
173
|
+
let text = msg.text();
|
|
174
|
+
this._console_errors.push(text);
|
|
175
|
+
// Also log immediately for debugging
|
|
176
|
+
console.log('[BROWSER ERROR]', text);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!this.options.log_console) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
let pieces = ['[BROWSER]'],
|
|
184
|
+
args = msg.args();
|
|
185
|
+
|
|
186
|
+
for (let arg of args) {
|
|
187
|
+
let remote = arg._remoteObject;
|
|
188
|
+
|
|
189
|
+
if (remote.type == 'string') {
|
|
190
|
+
pieces.push(remote.value);
|
|
191
|
+
} else if (remote.subtype == 'node') {
|
|
192
|
+
pieces.push('\x1b[1m\x1b[36m<' + remote.description + '>\x1b[0m');
|
|
193
|
+
} else if (remote.className) {
|
|
194
|
+
pieces.push('\x1b[1m\x1b[33m{' + remote.type + ' ' + remote.className + '}\x1b[0m');
|
|
195
|
+
} else if (remote.value != null) {
|
|
196
|
+
pieces.push(remote.value);
|
|
197
|
+
} else {
|
|
198
|
+
pieces.push(remote);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
console.log(...pieces);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Close the browser
|
|
208
|
+
*
|
|
209
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
210
|
+
* @since 1.4.1
|
|
211
|
+
* @version 1.4.1
|
|
212
|
+
*
|
|
213
|
+
* @return {Promise}
|
|
214
|
+
*/
|
|
215
|
+
BrowserHelper.setMethod(async function close() {
|
|
216
|
+
|
|
217
|
+
// Fetch final coverage data
|
|
218
|
+
if (this.options.coverage) {
|
|
219
|
+
await this.fetchCoverage();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Don't close if we connected (development mode)
|
|
223
|
+
if (this.browser && !this.options.connect) {
|
|
224
|
+
await this.browser.close();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
this.browser = null;
|
|
228
|
+
this.page = null;
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Get a full URL for a path
|
|
233
|
+
* Note: Uses 127.0.0.1 instead of localhost for consistency with browser tests
|
|
234
|
+
*
|
|
235
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
236
|
+
* @since 1.4.1
|
|
237
|
+
* @version 1.4.1
|
|
238
|
+
*
|
|
239
|
+
* @param {string} path
|
|
240
|
+
*
|
|
241
|
+
* @return {string}
|
|
242
|
+
*/
|
|
243
|
+
BrowserHelper.setMethod(function getUrl(path) {
|
|
244
|
+
|
|
245
|
+
if (!path.startsWith('/')) {
|
|
246
|
+
path = '/' + path;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Get the port from harness or alchemy settings
|
|
250
|
+
let port;
|
|
251
|
+
|
|
252
|
+
if (this.harness) {
|
|
253
|
+
port = this.harness.options.port;
|
|
254
|
+
} else if (typeof alchemy !== 'undefined') {
|
|
255
|
+
port = alchemy.settings.network.port;
|
|
256
|
+
} else {
|
|
257
|
+
port = 3470;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Use 127.0.0.1 for consistency with browser tests (not localhost)
|
|
261
|
+
return 'http://127.0.0.1:' + port + path;
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Fetch browser-side coverage data
|
|
266
|
+
*
|
|
267
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
268
|
+
* @since 1.4.1
|
|
269
|
+
* @version 1.4.1
|
|
270
|
+
*
|
|
271
|
+
* @return {Promise<Array>}
|
|
272
|
+
*/
|
|
273
|
+
BrowserHelper.setMethod(async function fetchCoverage() {
|
|
274
|
+
|
|
275
|
+
if (!this.page) {
|
|
276
|
+
return this._coverages;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
let temp = await this.page.evaluate(function getCoverage() {
|
|
280
|
+
|
|
281
|
+
if (typeof window.__Protoblast == 'undefined') {
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return window.__coverage__;
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
if (temp) {
|
|
289
|
+
this._coverages.push(temp);
|
|
290
|
+
} else if (temp !== false) {
|
|
291
|
+
console.log('Failed to get coverage from browser');
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return this._coverages;
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Get all collected coverage data
|
|
299
|
+
*
|
|
300
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
301
|
+
* @since 1.4.1
|
|
302
|
+
* @version 1.4.1
|
|
303
|
+
*
|
|
304
|
+
* @return {Array}
|
|
305
|
+
*/
|
|
306
|
+
BrowserHelper.setMethod(function getCoverages() {
|
|
307
|
+
return this._coverages;
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Get the number of navigations
|
|
312
|
+
*
|
|
313
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
314
|
+
* @since 1.4.1
|
|
315
|
+
* @version 1.4.1
|
|
316
|
+
*
|
|
317
|
+
* @return {number}
|
|
318
|
+
*/
|
|
319
|
+
BrowserHelper.setMethod(function getNavigationCount() {
|
|
320
|
+
return this._navigations;
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Navigate to a path (full page navigation)
|
|
325
|
+
*
|
|
326
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
327
|
+
* @since 1.4.1
|
|
328
|
+
* @version 1.4.1
|
|
329
|
+
*
|
|
330
|
+
* @param {string} path
|
|
331
|
+
*
|
|
332
|
+
* @return {Promise<Object>}
|
|
333
|
+
*/
|
|
334
|
+
BrowserHelper.setMethod(async function goto(path) {
|
|
335
|
+
|
|
336
|
+
if (!this.page) {
|
|
337
|
+
await this.load();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Collect coverage from previous navigation
|
|
341
|
+
if (this._navigations > 0 && this.options.coverage) {
|
|
342
|
+
await this.fetchCoverage();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
this._navigations++;
|
|
346
|
+
|
|
347
|
+
// Force exposing defaults (routes may be added on-the-fly during testing)
|
|
348
|
+
if (typeof alchemy !== 'undefined') {
|
|
349
|
+
alchemy.exposeDefaultStaticVariables();
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
let url;
|
|
353
|
+
|
|
354
|
+
if (path.indexOf('http') === -1) {
|
|
355
|
+
url = this.getUrl(path);
|
|
356
|
+
} else {
|
|
357
|
+
url = path;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
let resource = await this.page.goto(url);
|
|
361
|
+
let status = await resource.status();
|
|
362
|
+
|
|
363
|
+
if (status >= 400) {
|
|
364
|
+
throw new Error('Received a ' + status + ' error response for "' + path + '"');
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return resource;
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Navigate using Hawkejs client-side navigation (SPA-style)
|
|
372
|
+
*
|
|
373
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
374
|
+
* @since 1.4.1
|
|
375
|
+
* @version 1.4.1
|
|
376
|
+
*
|
|
377
|
+
* @param {string} path
|
|
378
|
+
*
|
|
379
|
+
* @return {Promise<Object>}
|
|
380
|
+
*/
|
|
381
|
+
BrowserHelper.setMethod(async function openUrl(path) {
|
|
382
|
+
|
|
383
|
+
if (!this.page) {
|
|
384
|
+
throw new Error('Browser not loaded. Call load() or goto() first.');
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Check if hawkejs is available before trying to use it
|
|
388
|
+
let check = await this.page.evaluate(function() {
|
|
389
|
+
return typeof hawkejs !== 'undefined';
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
if (!check) {
|
|
393
|
+
let errors = this._console_errors || [];
|
|
394
|
+
throw new Error('hawkejs is not defined in browser. Console errors: ' + errors.join(' | '));
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
await this.page.evaluate(function(path) {
|
|
398
|
+
return hawkejs.scene.openUrl(path);
|
|
399
|
+
}, path);
|
|
400
|
+
|
|
401
|
+
let result = await this.page.evaluate(function() {
|
|
402
|
+
return {
|
|
403
|
+
location: document.location.pathname,
|
|
404
|
+
};
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
return result;
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Evaluate code in the browser page context
|
|
412
|
+
*
|
|
413
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
414
|
+
* @since 1.4.1
|
|
415
|
+
* @version 1.4.1
|
|
416
|
+
*
|
|
417
|
+
* @param {Function} fn
|
|
418
|
+
* @param {...*} args
|
|
419
|
+
*
|
|
420
|
+
* @return {Promise<*>}
|
|
421
|
+
*/
|
|
422
|
+
BrowserHelper.setMethod(function evaluate(fn, ...args) {
|
|
423
|
+
|
|
424
|
+
if (!this.page) {
|
|
425
|
+
throw new Error('Browser not loaded. Call load() or goto() first.');
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return this.page.evaluate(fn, ...args);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Get the full document HTML
|
|
433
|
+
*
|
|
434
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
435
|
+
* @since 1.4.1
|
|
436
|
+
* @version 1.4.1
|
|
437
|
+
*
|
|
438
|
+
* @return {Promise<string>}
|
|
439
|
+
*/
|
|
440
|
+
BrowserHelper.setMethod(async function getDocumentHtml() {
|
|
441
|
+
return this.evaluate(function() {
|
|
442
|
+
return document.documentElement.outerHTML;
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Get the body innerHTML
|
|
448
|
+
*
|
|
449
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
450
|
+
* @since 1.4.1
|
|
451
|
+
* @version 1.4.1
|
|
452
|
+
*
|
|
453
|
+
* @return {Promise<string>}
|
|
454
|
+
*/
|
|
455
|
+
BrowserHelper.setMethod(async function getBodyHtml() {
|
|
456
|
+
return this.evaluate(function() {
|
|
457
|
+
return document.body.innerHTML;
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Get an element handle (for file uploads, etc.)
|
|
463
|
+
*
|
|
464
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
465
|
+
* @since 1.4.1
|
|
466
|
+
* @version 1.4.1
|
|
467
|
+
*
|
|
468
|
+
* @param {string} selector
|
|
469
|
+
*
|
|
470
|
+
* @return {Promise<ElementHandle|null>}
|
|
471
|
+
*/
|
|
472
|
+
BrowserHelper.setMethod(function getElementHandle(selector) {
|
|
473
|
+
|
|
474
|
+
if (!this.page) {
|
|
475
|
+
throw new Error('Browser not loaded. Call load() or goto() first.');
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return this.page.$(selector);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Query an element and get its data
|
|
483
|
+
*
|
|
484
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
485
|
+
* @since 1.4.1
|
|
486
|
+
* @version 1.4.1
|
|
487
|
+
*
|
|
488
|
+
* @param {string} selector
|
|
489
|
+
*
|
|
490
|
+
* @return {Promise<Object|false>}
|
|
491
|
+
*/
|
|
492
|
+
BrowserHelper.setMethod(async function queryElement(selector) {
|
|
493
|
+
|
|
494
|
+
return this.evaluate(function(sel) {
|
|
495
|
+
let element = document.querySelector(sel);
|
|
496
|
+
|
|
497
|
+
if (!element) {
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return {
|
|
502
|
+
html: element.outerHTML,
|
|
503
|
+
text: element.textContent,
|
|
504
|
+
location: document.location.pathname,
|
|
505
|
+
scroll_top: document.scrollingElement.scrollTop,
|
|
506
|
+
};
|
|
507
|
+
}, selector);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Click an element
|
|
512
|
+
*
|
|
513
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
514
|
+
* @since 1.4.1
|
|
515
|
+
* @version 1.4.1
|
|
516
|
+
*
|
|
517
|
+
* @param {string} selector
|
|
518
|
+
*
|
|
519
|
+
* @return {Promise<boolean>}
|
|
520
|
+
*/
|
|
521
|
+
BrowserHelper.setMethod(async function click(selector) {
|
|
522
|
+
|
|
523
|
+
return this.evaluate(function(sel) {
|
|
524
|
+
let element = document.querySelector(sel);
|
|
525
|
+
|
|
526
|
+
if (!element) {
|
|
527
|
+
return false;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
element.click();
|
|
531
|
+
return true;
|
|
532
|
+
}, selector);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Check if an element exists in the DOM
|
|
537
|
+
*
|
|
538
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
539
|
+
* @since 1.4.1
|
|
540
|
+
* @version 1.4.1
|
|
541
|
+
*
|
|
542
|
+
* @param {string} selector
|
|
543
|
+
*
|
|
544
|
+
* @return {Promise<boolean>}
|
|
545
|
+
*/
|
|
546
|
+
BrowserHelper.setMethod(async function hasElement(selector) {
|
|
547
|
+
|
|
548
|
+
return this.evaluate(function(sel) {
|
|
549
|
+
return !!document.querySelector(sel);
|
|
550
|
+
}, selector);
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Get the current page URL pathname
|
|
555
|
+
*
|
|
556
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
557
|
+
* @since 1.4.1
|
|
558
|
+
* @version 1.4.1
|
|
559
|
+
*
|
|
560
|
+
* @return {Promise<string>}
|
|
561
|
+
*/
|
|
562
|
+
BrowserHelper.setMethod(async function getCurrentUrl() {
|
|
563
|
+
|
|
564
|
+
return this.evaluate(function() {
|
|
565
|
+
return window.location.pathname;
|
|
566
|
+
});
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Click an element and wait for navigation to complete
|
|
571
|
+
*
|
|
572
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
573
|
+
* @since 1.4.1
|
|
574
|
+
* @version 1.4.1
|
|
575
|
+
*
|
|
576
|
+
* @param {string} selector
|
|
577
|
+
* @param {Object|string} options Navigation options, or expected URL path
|
|
578
|
+
*
|
|
579
|
+
* @return {Promise<string>} The new URL pathname
|
|
580
|
+
*/
|
|
581
|
+
BrowserHelper.setMethod(async function clickAndWait(selector, options) {
|
|
582
|
+
|
|
583
|
+
if (!this.page) {
|
|
584
|
+
throw new Error('Browser not loaded. Call load() or goto() first.');
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
let expectedUrl = null;
|
|
588
|
+
let timeout = 5000;
|
|
589
|
+
|
|
590
|
+
// Allow passing just a URL string as second argument
|
|
591
|
+
if (typeof options === 'string') {
|
|
592
|
+
expectedUrl = options;
|
|
593
|
+
options = {};
|
|
594
|
+
} else if (options?.url) {
|
|
595
|
+
expectedUrl = options.url;
|
|
596
|
+
timeout = options.timeout || timeout;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Start navigation wait before clicking
|
|
600
|
+
const navigationPromise = this.page.waitForNavigation(options).catch(() => {
|
|
601
|
+
// Navigation might not happen (e.g., validation error)
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
const clicked = await this.click(selector);
|
|
605
|
+
|
|
606
|
+
if (clicked) {
|
|
607
|
+
await navigationPromise;
|
|
608
|
+
|
|
609
|
+
// If an expected URL was provided, wait for it
|
|
610
|
+
if (expectedUrl) {
|
|
611
|
+
await this.waitForUrl(expectedUrl, timeout);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
return this.getCurrentUrl();
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Wait for the URL to match an expected path
|
|
620
|
+
*
|
|
621
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
622
|
+
* @since 1.4.1
|
|
623
|
+
* @version 1.4.1
|
|
624
|
+
*
|
|
625
|
+
* @param {string} expectedUrl The expected pathname
|
|
626
|
+
* @param {number} timeout Max wait time in ms (default: 5000)
|
|
627
|
+
*
|
|
628
|
+
* @return {Promise<boolean>}
|
|
629
|
+
*/
|
|
630
|
+
BrowserHelper.setMethod(async function waitForUrl(expectedUrl, timeout = 5000) {
|
|
631
|
+
|
|
632
|
+
if (!this.page) {
|
|
633
|
+
throw new Error('Browser not loaded. Call load() or goto() first.');
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Use Puppeteer's waitForFunction - efficient, no manual polling
|
|
637
|
+
try {
|
|
638
|
+
await this.page.waitForFunction(
|
|
639
|
+
(expected) => window.location.pathname === expected,
|
|
640
|
+
{ timeout },
|
|
641
|
+
expectedUrl
|
|
642
|
+
);
|
|
643
|
+
return true;
|
|
644
|
+
} catch (err) {
|
|
645
|
+
return false;
|
|
646
|
+
}
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Set a form element's value
|
|
651
|
+
*
|
|
652
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
653
|
+
* @since 1.4.1
|
|
654
|
+
* @version 1.4.1
|
|
655
|
+
*
|
|
656
|
+
* @param {string} selector
|
|
657
|
+
* @param {*} value
|
|
658
|
+
*
|
|
659
|
+
* @return {Promise<Object|false>}
|
|
660
|
+
*/
|
|
661
|
+
BrowserHelper.setMethod(async function setValue(selector, value) {
|
|
662
|
+
|
|
663
|
+
return this.evaluate(function(sel, val) {
|
|
664
|
+
let element = document.querySelector(sel);
|
|
665
|
+
|
|
666
|
+
if (!element) {
|
|
667
|
+
return false;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
element.value = val;
|
|
671
|
+
|
|
672
|
+
return {
|
|
673
|
+
html: element.outerHTML,
|
|
674
|
+
text: element.textContent,
|
|
675
|
+
location: document.location.pathname,
|
|
676
|
+
scroll_top: document.scrollingElement.scrollTop,
|
|
677
|
+
value: element.value,
|
|
678
|
+
};
|
|
679
|
+
}, selector, value);
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Set element value or throw an error if not found/value doesn't match
|
|
684
|
+
*
|
|
685
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
686
|
+
* @since 1.4.1
|
|
687
|
+
* @version 1.4.1
|
|
688
|
+
*
|
|
689
|
+
* @param {string} selector
|
|
690
|
+
* @param {*} value
|
|
691
|
+
*
|
|
692
|
+
* @return {Promise<Object>}
|
|
693
|
+
*/
|
|
694
|
+
BrowserHelper.setMethod(async function setValueOrThrow(selector, value) {
|
|
695
|
+
|
|
696
|
+
let result = await this.setValue(selector, value);
|
|
697
|
+
|
|
698
|
+
if (!result) {
|
|
699
|
+
throw new Error('Failed to find the `' + selector + '` element, unable to set value to "' + value + '"');
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
if (result.value != value) {
|
|
703
|
+
throw new Error('The `' + selector + '` element has the value "' + result.value + '", but "' + value + '" was expected');
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
return result;
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Upload a file to a file input using a filesystem path
|
|
711
|
+
*
|
|
712
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
713
|
+
* @since 1.4.1
|
|
714
|
+
* @version 1.4.1
|
|
715
|
+
*
|
|
716
|
+
* @param {string} selector
|
|
717
|
+
* @param {string} file_path
|
|
718
|
+
*
|
|
719
|
+
* @return {Promise<boolean>}
|
|
720
|
+
*/
|
|
721
|
+
BrowserHelper.setMethod(async function uploadFile(selector, file_path) {
|
|
722
|
+
|
|
723
|
+
let handle = await this.getElementHandle(selector);
|
|
724
|
+
|
|
725
|
+
if (!handle) {
|
|
726
|
+
return false;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
return handle.uploadFile(file_path);
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Set a file input's value with a blob (in-memory content)
|
|
734
|
+
*
|
|
735
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
736
|
+
* @since 1.4.1
|
|
737
|
+
* @version 1.4.1
|
|
738
|
+
*
|
|
739
|
+
* @param {string} selector
|
|
740
|
+
* @param {string} content
|
|
741
|
+
* @param {string} filename Optional filename (default: 'test.txt')
|
|
742
|
+
* @param {string} mimetype Optional mimetype (default: 'text/plain')
|
|
743
|
+
*
|
|
744
|
+
* @return {Promise<Object|false>}
|
|
745
|
+
*/
|
|
746
|
+
BrowserHelper.setMethod(async function setFileBlob(selector, content, filename, mimetype) {
|
|
747
|
+
|
|
748
|
+
if (filename == null) {
|
|
749
|
+
filename = 'test.txt';
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
if (mimetype == null) {
|
|
753
|
+
mimetype = 'text/plain';
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
return this.evaluate(function(sel, content, filename, mimetype) {
|
|
757
|
+
let element = document.querySelector(sel);
|
|
758
|
+
|
|
759
|
+
if (!element) {
|
|
760
|
+
return false;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
element.files = [new File([new Blob([content], { type: mimetype })], filename, { type: mimetype })];
|
|
764
|
+
|
|
765
|
+
return {
|
|
766
|
+
html: element.outerHTML,
|
|
767
|
+
text: element.textContent,
|
|
768
|
+
location: document.location.pathname,
|
|
769
|
+
scroll_top: document.scrollingElement.scrollTop,
|
|
770
|
+
value: element.value,
|
|
771
|
+
};
|
|
772
|
+
}, selector, content, filename, mimetype);
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Wait for a specific amount of time
|
|
777
|
+
*
|
|
778
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
779
|
+
* @since 1.4.1
|
|
780
|
+
* @version 1.4.1
|
|
781
|
+
*
|
|
782
|
+
* @param {number} ms
|
|
783
|
+
*
|
|
784
|
+
* @return {Promise}
|
|
785
|
+
*/
|
|
786
|
+
BrowserHelper.setMethod(function wait(ms) {
|
|
787
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Wait for a selector to appear in the DOM
|
|
792
|
+
*
|
|
793
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
794
|
+
* @since 1.4.1
|
|
795
|
+
* @version 1.4.1
|
|
796
|
+
*
|
|
797
|
+
* @param {string} selector
|
|
798
|
+
* @param {Object} options Puppeteer waitForSelector options
|
|
799
|
+
*
|
|
800
|
+
* @return {Promise<ElementHandle|null>}
|
|
801
|
+
*/
|
|
802
|
+
BrowserHelper.setMethod(async function waitForSelector(selector, options) {
|
|
803
|
+
|
|
804
|
+
if (!this.page) {
|
|
805
|
+
throw new Error('Browser not loaded. Call load() or goto() first.');
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
return this.page.waitForSelector(selector, options);
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Wait for navigation to complete
|
|
813
|
+
*
|
|
814
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
815
|
+
* @since 1.4.1
|
|
816
|
+
* @version 1.4.1
|
|
817
|
+
*
|
|
818
|
+
* @param {Object} options Puppeteer waitForNavigation options
|
|
819
|
+
*
|
|
820
|
+
* @return {Promise}
|
|
821
|
+
*/
|
|
822
|
+
BrowserHelper.setMethod(async function waitForNavigation(options) {
|
|
823
|
+
|
|
824
|
+
if (!this.page) {
|
|
825
|
+
throw new Error('Browser not loaded. Call load() or goto() first.');
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
return this.page.waitForNavigation(options);
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Type text into an element
|
|
833
|
+
*
|
|
834
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
835
|
+
* @since 1.4.1
|
|
836
|
+
* @version 1.4.1
|
|
837
|
+
*
|
|
838
|
+
* @param {string} selector
|
|
839
|
+
* @param {string} text
|
|
840
|
+
* @param {Object} options Puppeteer type options
|
|
841
|
+
*
|
|
842
|
+
* @return {Promise}
|
|
843
|
+
*/
|
|
844
|
+
BrowserHelper.setMethod(async function type(selector, text, options) {
|
|
845
|
+
|
|
846
|
+
if (!this.page) {
|
|
847
|
+
throw new Error('Browser not loaded. Call load() or goto() first.');
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
return this.page.type(selector, text, options);
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* Take a screenshot
|
|
855
|
+
*
|
|
856
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
857
|
+
* @since 1.4.1
|
|
858
|
+
* @version 1.4.1
|
|
859
|
+
*
|
|
860
|
+
* @param {Object} options Puppeteer screenshot options
|
|
861
|
+
*
|
|
862
|
+
* @return {Promise<Buffer|string>}
|
|
863
|
+
*/
|
|
864
|
+
BrowserHelper.setMethod(async function screenshot(options) {
|
|
865
|
+
|
|
866
|
+
if (!this.page) {
|
|
867
|
+
throw new Error('Browser not loaded. Call load() or goto() first.');
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
return this.page.screenshot(options);
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Write collected coverage data to files for NYC/Istanbul
|
|
875
|
+
* This should be called before closing the browser if coverage is enabled
|
|
876
|
+
*
|
|
877
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
878
|
+
* @since 1.4.1
|
|
879
|
+
* @version 1.4.1
|
|
880
|
+
*
|
|
881
|
+
* @param {string} output_dir Directory to write files (default: './.nyc_output')
|
|
882
|
+
* @param {string} prefix Filename prefix (default: 'alchemy_')
|
|
883
|
+
*
|
|
884
|
+
* @return {Promise<number>} Number of coverage files written
|
|
885
|
+
*/
|
|
886
|
+
BrowserHelper.setMethod(async function writeCoverageFiles(output_dir, prefix) {
|
|
887
|
+
|
|
888
|
+
if (output_dir == null) {
|
|
889
|
+
output_dir = './.nyc_output';
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
if (prefix == null) {
|
|
893
|
+
prefix = 'alchemy_';
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// Fetch final coverage data
|
|
897
|
+
let coverages = await this.fetchCoverage();
|
|
898
|
+
|
|
899
|
+
if (!coverages || coverages.length === 0) {
|
|
900
|
+
return 0;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// Lazy load fs
|
|
904
|
+
let fs = require('fs');
|
|
905
|
+
|
|
906
|
+
// Write each coverage snapshot to a file
|
|
907
|
+
for (let i = 0; i < coverages.length; i++) {
|
|
908
|
+
let filepath = output_dir + '/' + prefix + i + '.json';
|
|
909
|
+
fs.writeFileSync(filepath, JSON.stringify(coverages[i]));
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
return coverages.length;
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* Wait for the Hawkejs scene to be ready.
|
|
917
|
+
* This is important when testing pages with custom elements or client-side rendering.
|
|
918
|
+
*
|
|
919
|
+
* The scene is ready when:
|
|
920
|
+
* - The hawkejs object exists
|
|
921
|
+
* - The scene has been initialized
|
|
922
|
+
* - The general_renderer is available
|
|
923
|
+
*
|
|
924
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
925
|
+
* @since 1.4.1
|
|
926
|
+
* @version 1.4.1
|
|
927
|
+
*
|
|
928
|
+
* @param {number} timeout Maximum time to wait in ms (default: 5000)
|
|
929
|
+
*
|
|
930
|
+
* @return {Promise<boolean>}
|
|
931
|
+
*/
|
|
932
|
+
BrowserHelper.setMethod(async function waitForSceneReady(timeout = 5000) {
|
|
933
|
+
|
|
934
|
+
if (!this.page) {
|
|
935
|
+
throw new Error('Browser not loaded. Call load() or goto() first.');
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
return this.evaluate((timeout) => {
|
|
939
|
+
return new Promise((resolve) => {
|
|
940
|
+
|
|
941
|
+
const isReady = () => {
|
|
942
|
+
return typeof hawkejs !== 'undefined' &&
|
|
943
|
+
hawkejs.scene &&
|
|
944
|
+
hawkejs.scene.general_renderer;
|
|
945
|
+
};
|
|
946
|
+
|
|
947
|
+
if (isReady()) {
|
|
948
|
+
return resolve(true);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
const start = Date.now();
|
|
952
|
+
|
|
953
|
+
const check = () => {
|
|
954
|
+
if (isReady()) {
|
|
955
|
+
return resolve(true);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
if (Date.now() - start > timeout) {
|
|
959
|
+
return resolve(false);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
setTimeout(check, 50);
|
|
963
|
+
};
|
|
964
|
+
|
|
965
|
+
check();
|
|
966
|
+
});
|
|
967
|
+
}, timeout);
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* Wait for an element to appear in the DOM using MutationObserver.
|
|
972
|
+
* This is the proper way to wait for Alchemy custom elements that render asynchronously.
|
|
973
|
+
*
|
|
974
|
+
* Unlike Puppeteer's waitForSelector, this:
|
|
975
|
+
* - Uses MutationObserver (efficient, no polling)
|
|
976
|
+
* - Returns element info, not an ElementHandle
|
|
977
|
+
* - Supports multiple selectors with "any" mode
|
|
978
|
+
*
|
|
979
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
980
|
+
* @since 1.4.1
|
|
981
|
+
* @version 1.4.1
|
|
982
|
+
*
|
|
983
|
+
* @param {string|string[]} selector CSS selector(s) to wait for
|
|
984
|
+
* @param {Object} options
|
|
985
|
+
* @param {number} options.timeout Max wait time in ms (default: 5000)
|
|
986
|
+
* @param {boolean} options.visible Wait for element to be visible (default: false)
|
|
987
|
+
*
|
|
988
|
+
* @return {Promise<Object|false>} Element info or false if timeout
|
|
989
|
+
*/
|
|
990
|
+
BrowserHelper.setMethod(async function waitForElement(selector, options = {}) {
|
|
991
|
+
|
|
992
|
+
const timeout = options.timeout || 5000;
|
|
993
|
+
const visible = options.visible || false;
|
|
994
|
+
|
|
995
|
+
if (!this.page) {
|
|
996
|
+
throw new Error('Browser not loaded. Call load() or goto() first.');
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
const selectors = Array.isArray(selector) ? selector : [selector];
|
|
1000
|
+
|
|
1001
|
+
return this.evaluate((selectors, timeout, visible) => {
|
|
1002
|
+
return new Promise((resolve) => {
|
|
1003
|
+
|
|
1004
|
+
const isVisible = (el) => {
|
|
1005
|
+
if (!visible) return true;
|
|
1006
|
+
const rect = el.getBoundingClientRect();
|
|
1007
|
+
return rect.width > 0 && rect.height > 0;
|
|
1008
|
+
};
|
|
1009
|
+
|
|
1010
|
+
const findElement = () => {
|
|
1011
|
+
for (let selector of selectors) {
|
|
1012
|
+
const el = document.querySelector(selector);
|
|
1013
|
+
if (el && isVisible(el)) {
|
|
1014
|
+
return el;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
return null;
|
|
1018
|
+
};
|
|
1019
|
+
|
|
1020
|
+
const createResult = (el) => ({
|
|
1021
|
+
found: true,
|
|
1022
|
+
selector: selectors.find(s => document.querySelector(s)),
|
|
1023
|
+
tagName: el.tagName.toLowerCase(),
|
|
1024
|
+
text: el.textContent
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
const found = findElement();
|
|
1028
|
+
if (found) {
|
|
1029
|
+
return resolve(createResult(found));
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
const observer = new MutationObserver(() => {
|
|
1033
|
+
const el = findElement();
|
|
1034
|
+
if (el) {
|
|
1035
|
+
observer.disconnect();
|
|
1036
|
+
resolve(createResult(el));
|
|
1037
|
+
}
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
observer.observe(document.body, {
|
|
1041
|
+
childList: true,
|
|
1042
|
+
subtree: true,
|
|
1043
|
+
attributes: visible
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
setTimeout(() => {
|
|
1047
|
+
observer.disconnect();
|
|
1048
|
+
const el = findElement();
|
|
1049
|
+
resolve(el ? createResult(el) : false);
|
|
1050
|
+
}, timeout);
|
|
1051
|
+
});
|
|
1052
|
+
}, selectors, timeout, visible);
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* Wait for all pending AJAX/fetch requests to complete.
|
|
1057
|
+
* This is useful after clicking buttons that trigger API calls.
|
|
1058
|
+
*
|
|
1059
|
+
* Uses the browser's performance API to track pending requests.
|
|
1060
|
+
*
|
|
1061
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
1062
|
+
* @since 1.4.1
|
|
1063
|
+
* @version 1.4.1
|
|
1064
|
+
*
|
|
1065
|
+
* @param {number} timeout Max wait time in ms (default: 5000)
|
|
1066
|
+
* @param {number} settle_time Time with no requests to consider settled (default: 200)
|
|
1067
|
+
*
|
|
1068
|
+
* @return {Promise<boolean>}
|
|
1069
|
+
*/
|
|
1070
|
+
BrowserHelper.setMethod(async function waitForAjaxComplete(timeout = 5000, settle_time = 200) {
|
|
1071
|
+
|
|
1072
|
+
if (!this.page) {
|
|
1073
|
+
throw new Error('Browser not loaded. Call load() or goto() first.');
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
return this.evaluate((timeout, settle_time) => {
|
|
1077
|
+
return new Promise((resolve) => {
|
|
1078
|
+
const start = Date.now();
|
|
1079
|
+
let lastActivity = Date.now();
|
|
1080
|
+
|
|
1081
|
+
const checkSettled = () => {
|
|
1082
|
+
const now = Date.now();
|
|
1083
|
+
|
|
1084
|
+
const entries = performance.getEntriesByType('resource');
|
|
1085
|
+
const pending = entries.filter(e => {
|
|
1086
|
+
return e.startTime > (performance.now() - settle_time) &&
|
|
1087
|
+
(e.initiatorType === 'fetch' || e.initiatorType === 'xmlhttprequest');
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
if (pending.length > 0) {
|
|
1091
|
+
lastActivity = now;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
if (now - lastActivity >= settle_time) {
|
|
1095
|
+
return resolve(true);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
if (now - start > timeout) {
|
|
1099
|
+
return resolve(false);
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
setTimeout(checkSettled, 50);
|
|
1103
|
+
};
|
|
1104
|
+
|
|
1105
|
+
checkSettled();
|
|
1106
|
+
});
|
|
1107
|
+
}, timeout, settle_time);
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* Wait for client-side navigation to complete.
|
|
1112
|
+
* This handles both full page navigations and Hawkejs SPA-style navigations.
|
|
1113
|
+
*
|
|
1114
|
+
* For Hawkejs navigations, it waits for:
|
|
1115
|
+
* - The scene to finish rendering
|
|
1116
|
+
* - Any pending AJAX requests to settle
|
|
1117
|
+
*
|
|
1118
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
1119
|
+
* @since 1.4.1
|
|
1120
|
+
* @version 1.4.1
|
|
1121
|
+
*
|
|
1122
|
+
* @param {number} timeout Max wait time in ms (default: 5000)
|
|
1123
|
+
*
|
|
1124
|
+
* @return {Promise<boolean>}
|
|
1125
|
+
*/
|
|
1126
|
+
BrowserHelper.setMethod(async function waitForClientNavigation(timeout = 5000) {
|
|
1127
|
+
|
|
1128
|
+
if (!this.page) {
|
|
1129
|
+
throw new Error('Browser not loaded. Call load() or goto() first.');
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
return this.evaluate((timeout) => {
|
|
1133
|
+
return new Promise((resolve) => {
|
|
1134
|
+
const start = Date.now();
|
|
1135
|
+
|
|
1136
|
+
const checkReady = () => {
|
|
1137
|
+
const now = Date.now();
|
|
1138
|
+
|
|
1139
|
+
if (now - start > timeout) {
|
|
1140
|
+
return resolve(false);
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
if (typeof hawkejs !== 'undefined' && hawkejs.scene) {
|
|
1144
|
+
const renderer = hawkejs.scene.general_renderer;
|
|
1145
|
+
|
|
1146
|
+
if (renderer && !renderer.rendering && document.readyState === 'complete') {
|
|
1147
|
+
setTimeout(() => resolve(true), 100);
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
} else if (document.readyState === 'complete') {
|
|
1151
|
+
setTimeout(() => resolve(true), 100);
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
setTimeout(checkReady, 50);
|
|
1156
|
+
};
|
|
1157
|
+
|
|
1158
|
+
checkReady();
|
|
1159
|
+
});
|
|
1160
|
+
}, timeout);
|
|
1161
|
+
});
|
|
1162
|
+
|
|
1163
|
+
// Export the BrowserHelper class
|
|
1164
|
+
module.exports = BrowserHelper;
|