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