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.
Files changed (44) 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 +10 -13
  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_field/time_field.js +1 -1
  12. package/lib/app/helper_model/00-base_criteria.js +14 -0
  13. package/lib/app/helper_model/05-criteria_expressions.js +30 -7
  14. package/lib/app/helper_model/10-model_criteria.js +47 -8
  15. package/lib/app/helper_model/document.js +11 -2
  16. package/lib/app/helper_model/model.js +6 -3
  17. package/lib/app/model/system_task_history_model.js +134 -0
  18. package/lib/class/conduit.js +5 -2
  19. package/lib/class/controller.js +1 -0
  20. package/lib/class/datasource.js +14 -2
  21. package/lib/class/document.js +40 -12
  22. package/lib/class/import_stream_parser.js +299 -0
  23. package/lib/class/inode_file.js +2 -0
  24. package/lib/class/migration.js +5 -2
  25. package/lib/class/model.js +12 -142
  26. package/lib/class/plugin.js +32 -3
  27. package/lib/class/postponement.js +1 -1
  28. package/lib/class/router.js +26 -28
  29. package/lib/class/schema_client.js +39 -8
  30. package/lib/class/sitemap.js +2 -2
  31. package/lib/class/task.js +42 -24
  32. package/lib/core/alchemy.js +110 -162
  33. package/lib/core/alchemy_load_functions.js +64 -5
  34. package/lib/core/base.js +2 -2
  35. package/lib/core/middleware.js +31 -5
  36. package/lib/core/prefix.js +1 -1
  37. package/lib/core/setting.js +12 -9
  38. package/lib/scripts/create_constants.js +5 -1
  39. package/lib/stages/00-load_core.js +8 -2
  40. package/lib/testing/browser.js +1164 -0
  41. package/lib/testing/harness.js +922 -0
  42. package/package.json +13 -6
  43. package/testing/browser.js +27 -0
  44. package/testing.js +37 -0
