alchemymvc 1.4.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/lib/app/behaviour/revision_behaviour.js +1 -1
  2. package/lib/app/behaviour/sluggable_behaviour.js +2 -2
  3. package/lib/app/datasource/mongo_datasource.js +19 -3
  4. package/lib/app/helper/cron.js +2 -2
  5. package/lib/app/helper_datasource/00-nosql_datasource.js +9 -3
  6. package/lib/app/helper_datasource/05-fallback_datasource.js +2 -0
  7. package/lib/app/helper_datasource/idb_datasource.js +7 -5
  8. package/lib/app/helper_datasource/remote_datasource.js +1 -1
  9. package/lib/app/helper_field/password_field.js +4 -2
  10. package/lib/app/helper_field/schema_field.js +3 -2
  11. package/lib/app/helper_model/00-base_criteria.js +14 -0
  12. package/lib/app/helper_model/05-criteria_expressions.js +30 -7
  13. package/lib/app/helper_model/10-model_criteria.js +47 -8
  14. package/lib/app/helper_model/document.js +11 -2
  15. package/lib/app/helper_model/model.js +6 -3
  16. package/lib/class/conduit.js +5 -2
  17. package/lib/class/controller.js +1 -0
  18. package/lib/class/document.js +39 -11
  19. package/lib/class/import_stream_parser.js +299 -0
  20. package/lib/class/migration.js +5 -2
  21. package/lib/class/model.js +10 -140
  22. package/lib/class/plugin.js +32 -3
  23. package/lib/class/router.js +24 -27
  24. package/lib/class/schema_client.js +38 -7
  25. package/lib/class/sitemap.js +2 -2
  26. package/lib/core/alchemy.js +110 -162
  27. package/lib/core/alchemy_load_functions.js +64 -5
  28. package/lib/core/base.js +2 -2
  29. package/lib/core/middleware.js +31 -5
  30. package/lib/core/setting.js +12 -9
  31. package/lib/scripts/create_constants.js +5 -1
  32. package/lib/stages/00-load_core.js +8 -2
  33. package/lib/testing/browser.js +1164 -0
  34. package/lib/testing/harness.js +840 -0
  35. package/package.json +10 -3
  36. package/testing/browser.js +27 -0
  37. 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;