sounding 0.0.3 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,480 @@
1
+ const { createSoundingError } = require('./create-error')
2
+
3
+ /** @typedef {import('./types').SoundingTestOptions} SoundingTestOptions */
4
+ /** @typedef {import('./types').SoundingTrialHandler} SoundingTrialHandler */
5
+
6
+ const ALLOWED_TRANSPORTS = ['virtual', 'http']
7
+ const ALLOWED_BROWSER_ARTIFACT_KEYS = ['outputDir', 'screenshot', 'trace', 'video', 'currentUrl']
8
+ const ALLOWED_BROWSER_ARTIFACT_MODES = ['off', 'on', 'on-failure']
9
+
10
+ /**
11
+ * @param {any} value
12
+ * @returns {value is Record<string, any>}
13
+ */
14
+ function isPlainObject(value) {
15
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
16
+ }
17
+
18
+ /**
19
+ * @param {string} apiName
20
+ * @returns {string}
21
+ */
22
+ function formatTrialSignature(apiName) {
23
+ return `${apiName}(name, [options], handler)`
24
+ }
25
+
26
+ /**
27
+ * @param {{
28
+ * code: string,
29
+ * message: string,
30
+ * apiName: string,
31
+ * value?: any,
32
+ * path?: string,
33
+ * allowed?: string[],
34
+ * suggestion?: string,
35
+ * }} input
36
+ * @returns {Error}
37
+ */
38
+ function createTestArgumentError(input) {
39
+ const { code, message, apiName, value, path, allowed, suggestion } = input
40
+ /** @type {Record<string, any>} */
41
+ const details = {
42
+ api: apiName,
43
+ signature: formatTrialSignature(apiName),
44
+ }
45
+
46
+ if (Object.prototype.hasOwnProperty.call(input, 'value')) {
47
+ details.value = value
48
+ }
49
+
50
+ if (path) {
51
+ details.path = path
52
+ }
53
+
54
+ if (allowed) {
55
+ details.allowed = allowed
56
+ }
57
+
58
+ if (suggestion) {
59
+ details.suggestion = suggestion
60
+ }
61
+
62
+ return createSoundingError({
63
+ code,
64
+ name: 'SoundingTestArgumentError',
65
+ message,
66
+ details,
67
+ })
68
+ }
69
+
70
+ /**
71
+ * @param {any} title
72
+ * @param {string} apiName
73
+ */
74
+ function assertTrialTitle(title, apiName) {
75
+ if (typeof title === 'string' && title.trim()) {
76
+ return
77
+ }
78
+
79
+ throw createTestArgumentError({
80
+ code: 'E_SOUNDING_TEST_TITLE_REQUIRED',
81
+ message: `Sounding ${apiName} requires a non-empty trial name. Use \`${formatTrialSignature(apiName)}\`.`,
82
+ apiName,
83
+ value: title,
84
+ path: 'name',
85
+ })
86
+ }
87
+
88
+ /**
89
+ * @param {any} handler
90
+ * @param {string} apiName
91
+ */
92
+ function assertTrialHandler(handler, apiName) {
93
+ if (typeof handler === 'function') {
94
+ return
95
+ }
96
+
97
+ throw createTestArgumentError({
98
+ code: 'E_SOUNDING_TEST_HANDLER_REQUIRED',
99
+ message: `Sounding ${apiName} requires a trial handler. Use \`${formatTrialSignature(apiName)}\`.`,
100
+ apiName,
101
+ value: handler,
102
+ path: 'handler',
103
+ suggestion: `Pass an async function as the final argument: \`${formatTrialSignature(apiName)}\`.`,
104
+ })
105
+ }
106
+
107
+ /**
108
+ * @param {any} options
109
+ * @param {string} apiName
110
+ */
111
+ function assertBrowserOptions(options, apiName) {
112
+ if (
113
+ options.browser !== undefined &&
114
+ typeof options.browser !== 'boolean' &&
115
+ typeof options.browser !== 'string' &&
116
+ !isPlainObject(options.browser)
117
+ ) {
118
+ throw createTestArgumentError({
119
+ code: 'E_SOUNDING_TEST_OPTIONS_INVALID',
120
+ message: `Sounding ${apiName} option \`browser\` must be a boolean, project name, or browser options object.`,
121
+ apiName,
122
+ value: options.browser,
123
+ path: 'options.browser',
124
+ suggestion: 'Use `browser: true`, `browser: "mobile"`, or `browser: { project: "desktop" }`.',
125
+ })
126
+ }
127
+
128
+ if (typeof options.browser === 'string') {
129
+ if (options.browser.trim()) {
130
+ return
131
+ }
132
+
133
+ throw createTestArgumentError({
134
+ code: 'E_SOUNDING_TEST_OPTIONS_INVALID',
135
+ message: `Sounding ${apiName} option \`browser\` must name a browser project when provided as a string.`,
136
+ apiName,
137
+ value: options.browser,
138
+ path: 'options.browser',
139
+ suggestion: 'Use `browser: "mobile"` or `browser: { project: "mobile" }`.',
140
+ })
141
+ }
142
+
143
+ if (!isPlainObject(options.browser)) {
144
+ return
145
+ }
146
+
147
+ if (options.browser.type !== undefined && typeof options.browser.type !== 'string') {
148
+ throw createTestArgumentError({
149
+ code: 'E_SOUNDING_TEST_OPTIONS_INVALID',
150
+ message: `Sounding ${apiName} option \`browser.type\` must be a string.`,
151
+ apiName,
152
+ value: options.browser.type,
153
+ path: 'options.browser.type',
154
+ })
155
+ }
156
+
157
+ if (options.browser.project !== undefined && typeof options.browser.project !== 'string') {
158
+ throw createTestArgumentError({
159
+ code: 'E_SOUNDING_TEST_OPTIONS_INVALID',
160
+ message: `Sounding ${apiName} option \`browser.project\` must be a string.`,
161
+ apiName,
162
+ value: options.browser.project,
163
+ path: 'options.browser.project',
164
+ })
165
+ }
166
+
167
+ if (options.browser.launchOptions !== undefined && !isPlainObject(options.browser.launchOptions)) {
168
+ throw createTestArgumentError({
169
+ code: 'E_SOUNDING_TEST_OPTIONS_INVALID',
170
+ message: `Sounding ${apiName} option \`browser.launchOptions\` must be an object.`,
171
+ apiName,
172
+ value: options.browser.launchOptions,
173
+ path: 'options.browser.launchOptions',
174
+ })
175
+ }
176
+
177
+ if (options.browser.contextOptions !== undefined && !isPlainObject(options.browser.contextOptions)) {
178
+ throw createTestArgumentError({
179
+ code: 'E_SOUNDING_TEST_OPTIONS_INVALID',
180
+ message: `Sounding ${apiName} option \`browser.contextOptions\` must be an object.`,
181
+ apiName,
182
+ value: options.browser.contextOptions,
183
+ path: 'options.browser.contextOptions',
184
+ })
185
+ }
186
+
187
+ if (
188
+ options.browser.artifacts !== undefined &&
189
+ typeof options.browser.artifacts !== 'boolean' &&
190
+ !isPlainObject(options.browser.artifacts)
191
+ ) {
192
+ throw createTestArgumentError({
193
+ code: 'E_SOUNDING_TEST_OPTIONS_INVALID',
194
+ message: `Sounding ${apiName} option \`browser.artifacts\` must be a boolean or artifacts options object.`,
195
+ apiName,
196
+ value: options.browser.artifacts,
197
+ path: 'options.browser.artifacts',
198
+ suggestion: 'Use `browser: { artifacts: { trace: true } }` for richer failure artifacts.',
199
+ })
200
+ }
201
+
202
+ if (!isPlainObject(options.browser.artifacts)) {
203
+ return
204
+ }
205
+
206
+ for (const key of Object.keys(options.browser.artifacts)) {
207
+ if (ALLOWED_BROWSER_ARTIFACT_KEYS.includes(key)) {
208
+ continue
209
+ }
210
+
211
+ throw createTestArgumentError({
212
+ code: 'E_SOUNDING_TEST_OPTIONS_INVALID',
213
+ message: `Sounding ${apiName} option \`browser.artifacts.${key}\` is unknown.`,
214
+ apiName,
215
+ value: options.browser.artifacts[key],
216
+ path: `options.browser.artifacts.${key}`,
217
+ allowed: ALLOWED_BROWSER_ARTIFACT_KEYS,
218
+ suggestion:
219
+ 'Use `outputDir`, `screenshot`, `trace`, `video`, or `currentUrl` inside `browser.artifacts`.',
220
+ })
221
+ }
222
+
223
+ if (
224
+ options.browser.artifacts.outputDir !== undefined &&
225
+ typeof options.browser.artifacts.outputDir !== 'string'
226
+ ) {
227
+ throw createTestArgumentError({
228
+ code: 'E_SOUNDING_TEST_OPTIONS_INVALID',
229
+ message: `Sounding ${apiName} option \`browser.artifacts.outputDir\` must be a string.`,
230
+ apiName,
231
+ value: options.browser.artifacts.outputDir,
232
+ path: 'options.browser.artifacts.outputDir',
233
+ })
234
+ }
235
+
236
+ for (const key of ['screenshot', 'trace', 'video']) {
237
+ const value = options.browser.artifacts[key]
238
+
239
+ if (
240
+ value !== undefined &&
241
+ typeof value !== 'boolean' &&
242
+ !ALLOWED_BROWSER_ARTIFACT_MODES.includes(value)
243
+ ) {
244
+ throw createTestArgumentError({
245
+ code: 'E_SOUNDING_TEST_OPTIONS_INVALID',
246
+ message: `Sounding ${apiName} option \`browser.artifacts.${key}\` must be a boolean or one of \`off\`, \`on\`, \`on-failure\`.`,
247
+ apiName,
248
+ value,
249
+ path: `options.browser.artifacts.${key}`,
250
+ allowed: ALLOWED_BROWSER_ARTIFACT_MODES,
251
+ })
252
+ }
253
+ }
254
+
255
+ if (
256
+ options.browser.artifacts.currentUrl !== undefined &&
257
+ typeof options.browser.artifacts.currentUrl !== 'boolean'
258
+ ) {
259
+ throw createTestArgumentError({
260
+ code: 'E_SOUNDING_TEST_OPTIONS_INVALID',
261
+ message: `Sounding ${apiName} option \`browser.artifacts.currentUrl\` must be a boolean.`,
262
+ apiName,
263
+ value: options.browser.artifacts.currentUrl,
264
+ path: 'options.browser.artifacts.currentUrl',
265
+ })
266
+ }
267
+ }
268
+
269
+ /**
270
+ * @param {any} options
271
+ * @param {string} apiName
272
+ */
273
+ function assertSocketOptions(options, apiName) {
274
+ if (
275
+ options.socket !== undefined &&
276
+ typeof options.socket !== 'boolean' &&
277
+ !isPlainObject(options.socket)
278
+ ) {
279
+ throw createTestArgumentError({
280
+ code: 'E_SOUNDING_TEST_OPTIONS_INVALID',
281
+ message: `Sounding ${apiName} option \`socket\` must be a boolean or socket options object.`,
282
+ apiName,
283
+ value: options.socket,
284
+ path: 'options.socket',
285
+ suggestion: 'Use `socket: true` for trials that need Sails websocket helpers.',
286
+ })
287
+ }
288
+
289
+ if (!isPlainObject(options.socket)) {
290
+ return
291
+ }
292
+
293
+ if (options.socket.timeout !== undefined && typeof options.socket.timeout !== 'number') {
294
+ throw createTestArgumentError({
295
+ code: 'E_SOUNDING_TEST_OPTIONS_INVALID',
296
+ message: `Sounding ${apiName} option \`socket.timeout\` must be a number.`,
297
+ apiName,
298
+ value: options.socket.timeout,
299
+ path: 'options.socket.timeout',
300
+ })
301
+ }
302
+
303
+ if (options.socket.baseUrl !== undefined && typeof options.socket.baseUrl !== 'string') {
304
+ throw createTestArgumentError({
305
+ code: 'E_SOUNDING_TEST_OPTIONS_INVALID',
306
+ message: `Sounding ${apiName} option \`socket.baseUrl\` must be a string.`,
307
+ apiName,
308
+ value: options.socket.baseUrl,
309
+ path: 'options.socket.baseUrl',
310
+ })
311
+ }
312
+ }
313
+
314
+ /**
315
+ * @param {any} options
316
+ * @param {string} apiName
317
+ */
318
+ function assertWorldOptions(options, apiName) {
319
+ if (options.world === undefined) {
320
+ return
321
+ }
322
+
323
+ if (typeof options.world === 'string') {
324
+ if (options.world.trim()) {
325
+ return
326
+ }
327
+
328
+ throw createTestArgumentError({
329
+ code: 'E_SOUNDING_TEST_OPTIONS_INVALID',
330
+ message: `Sounding ${apiName} option \`world\` must name a world scenario.`,
331
+ apiName,
332
+ value: options.world,
333
+ path: 'options.world',
334
+ suggestion: 'Use `world: "signed-in-user"` to auto-load a named scenario.',
335
+ })
336
+ }
337
+
338
+ if (!isPlainObject(options.world)) {
339
+ throw createTestArgumentError({
340
+ code: 'E_SOUNDING_TEST_OPTIONS_INVALID',
341
+ message: `Sounding ${apiName} option \`world\` must be a scenario name or world options object.`,
342
+ apiName,
343
+ value: options.world,
344
+ path: 'options.world',
345
+ suggestion: 'Use `world: "signed-in-user"` or `world: { name: "signed-in-user" }`.',
346
+ })
347
+ }
348
+
349
+ if (typeof options.world.name !== 'string' || !options.world.name.trim()) {
350
+ throw createTestArgumentError({
351
+ code: 'E_SOUNDING_TEST_OPTIONS_INVALID',
352
+ message: `Sounding ${apiName} option \`world.name\` must be a non-empty scenario name.`,
353
+ apiName,
354
+ value: options.world.name,
355
+ path: 'options.world.name',
356
+ suggestion: 'Use `world: { name: "signed-in-user" }`.',
357
+ })
358
+ }
359
+
360
+ if (options.world.context !== undefined && !isPlainObject(options.world.context)) {
361
+ throw createTestArgumentError({
362
+ code: 'E_SOUNDING_TEST_OPTIONS_INVALID',
363
+ message: `Sounding ${apiName} option \`world.context\` must be an object when provided.`,
364
+ apiName,
365
+ value: options.world.context,
366
+ path: 'options.world.context',
367
+ suggestion: 'Use `world: { name: "signed-in-user", context: { ... } }`.',
368
+ })
369
+ }
370
+ }
371
+
372
+ /**
373
+ * @param {any} options
374
+ * @param {string} apiName
375
+ */
376
+ function assertTrialOptions(options, apiName) {
377
+ if (!isPlainObject(options)) {
378
+ throw createTestArgumentError({
379
+ code: 'E_SOUNDING_TEST_OPTIONS_INVALID',
380
+ message: `Sounding ${apiName} options must be an object when provided. Use \`${formatTrialSignature(apiName)}\`.`,
381
+ apiName,
382
+ value: options,
383
+ path: 'options',
384
+ })
385
+ }
386
+
387
+ if (options.concurrent !== undefined && typeof options.concurrent !== 'boolean') {
388
+ throw createTestArgumentError({
389
+ code: 'E_SOUNDING_TEST_OPTIONS_INVALID',
390
+ message: `Sounding ${apiName} option \`concurrent\` must be a boolean.`,
391
+ apiName,
392
+ value: options.concurrent,
393
+ path: 'options.concurrent',
394
+ suggestion:
395
+ 'Use `concurrent: true` only for trials that can run with isolated runtime state.',
396
+ })
397
+ }
398
+
399
+ if (options.transport !== undefined && !ALLOWED_TRANSPORTS.includes(options.transport)) {
400
+ throw createTestArgumentError({
401
+ code: 'E_SOUNDING_TEST_OPTIONS_INVALID',
402
+ message: `Sounding ${apiName} option \`transport\` must be either \`virtual\` or \`http\`.`,
403
+ apiName,
404
+ value: options.transport,
405
+ path: 'options.transport',
406
+ allowed: ALLOWED_TRANSPORTS,
407
+ suggestion: 'Use `virtual` for Sails-native in-process requests or `http` for real HTTP requests.',
408
+ })
409
+ }
410
+
411
+ assertBrowserOptions(options, apiName)
412
+ assertSocketOptions(options, apiName)
413
+ assertWorldOptions(options, apiName)
414
+ }
415
+
416
+ /**
417
+ * @param {any} title
418
+ * @param {SoundingTestOptions | SoundingTrialHandler} optionsOrHandler
419
+ * @param {SoundingTrialHandler} [maybeHandler]
420
+ * @param {string} [apiName]
421
+ * @returns {{ title: string, options: SoundingTestOptions, handler: SoundingTrialHandler }}
422
+ */
423
+ function normalizeTestArgs(title, optionsOrHandler, maybeHandler, apiName = 'test') {
424
+ assertTrialTitle(title, apiName)
425
+
426
+ if (typeof optionsOrHandler === 'function') {
427
+ assertTrialHandler(optionsOrHandler, apiName)
428
+
429
+ return {
430
+ title,
431
+ options: {},
432
+ handler: optionsOrHandler,
433
+ }
434
+ }
435
+
436
+ const options = optionsOrHandler === undefined ? {} : optionsOrHandler
437
+ assertTrialOptions(options, apiName)
438
+ assertTrialHandler(maybeHandler, apiName)
439
+
440
+ return {
441
+ title,
442
+ options,
443
+ handler: maybeHandler,
444
+ }
445
+ }
446
+
447
+ /**
448
+ * @param {SoundingTestOptions} [options]
449
+ * @param {string} [apiName]
450
+ * @returns {{ nodeOptions: Record<string, any>, trialOptions: SoundingTestOptions }}
451
+ */
452
+ function splitTestOptions(options = {}, apiName = 'test') {
453
+ assertTrialOptions(options, apiName)
454
+
455
+ const { transport, browser, socket, world, concurrent, ...nodeOptions } = options
456
+ const nodeConcurrency = nodeOptions.concurrency
457
+ const concurrentEnabled =
458
+ concurrent === true ||
459
+ nodeConcurrency === true ||
460
+ (typeof nodeConcurrency === 'number' && nodeConcurrency > 1)
461
+
462
+ return {
463
+ nodeOptions: {
464
+ ...nodeOptions,
465
+ concurrency: concurrentEnabled ? nodeConcurrency ?? true : false,
466
+ },
467
+ trialOptions: {
468
+ transport,
469
+ browser,
470
+ socket,
471
+ world,
472
+ concurrent: concurrentEnabled,
473
+ },
474
+ }
475
+ }
476
+
477
+ module.exports = {
478
+ normalizeTestArgs,
479
+ splitTestOptions,
480
+ }
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "sounding",
3
- "version": "0.0.3",
3
+ "version": "0.1.0",
4
4
  "description": "Testing framework for Sails applications and The Boring JavaScript Stack.",