@@ -0,0 +1,922 @@
1
+ /**
2
+ * Test Harness for Alchemy projects
3
+ *
4
+ * This module provides a reusable test harness for AlchemyMVC applications and plugins.
5
+ * It handles all the boilerplate setup including:
6
+ * - Environment variables
7
+ * - MongoDB in-memory database (mongo-unit)
8
+ * - Alchemy server startup
9
+ * - Plugin loading
10
+ * - Cleanup/teardown
11
+ *
12
+ * Usage:
13
+ * const TestHarness = require('alchemymvc/testing');
14
+ * const harness = new TestHarness({ path_root: __dirname });
15
+ * await harness.start();
16
+ * // ... run tests ...
17
+ * await harness.stop();
18
+ *
19
+ * @author Jelle De Loecker <jelle@elevenways.be>
20
+ * @since 1.4.1
21
+ * @version 1.4.1
22
+ */
23
+ 'use strict';
24
+
25
+ const libpath = require('path');
26
+
27
+ /**
28
+ * The TestHarness class
29
+ *
30
+ * @author Jelle De Loecker <jelle@elevenways.be>
31
+ * @since 1.4.1
32
+ * @version 1.4.1
33
+ *
34
+ * @param {Object} options
35
+ */
36
+ const TestHarness = Function.inherits('Informer', 'Alchemy.Testing', function TestHarness(options) {
37
+
38
+ TestHarness.super.call(this);
39
+
40
+ if (options == null) {
41
+ options = {};
42
+ }
43
+
44
+ // Store options with defaults
45
+ this.options = Object.assign({
46
+ // Required: path to the project root (where app/ is)
47
+ path_root: null,
48
+
49
+ // Environment to use (default: 'test')
50
+ environment: 'test',
51
+
52
+ // Skip loading local.js config (default: true for tests)
53
+ skip_local_config: true,
54
+
55
+ // Use mongo-unit for in-memory MongoDB (default: true)
56
+ use_mongo_unit: true,
57
+
58
+ // Extra options forwarded to mongo-unit's start() (e.g. `version`
59
+ // to pin the mongod binary, or `dbpath` to point at a tmpfs dir).
60
+ // `storageEngine` defaults to 'wiredTiger' in startMongo - see why
61
+ // there.
62
+ mongo_unit_options: {},
63
+
64
+ // Server port (default: random between 3470-3570)
65
+ port: 3470 + Math.floor(Math.random() * 100),
66
+
67
+ // Silent mode - suppress Alchemy logs (default: true)
68
+ silent: true,
69
+
70
+ // Plugins to load: { name: options }
71
+ plugins: {},
72
+
73
+ // Extra stage tasks to run
74
+ stage_tasks: {},
75
+
76
+ // Disable request postponing on overload (default: true for tests)
77
+ disable_postpone: true,
78
+
79
+ }, options);
80
+
81
+ // Validate required options
82
+ if (!this.options.path_root) {
83
+ throw new Error('TestHarness requires path_root option');
84
+ }
85
+
86
+ // State tracking
87
+ this._mongo_uri = null;
88
+ this._mongo_unit = null;
89
+ this._started = false;
90
+ this._alchemy_required = false;
91
+
92
+ // Store reference to Puppeteer browser if used
93
+ this.browser = null;
94
+ this.page = null;
95
+ });
96
+
97
+ /**
98
+ * Get the MongoUnit module (lazy loaded)
99
+ *
100
+ * @author Jelle De Loecker <jelle@elevenways.be>
101
+ * @since 1.4.1
102
+ * @version 1.4.1
103
+ *
104
+ * @return {Object}
105
+ */
106
+ TestHarness.setMethod(function getMongoUnit() {
107
+
108
+ if (!this._mongo_unit) {
109
+ try {
110
+ this._mongo_unit = require('mongo-unit');
111
+ } catch (err) {
112
+ throw new Error('mongo-unit is required for testing. Install it with: npm install --save-dev mongo-unit');
113
+ }
114
+ }
115
+
116
+ return this._mongo_unit;
117
+ });
118
+
119
+ /**
120
+ * Resolve a plugin path from node_modules
121
+ *
122
+ * This method mimics the logic in alchemy.usePlugin() to find plugins.
123
+ * It searches in multiple locations and handles the "alchemy-" prefix.
124
+ *
125
+ * @author Jelle De Loecker <jelle@elevenways.be>
126
+ * @since 1.4.1
127
+ * @version 1.4.1
128
+ *
129
+ * @param {string} plugin_name
130
+ *
131
+ * @return {string}
132
+ */
133
+ TestHarness.setMethod(function getPluginPath(plugin_name) {
134
+
135
+ // If already an absolute path, return as-is
136
+ if (libpath.isAbsolute(plugin_name)) {
137
+ return plugin_name;
138
+ }
139
+
140
+ // Determine names to try (with and without alchemy- prefix)
141
+ let names_to_try;
142
+ if (plugin_name.startsWith('alchemy-')) {
143
+ names_to_try = [plugin_name, plugin_name.slice(8)];
144
+ } else {
145
+ names_to_try = ['alchemy-' + plugin_name, plugin_name];
146
+ }
147
+
148
+ // Build search paths: path_root and its parent directories
149
+ // This handles the case where tests run from test_root inside a plugin
150
+ let search_paths = [this.options.path_root];
151
+ let current = this.options.path_root;
152
+
153
+ for (let i = 0; i < 5; i++) {
154
+ let parent = libpath.dirname(current);
155
+ if (parent === current) break;
156
+ search_paths.push(parent);
157
+ current = parent;
158
+ }
159
+
160
+ // Use require.resolve - Node's optimized module resolution
161
+ // It handles symlinks, node_modules lookup, and caching
162
+ for (let name of names_to_try) {
163
+ try {
164
+ let resolved = require.resolve(name + '/package.json', { paths: search_paths });
165
+ return libpath.dirname(resolved);
166
+ } catch (err) {
167
+ // Not found, try next name
168
+ }
169
+ }
170
+
171
+ throw new Error(`Could not find plugin "${plugin_name}" in search paths starting from: ${this.options.path_root}`);
172
+ });
173
+
174
+ /**
175
+ * Set up environment variables
176
+ * This MUST be called before requiring alchemymvc
177
+ *
178
+ * @author Jelle De Loecker <jelle@elevenways.be>
179
+ * @since 1.4.1
180
+ * @version 1.4.1
181
+ */
182
+ TestHarness.setMethod(function setupEnvironment() {
183
+
184
+ // Disable Janeway terminal UI
185
+ process.env.DISABLE_JANEWAY = '1';
186
+
187
+ // Suppress "file not found" warnings during loading
188
+ // This helps keep test output clean, but actual code errors in files
189
+ // will still be surfaced because the load stage handles ENOENT separately.
190
+ process.env.NO_ALCHEMY_LOAD_WARNING = '1';
191
+
192
+ // Set PATH_ROOT before requiring alchemymvc
193
+ process.env.PATH_ROOT = this.options.path_root;
194
+
195
+ // Skip local.js if requested
196
+ if (this.options.skip_local_config) {
197
+ process.env.ALCHEMY_SKIP_LOCAL_CONFIG = '1';
198
+ }
199
+
200
+ // Set environment
201
+ if (this.options.environment) {
202
+ process.env.ALCHEMY_ENV = this.options.environment;
203
+ }
204
+ });
205
+
206
+ /**
207
+ * Require AlchemyMVC (only once)
208
+ *
209
+ * @author Jelle De Loecker <jelle@elevenways.be>
210
+ * @since 1.4.1
211
+ * @version 1.4.1
212
+ */
213
+ TestHarness.setMethod(function requireAlchemy() {
214
+
215
+ if (this._alchemy_required) {
216
+ return this._alchemy_required;
217
+ }
218
+
219
+ let pledge = this._alchemy_required = new Pledge();
220
+
221
+ // Set up environment BEFORE requiring
222
+ this.setupEnvironment();
223
+
224
+ // Require alchemymvc - use the bootstrap from the same package
225
+ const ROOT_STAGE = require('../bootstrap.js');
226
+
227
+ ROOT_STAGE.getStage('load_core').addPostTask(() => pledge.resolve());
228
+
229
+ return pledge;
230
+ });
231
+
232
+ /**
233
+ * Start the in-memory MongoDB
234
+ *
235
+ * @author Jelle De Loecker <jelle@elevenways.be>
236
+ * @since 1.4.1
237
+ * @version 1.4.1
238
+ *
239
+ * @return {Promise<string>} MongoDB URI
240
+ */
241
+ TestHarness.setMethod(async function startMongo() {
242
+
243
+ if (!this.options.use_mongo_unit) {
244
+ return null;
245
+ }
246
+
247
+ if (this._mongo_uri) {
248
+ return this._mongo_uri;
249
+ }
250
+
251
+ let MongoUnit = this.getMongoUnit();
252
+
253
+ // mongo-unit defaults its standalone storage engine to
254
+ // `ephemeralForTest`, which was removed in MongoDB 6.0 (and
255
+ // `inMemory` is Enterprise-only). Force `wiredTiger` so a modern
256
+ // mongod binary actually boots. Callers can still override it (or
257
+ // pin `version` / set `dbpath`) via `mongo_unit_options`.
258
+ let mongo_opts = Object.assign({
259
+ verbose : false,
260
+ storageEngine : 'wiredTiger',
261
+ }, this.options.mongo_unit_options);
262
+
263
+ this._mongo_uri = await MongoUnit.start(mongo_opts);
264
+
265
+ if (!this._mongo_uri) {
266
+ throw new Error('Failed to start mongo-unit (no URI returned)');
267
+ }
268
+
269
+ return this._mongo_uri;
270
+ });
271
+
272
+ /**
273
+ * Configure and start the Alchemy server
274
+ *
275
+ * @author Jelle De Loecker <jelle@elevenways.be>
276
+ * @since 1.4.1
277
+ * @version 1.4.1
278
+ *
279
+ * @return {Promise}
280
+ */
281
+ TestHarness.setMethod(function startServer() {
282
+
283
+ return new Promise((resolve, reject) => {
284
+
285
+ // Configure network settings
286
+ alchemy.setSetting('network.port', this.options.port);
287
+
288
+ // Disable request postponing for tests
289
+ if (this.options.disable_postpone) {
290
+ alchemy.setSetting('performance.postpone_requests_on_overload', false);
291
+ }
292
+
293
+ // Configure datasource if using mongo-unit
294
+ if (this._mongo_uri) {
295
+ STAGES.getStage('datasource').addPostTask(() => {
296
+ Datasource.create('mongo', 'default', { uri: this._mongo_uri });
297
+ });
298
+ } else if (this.options.use_mongo_unit) {
299
+ // mongo-unit was requested but never produced a URI (startMongo
300
+ // failed or wasn't called). Refuse to start - otherwise the app
301
+ // silently falls back to whatever default datasource is
302
+ // configured, which can be a REAL database. That fallback is how
303
+ // a broken in-memory mongo ends up polluting (and reading stale
304
+ // data from) a live dev DB. Fail loud instead.
305
+ return reject(new Error('TestHarness: use_mongo_unit is enabled but no mongo URI is available '
306
+ + '(did startMongo() fail?). Refusing to start the server to avoid connecting to a non-test database.'));
307
+ }
308
+
309
+ // Register additional module search paths
310
+ // This allows plugins to find their own dependencies during tests
311
+ if (this.options.extra_module_paths) {
312
+ for (let extra_path of this.options.extra_module_paths) {
313
+ if (!libpath.isAbsolute(extra_path)) {
314
+ extra_path = libpath.resolve(this.options.path_root, extra_path);
315
+ }
316
+ alchemy.addModuleSearchPath(extra_path);
317
+ }
318
+ }
319
+
320
+ // Load plugins
321
+ for (let [name, options] of Object.entries(this.options.plugins)) {
322
+
323
+ let plugin_options = { ...options };
324
+
325
+ // Resolve path if not already absolute
326
+ if (!plugin_options.path_to_plugin) {
327
+ plugin_options.path_to_plugin = this.getPluginPath(name);
328
+ } else if (!libpath.isAbsolute(plugin_options.path_to_plugin)) {
329
+ plugin_options.path_to_plugin = libpath.resolve(
330
+ this.options.path_root,
331
+ plugin_options.path_to_plugin
332
+ );
333
+ }
334
+
335
+ // Add the plugin's node_modules to the module search paths
336
+ // This allows dependencies of the plugin to be found during tests
337
+ let plugin_node_modules = libpath.resolve(plugin_options.path_to_plugin, 'node_modules');
338
+ alchemy.addModuleSearchPath(plugin_node_modules);
339
+
340
+ alchemy.usePlugin(name, plugin_options);
341
+ }
342
+
343
+ // Add custom stage tasks
344
+ for (let [stage_name, tasks] of Object.entries(this.options.stage_tasks)) {
345
+ let stage = STAGES.getStage(stage_name);
346
+
347
+ if (!stage) {
348
+ console.warn(`TestHarness: Stage '${stage_name}' not found`);
349
+ continue;
350
+ }
351
+
352
+ if (tasks.pre) {
353
+ for (let task of Array.isArray(tasks.pre) ? tasks.pre : [tasks.pre]) {
354
+ stage.addPreTask(task);
355
+ }
356
+ }
357
+
358
+ if (tasks.post) {
359
+ for (let task of Array.isArray(tasks.post) ? tasks.post : [tasks.post]) {
360
+ stage.addPostTask(task);
361
+ }
362
+ }
363
+ }
364
+
365
+ // Start the server
366
+ alchemy.start({ silent: this.options.silent }, (err) => {
367
+ if (err) {
368
+ reject(err);
369
+ } else {
370
+ this._started = true;
371
+ resolve();
372
+ }
373
+ });
374
+ });
375
+ });
376
+
377
+ /**
378
+ * Start everything: mongo-unit, require alchemy, start server
379
+ * This is the main entry point for setting up tests
380
+ *
381
+ * @author Jelle De Loecker <jelle@elevenways.be>
382
+ * @since 1.4.1
383
+ * @version 1.4.1
384
+ *
385
+ * @return {Promise}
386
+ */
387
+ TestHarness.setMethod(async function start() {
388
+
389
+ // Start mongo first (if enabled)
390
+ await this.startMongo();
391
+
392
+ // Require Alchemy (sets up environment vars first)
393
+ await this.requireAlchemy();
394
+
395
+ // Start the server
396
+ await this.startServer();
397
+
398
+ return this;
399
+ });
400
+
401
+ /**
402
+ * Stop everything: mongo-unit, alchemy server, browser
403
+ *
404
+ * @author Jelle De Loecker <jelle@elevenways.be>
405
+ * @since 1.4.1
406
+ * @version 1.4.1
407
+ *
408
+ * @return {Promise}
409
+ */
410
+ TestHarness.setMethod(async function stop() {
411
+
412
+ // Close browser if open
413
+ if (this.browser) {
414
+ await this.browser.close();
415
+ this.browser = null;
416
+ this.page = null;
417
+ }
418
+
419
+ // Stop mongo-unit
420
+ if (this._mongo_unit && this._mongo_uri) {
421
+
422
+ // mongodb-memory-server kills a standalone mongod with SIGINT (then
423
+ // SIGKILL) rather than a clean admin shutdown. Under some mongod
424
+ // builds that triggers a coredump on the way down. Ask mongod to
425
+ // shut down cleanly first, so the subsequent signal hits an already
426
+ // gone process and short-circuits.
427
+ await this._gracefullyShutdownMongo(this._mongo_uri);
428
+
429
+ await this._mongo_unit.stop();
430
+ this._mongo_uri = null;
431
+ }
432
+
433
+ // Stop alchemy
434
+ if (this._started && typeof alchemy !== 'undefined') {
435
+ alchemy.stop();
436
+ this._started = false;
437
+ }
438
+ });
439
+
440
+ /**
441
+ * Ask a mongod to shut down cleanly via the admin command, so the
442
+ * subsequent signal-based kill from mongodb-memory-server hits an
443
+ * already-gone process. Best-effort: any failure is swallowed and we
444
+ * fall through to the normal stop path.
445
+ *
446
+ * @author Jelle De Loecker <jelle@elevenways.be>
447
+ * @since 1.4.2
448
+ *
449
+ * @param {string} uri
450
+ *
451
+ * @return {Promise}
452
+ */
453
+ TestHarness.setMethod(async function _gracefullyShutdownMongo(uri) {
454
+
455
+ if (!uri) {
456
+ return;
457
+ }
458
+
459
+ let MongoClient;
460
+
461
+ try {
462
+ MongoClient = require('mongodb').MongoClient;
463
+ } catch (err) {
464
+ // No mongodb driver available - nothing we can do, let the
465
+ // normal stop path handle it.
466
+ return;
467
+ }
468
+
469
+ let client;
470
+
471
+ try {
472
+ client = await MongoClient.connect(uri, { directConnection: true });
473
+ try {
474
+ await client.db('admin').command({ shutdown: 1, force: true, timeoutSecs: 1 });
475
+ } catch (err) {
476
+ // mongod closes the connection mid-command while shutting down,
477
+ // surfacing as a network error. That means it worked - swallow.
478
+ }
479
+ } catch (err) {
480
+ // Couldn't reach mongod - fall through to the signal-based stop.
481
+ } finally {
482
+ if (client) {
483
+ try { await client.close(true); } catch (_) {}
484
+ }
485
+ }
486
+ });
487
+
488
+ /**
489
+ * Get a full URL for a path
490
+ *
491
+ * @author Jelle De Loecker <jelle@elevenways.be>
492
+ * @since 1.4.1
493
+ * @version 1.4.1
494
+ *
495
+ * @param {string} path
496
+ *
497
+ * @return {string}
498
+ */
499
+ TestHarness.setMethod(function getUrl(path) {
500
+
501
+ if (!path.startsWith('/')) {
502
+ path = '/' + path;
503
+ }
504
+
505
+ return 'http://localhost:' + this.options.port + path;
506
+ });
507
+
508
+ /**
509
+ * Get a URL for a named route
510
+ *
511
+ * @author Jelle De Loecker <jelle@elevenways.be>
512
+ * @since 1.4.1
513
+ * @version 1.4.1
514
+ *
515
+ * @param {string} route_name
516
+ * @param {Object} params
517
+ *
518
+ * @return {string}
519
+ */
520
+ TestHarness.setMethod(function getRouteUrl(route_name, params) {
521
+
522
+ let url = Router.getUrl(route_name, params);
523
+
524
+ url.host = 'localhost';
525
+ url.protocol = 'http';
526
+ url.port = this.options.port;
527
+
528
+ return String(url);
529
+ });
530
+
531
+ /**
532
+ * Make an HTTP request using Blast.fetch
533
+ *
534
+ * By default on the server, Blast.fetch doesn't undry JSON-dry responses.
535
+ * This method sets `allow_json_dry_response: true` to mimic browser behavior,
536
+ * since Alchemy sends `application/json-dry` responses by default.
537
+ *
538
+ * @author Jelle De Loecker <jelle@elevenways.be>
539
+ * @since 1.4.1
540
+ * @version 1.4.1
541
+ *
542
+ * @param {string} path_or_url
543
+ * @param {Object} options
544
+ *
545
+ * @return {Promise<{response: Object, body: *}>}
546
+ */
547
+ TestHarness.setMethod(function fetch(path_or_url, options) {
548
+
549
+ if (options == null) {
550
+ options = {};
551
+ }
552
+
553
+ // Enable JSON-dry response handling (like browser does)
554
+ // This makes the test harness behave like a browser client
555
+ if (options.allow_json_dry_response == null) {
556
+ options.allow_json_dry_response = true;
557
+ }
558
+
559
+ return new Promise((resolve, reject) => {
560
+
561
+ let url;
562
+
563
+ if (path_or_url.startsWith('http')) {
564
+ url = path_or_url;
565
+ } else {
566
+ url = this.getUrl(path_or_url);
567
+ }
568
+
569
+ Blast.fetch(url, options, (err, res, body) => {
570
+ if (err) {
571
+ reject(err);
572
+ } else {
573
+ resolve({ response: res, body });
574
+ }
575
+ });
576
+ });
577
+ });
578
+
579
+ /**
580
+ * Make an HTTP request to a named route
581
+ *
582
+ * @author Jelle De Loecker <jelle@elevenways.be>
583
+ * @since 1.4.1
584
+ * @version 1.4.1
585
+ *
586
+ * @param {string} route_name
587
+ * @param {Object} route_params
588
+ * @param {Object} fetch_options
589
+ *
590
+ * @return {Promise<{response: Object, body: string}>}
591
+ */
592
+ TestHarness.setMethod(function fetchRoute(route_name, route_params, fetch_options) {
593
+
594
+ if (route_params == null) {
595
+ route_params = {};
596
+ }
597
+
598
+ if (fetch_options == null) {
599
+ fetch_options = {};
600
+ }
601
+
602
+ let url = this.getRouteUrl(route_name, route_params);
603
+ return this.fetch(url, fetch_options);
604
+ });
605
+
606
+ /**
607
+ * Load Puppeteer browser for UI testing
608
+ *
609
+ * @author Jelle De Loecker <jelle@elevenways.be>
610
+ * @since 1.4.1
611
+ * @version 1.4.1
612
+ *
613
+ * @param {Object} options
614
+ *
615
+ * @return {Promise<{browser: Object, page: Object}>}
616
+ */
617
+ TestHarness.setMethod(async function loadBrowser(options) {
618
+
619
+ if (options == null) {
620
+ options = {};
621
+ }
622
+
623
+ if (this.browser) {
624
+ return { browser: this.browser, page: this.page };
625
+ }
626
+
627
+ let puppeteer;
628
+
629
+ try {
630
+ puppeteer = require('puppeteer');
631
+ } catch (err) {
632
+ throw new Error('puppeteer is required for browser testing. Install it with: npm install --save-dev puppeteer');
633
+ }
634
+
635
+ let launch_options = {
636
+ headless: 'new',
637
+ ...options,
638
+ };
639
+
640
+ this.browser = await puppeteer.launch(launch_options);
641
+ this.page = await this.browser.newPage();
642
+
643
+ return { browser: this.browser, page: this.page };
644
+ });
645
+
646
+ /**
647
+ * Navigate to a path in the browser
648
+ *
649
+ * @author Jelle De Loecker <jelle@elevenways.be>
650
+ * @since 1.4.1
651
+ * @version 1.4.1
652
+ *
653
+ * @param {string} path
654
+ *
655
+ * @return {Promise}
656
+ */
657
+ TestHarness.setMethod(async function goto(path) {
658
+
659
+ if (!this.page) {
660
+ await this.loadBrowser();
661
+ }
662
+
663
+ // Expose default static variables for each navigation
664
+ if (typeof alchemy !== 'undefined') {
665
+ alchemy.exposeDefaultStaticVariables();
666
+ }
667
+
668
+ let url = this.getUrl(path);
669
+ let resource = await this.page.goto(url);
670
+ let status = await resource.status();
671
+
672
+ if (status >= 400) {
673
+ throw new Error('Received a ' + status + ' error response for "' + path + '"');
674
+ }
675
+
676
+ return resource;
677
+ });
678
+
679
+ /**
680
+ * Evaluate code in the browser page context
681
+ *
682
+ * @author Jelle De Loecker <jelle@elevenways.be>
683
+ * @since 1.4.1
684
+ * @version 1.4.1
685
+ *
686
+ * @param {Function} fn
687
+ * @param {...*} args
688
+ *
689
+ * @return {Promise<*>}
690
+ */
691
+ TestHarness.setMethod(function evaluate(fn, ...args) {
692
+
693
+ if (!this.page) {
694
+ throw new Error('Browser not loaded. Call loadBrowser() first.');
695
+ }
696
+
697
+ return this.page.evaluate(fn, ...args);
698
+ });
699
+
700
+ /**
701
+ * Click an element in the browser
702
+ *
703
+ * @author Jelle De Loecker <jelle@elevenways.be>
704
+ * @since 1.4.1
705
+ * @version 1.4.1
706
+ *
707
+ * @param {string} selector
708
+ *
709
+ * @return {Promise<boolean>}
710
+ */
711
+ TestHarness.setMethod(async function click(selector) {
712
+
713
+ return this.evaluate((sel) => {
714
+ let element = document.querySelector(sel);
715
+
716
+ if (!element) {
717
+ return false;
718
+ }
719
+
720
+ element.click();
721
+ return true;
722
+ }, selector);
723
+ });
724
+
725
+ /**
726
+ * Get an element's data from the browser
727
+ *
728
+ * @author Jelle De Loecker <jelle@elevenways.be>
729
+ * @since 1.4.1
730
+ * @version 1.4.1
731
+ *
732
+ * @param {string} selector
733
+ *
734
+ * @return {Promise<Object|false>}
735
+ */
736
+ TestHarness.setMethod(async function queryElement(selector) {
737
+
738
+ return this.evaluate((sel) => {
739
+ let element = document.querySelector(sel);
740
+
741
+ if (!element) {
742
+ return false;
743
+ }
744
+
745
+ return {
746
+ html: element.outerHTML,
747
+ text: element.textContent,
748
+ location: document.location.pathname,
749
+ };
750
+ }, selector);
751
+ });
752
+
753
+ /**
754
+ * Set a value on a form element
755
+ *
756
+ * @author Jelle De Loecker <jelle@elevenways.be>
757
+ * @since 1.4.1
758
+ * @version 1.4.1
759
+ *
760
+ * @param {string} selector
761
+ * @param {*} value
762
+ *
763
+ * @return {Promise<Object|false>}
764
+ */
765
+ TestHarness.setMethod(async function setValue(selector, value) {
766
+
767
+ return this.evaluate((sel, val) => {
768
+ let element = document.querySelector(sel);
769
+
770
+ if (!element) {
771
+ return false;
772
+ }
773
+
774
+ element.value = val;
775
+
776
+ return {
777
+ html: element.outerHTML,
778
+ value: element.value,
779
+ };
780
+ }, selector, value);
781
+ });
782
+
783
+ /**
784
+ * Create a Mocha describe block for harness setup
785
+ * This is a convenience method for the most common test pattern
786
+ *
787
+ * @author Jelle De Loecker <jelle@elevenways.be>
788
+ * @since 1.4.1
789
+ * @version 1.4.1
790
+ *
791
+ * @param {Function} describe Mocha describe function
792
+ * @param {Function} it Mocha it function
793
+ *
794
+ * @return {TestHarness}
795
+ */
796
+ TestHarness.setMethod(function describeSetup(describe, it) {
797
+
798
+ let that = this;
799
+
800
+ describe('Test Setup', function() {
801
+ this.timeout(150000);
802
+
803
+ if (that.options.use_mongo_unit) {
804
+ it('should start in-memory MongoDB', async function() {
805
+ await that.startMongo();
806
+ });
807
+ }
808
+
809
+ it('should start the Alchemy server', async function() {
810
+ that.requireAlchemy();
811
+ await that.startServer();
812
+ });
813
+ });
814
+
815
+ return this;
816
+ });
817
+
818
+ /**
819
+ * Create a Mocha describe block for harness teardown
820
+ *
821
+ * @author Jelle De Loecker <jelle@elevenways.be>
822
+ * @since 1.4.1
823
+ * @version 1.4.1
824
+ *
825
+ * @param {Function} describe Mocha describe function
826
+ * @param {Function} it Mocha it function
827
+ *
828
+ * @return {TestHarness}
829
+ */
830
+ TestHarness.setMethod(function describeTeardown(describe, it) {
831
+
832
+ let that = this;
833
+
834
+ describe('Teardown', function() {
835
+ it('should stop all services', async function() {
836
+ await that.stop();
837
+ });
838
+ });
839
+
840
+ return this;
841
+ });
842
+
843
+ // =============================================================================
844
+ // Utility Methods
845
+ // =============================================================================
846
+
847
+ /**
848
+ * Clean up whitespace in text (trim, normalize newlines and spaces)
849
+ *
850
+ * @author Jelle De Loecker <jelle@elevenways.be>
851
+ * @since 1.4.1
852
+ * @version 1.4.1
853
+ *
854
+ * @param {string} text
855
+ *
856
+ * @return {string}
857
+ */
858
+ TestHarness.setStatic(function despace(text) {
859
+ return text.trim().replace(/\n/g, ' ').replace(/\s\s+/g, ' ');
860
+ });
861
+
862
+ // Store original console functions
863
+ let _console_log = console.log;
864
+ let _console_error = console.error;
865
+
866
+ /**
867
+ * Silence console output (useful for testing error conditions)
868
+ *
869
+ * @author Jelle De Loecker <jelle@elevenways.be>
870
+ * @since 1.4.1
871
+ * @version 1.4.1
872
+ */
873
+ TestHarness.setMethod(function silenceConsole() {
874
+ console.log = () => {};
875
+ console.error = () => {};
876
+ });
877
+
878
+ /**
879
+ * Restore console output
880
+ *
881
+ * @author Jelle De Loecker <jelle@elevenways.be>
882
+ * @since 1.4.1
883
+ * @version 1.4.1
884
+ */
885
+ TestHarness.setMethod(function restoreConsole() {
886
+ console.log = _console_log;
887
+ console.error = _console_error;
888
+ });
889
+
890
+ /**
891
+ * Create a model dynamically during tests
892
+ * Useful for testing model features without creating test fixture files
893
+ *
894
+ * @author Jelle De Loecker <jelle@elevenways.be>
895
+ * @since 1.4.1
896
+ * @version 1.4.1
897
+ *
898
+ * @param {Function} creator Named function that sets up schema in constitute context
899
+ *
900
+ * @return {Pledge}
901
+ */
902
+ TestHarness.setMethod(function createModel(creator) {
903
+
904
+ let name = creator.name,
905
+ pledge = new Classes.Pledge();
906
+
907
+ let fnc = Function.create(name, function model(options) {
908
+ model.super.call(this, options);
909
+ });
910
+
911
+ Function.inherits('Alchemy.Model', fnc);
912
+
913
+ fnc.constitute(function() {
914
+ creator.call(this);
915
+ pledge.resolve();
916
+ });
917
+
918
+ return pledge;
919
+ });
920
+
921
+ // Export the TestHarness class
922
+ module.exports = TestHarness;