5
5
  "main": "index.js",
6
+ "bin": {
7
+ "sounding": "bin/sounding.js"
8
+ },
6
9
  "license": "MIT",
7
10
  "keywords": [
8
11
  "sails",
@@ -12,6 +15,7 @@
12
15
  "tbjs"
13
16
  ],
14
17
  "files": [
18
+ "bin",
15
19
  "index.js",
16
20
  "lib",
17
21
  "README.md",
@@ -19,7 +23,8 @@
19
23
  "LICENSE"
20
24
  ],
21
25
  "scripts": {
22
- "test": "node --test"
26
+ "test": "node --test test/*.test.js",
27
+ "typecheck": "tsc -p jsconfig.json --noEmit"
23
28
  },
24
29
  "sails": {
25
30
  "isHook": true,
@@ -35,5 +40,14 @@
35
40
  },
36
41
  "publishConfig": {
37
42
  "access": "public"
43
+ },
44
+ "devDependencies": {
45
+ "@types/node": "^25.9.1",
46
+ "sails": "^1.5.18",
47
+ "sails-hook-orm": "^4.0.3",
48
+ "sails-hook-sockets": "^3.0.2",
49
+ "sails-sqlite": "^0.2.6",
50
+ "socket.io-client": "^4.8.3",
51
+ "typescript": "^6.0.3"
38
52
  }
39
53
  }