sounding 0.0.0 → 0.0.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.
@@ -0,0 +1,549 @@
1
+ const { Transform } = require('node:stream')
2
+ const QS = require('node:querystring')
3
+
4
+ function isAbsoluteUrl(value) {
5
+ return /^https?:\/\//i.test(value)
6
+ }
7
+
8
+ function isPlainObject(value) {
9
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
10
+ }
11
+
12
+ function trimTrailingSlash(value) {
13
+ return value.replace(/\/$/, '')
14
+ }
15
+
16
+ function looksLikeJson({ contentType, body }) {
17
+ if (!body) {
18
+ return false
19
+ }
20
+
21
+ if (contentType?.includes('application/json')) {
22
+ return true
23
+ }
24
+
25
+ return /^[\[{]/.test(String(body).trim())
26
+ }
27
+
28
+ function normalizeBodyValue(value) {
29
+ if (value === undefined || value === null) {
30
+ return {
31
+ body: '',
32
+ data: undefined,
33
+ }
34
+ }
35
+
36
+ if (typeof value === 'string') {
37
+ return {
38
+ body: value,
39
+ data: undefined,
40
+ }
41
+ }
42
+
43
+ return {
44
+ body: JSON.stringify(value),
45
+ data: value,
46
+ }
47
+ }
48
+
49
+ function normalizeResponse({
50
+ raw,
51
+ status,
52
+ statusText = '',
53
+ headers = {},
54
+ url,
55
+ redirected = false,
56
+ responseBody,
57
+ }) {
58
+ const normalizedHeaders = new Headers(headers)
59
+ const contentType = normalizedHeaders.get('content-type') || ''
60
+ let { body, data } = normalizeBodyValue(responseBody)
61
+
62
+ if (data === undefined && looksLikeJson({ contentType, body })) {
63
+ data = JSON.parse(body)
64
+ }
65
+
66
+ return {
67
+ raw,
68
+ ok: status >= 200 && status < 400,
69
+ status,
70
+ statusText,
71
+ url,
72
+ redirected,
73
+ headers: normalizedHeaders,
74
+ body,
75
+ data,
76
+ header(name) {
77
+ return normalizedHeaders.get(name)
78
+ },
79
+ async text() {
80
+ return body
81
+ },
82
+ async json() {
83
+ return data
84
+ },
85
+ }
86
+ }
87
+
88
+ function resolveRequestConfig({ sails, getConfig }) {
89
+ const soundingConfig =
90
+ (typeof getConfig === 'function' ? getConfig() : null) || sails?.config?.sounding || {}
91
+
92
+ return soundingConfig.request || {}
93
+ }
94
+
95
+ function resolveBaseUrl({ sails, getConfig, override }) {
96
+ if (override) {
97
+ return trimTrailingSlash(override)
98
+ }
99
+
100
+ const requestConfig = resolveRequestConfig({ sails, getConfig })
101
+ if (requestConfig.baseUrl) {
102
+ return trimTrailingSlash(requestConfig.baseUrl)
103
+ }
104
+
105
+ const soundingConfig =
106
+ (typeof getConfig === 'function' ? getConfig() : null) || sails?.config?.sounding || {}
107
+
108
+ if (soundingConfig.browser?.baseUrl) {
109
+ return trimTrailingSlash(soundingConfig.browser.baseUrl)
110
+ }
111
+
112
+ const address = sails?.hooks?.http?.server?.address?.()
113
+ if (address && typeof address === 'object' && address.port) {
114
+ const host =
115
+ !address.address || address.address === '::' || address.address === '0.0.0.0'
116
+ ? '127.0.0.1'
117
+ : address.address
118
+
119
+ return `http://${host}:${address.port}`
120
+ }
121
+
122
+ if (sails?.config?.port) {
123
+ return `http://127.0.0.1:${sails.config.port}`
124
+ }
125
+
126
+ throw new Error(
127
+ 'Sounding could not resolve a base URL for HTTP request trials. Configure `sounding.request.baseUrl`, `sounding.browser.baseUrl`, or lift Sails with the HTTP hook.'
128
+ )
129
+ }
130
+
131
+ function resolveUrl({ sails, getConfig, target, baseUrl }) {
132
+ if (isAbsoluteUrl(target)) {
133
+ return target
134
+ }
135
+
136
+ const resolvedBaseUrl = resolveBaseUrl({
137
+ sails,
138
+ getConfig,
139
+ override: baseUrl,
140
+ })
141
+
142
+ if (target.startsWith('/')) {
143
+ return `${resolvedBaseUrl}${target}`
144
+ }
145
+
146
+ return `${resolvedBaseUrl}/${target}`
147
+ }
148
+
149
+ function normalizePayload(method, payload) {
150
+ if (payload === undefined) {
151
+ return undefined
152
+ }
153
+
154
+ if (['GET', 'HEAD', 'DELETE'].includes(method)) {
155
+ return payload
156
+ }
157
+
158
+ if (isPlainObject(payload) || Array.isArray(payload)) {
159
+ return payload
160
+ }
161
+
162
+ return payload
163
+ }
164
+
165
+ function resolveTransport({ sails, getConfig, target, options = {} }) {
166
+ if (options.transport) {
167
+ return options.transport
168
+ }
169
+
170
+ if (isAbsoluteUrl(target) || options.baseUrl) {
171
+ return 'http'
172
+ }
173
+
174
+ const requestConfig = resolveRequestConfig({ sails, getConfig })
175
+ return requestConfig.transport || 'virtual'
176
+ }
177
+
178
+ function normalizeVirtualUrl(method, target, payload) {
179
+ if (
180
+ (method === 'GET' || method === 'HEAD' || method === 'DELETE') &&
181
+ isPlainObject(payload)
182
+ ) {
183
+ const stringifiedParams = QS.stringify(payload)
184
+ const queryStringPos = target.indexOf('?')
185
+
186
+ if (queryStringPos === -1) {
187
+ return `${target}?${stringifiedParams}`
188
+ }
189
+
190
+ return `${target.substring(0, queryStringPos)}?${stringifiedParams}`
191
+ }
192
+
193
+ return target
194
+ }
195
+
196
+ function createFlash(session = {}) {
197
+ const flashStore = (session.__soundingFlashStore ||= {})
198
+
199
+ return function flash(key, value) {
200
+ if (arguments.length === 1) {
201
+ const messages = flashStore[key] || []
202
+ delete flashStore[key]
203
+ return messages
204
+ }
205
+
206
+ flashStore[key] ||= []
207
+ flashStore[key].push(value)
208
+ return flashStore[key]
209
+ }
210
+ }
211
+
212
+ class MockClientResponse extends Transform {
213
+ _transform(chunk, _encoding, next) {
214
+ this.push(chunk)
215
+ next()
216
+ }
217
+ }
218
+
219
+ function createVirtualTransport({ sails }) {
220
+ if (typeof sails?.router?.route !== 'function') {
221
+ throw new Error(
222
+ 'Sounding could not find `sails.router.route()`. Virtual request transport requires a loaded Sails app.'
223
+ )
224
+ }
225
+
226
+ return {
227
+ async send(method, target, payload, options = {}) {
228
+ return new Promise((resolve, reject) => {
229
+ const session = options.session || defaultSessionState()
230
+ const clientRes = new MockClientResponse()
231
+
232
+ try {
233
+ clientRes.on('finish', () => {
234
+ try {
235
+ clientRes.body = clientRes.read()
236
+ clientRes.body = clientRes.body?.toString()
237
+ } catch {}
238
+
239
+ if (!clientRes.body) {
240
+ delete clientRes.body
241
+ }
242
+
243
+ if (
244
+ clientRes.body !== undefined &&
245
+ clientRes.headers?.['content-type'] === 'application/json'
246
+ ) {
247
+ clientRes.body = JSON.parse(clientRes.body)
248
+ }
249
+
250
+ const status = clientRes.statusCode || 500
251
+ const responseBody = clientRes.body
252
+
253
+ resolve(
254
+ normalizeResponse({
255
+ raw: clientRes,
256
+ status,
257
+ statusText: clientRes.statusMessage || '',
258
+ headers: clientRes.headers || {},
259
+ url: target,
260
+ redirected: status >= 300 && status < 400,
261
+ responseBody,
262
+ })
263
+ )
264
+ })
265
+
266
+ clientRes.on('error', (error) => {
267
+ reject(error || new Error('Error on virtual response stream'))
268
+ })
269
+
270
+ sails.router.route(
271
+ {
272
+ method,
273
+ url: normalizeVirtualUrl(method, target, normalizePayload(method, payload)),
274
+ body: ['GET', 'HEAD', 'DELETE'].includes(method)
275
+ ? undefined
276
+ : normalizePayload(method, payload),
277
+ headers: {
278
+ ...(options.headers || {}),
279
+ nosession: 'true',
280
+ },
281
+ session,
282
+ flash: createFlash(session),
283
+ },
284
+ {
285
+ _clientRes: clientRes,
286
+ }
287
+ )
288
+ } catch (error) {
289
+ reject(error)
290
+ return
291
+ }
292
+ })
293
+ },
294
+ }
295
+ }
296
+
297
+ function defaultSessionState() {
298
+ return {}
299
+ }
300
+
301
+ function normalizeBodyAndHeaders(method, payload, headers) {
302
+ if (payload === undefined || method === 'GET' || method === 'HEAD') {
303
+ return {
304
+ body: undefined,
305
+ headers,
306
+ }
307
+ }
308
+
309
+ if (
310
+ typeof payload === 'string' ||
311
+ payload instanceof URLSearchParams ||
312
+ (typeof FormData !== 'undefined' && payload instanceof FormData) ||
313
+ payload instanceof ArrayBuffer ||
314
+ ArrayBuffer.isView(payload)
315
+ ) {
316
+ return {
317
+ body: payload,
318
+ headers,
319
+ }
320
+ }
321
+
322
+ if (isPlainObject(payload) || Array.isArray(payload)) {
323
+ if (!headers.has('content-type')) {
324
+ headers.set('content-type', 'application/json')
325
+ }
326
+
327
+ return {
328
+ body: JSON.stringify(payload),
329
+ headers,
330
+ }
331
+ }
332
+
333
+ return {
334
+ body: payload,
335
+ headers,
336
+ }
337
+ }
338
+
339
+ function createHttpTransport({
340
+ sails,
341
+ getConfig,
342
+ fetchImplementation = globalThis.fetch,
343
+ }) {
344
+ if (typeof fetchImplementation !== 'function') {
345
+ throw new Error('Sounding could not find a fetch implementation for HTTP request trials.')
346
+ }
347
+
348
+ return {
349
+ async send(method, target, payload, options = {}) {
350
+ const headers = new Headers({
351
+ accept: 'application/json',
352
+ ...(options.headers || {}),
353
+ })
354
+
355
+ const { body, headers: finalHeaders } = normalizeBodyAndHeaders(method, payload, headers)
356
+
357
+ const response = await fetchImplementation(
358
+ resolveUrl({
359
+ sails,
360
+ getConfig,
361
+ target,
362
+ baseUrl: options.baseUrl,
363
+ }),
364
+ {
365
+ method,
366
+ redirect: options.redirect || 'manual',
367
+ ...options,
368
+ headers: finalHeaders,
369
+ body,
370
+ }
371
+ )
372
+
373
+ return normalizeResponse({
374
+ raw: response,
375
+ status: response.status,
376
+ statusText: response.statusText,
377
+ headers: response.headers,
378
+ url: response.url,
379
+ redirected: response.redirected,
380
+ responseBody: await response.text(),
381
+ })
382
+ },
383
+ }
384
+ }
385
+
386
+ function createRequestClient({
387
+ sails,
388
+ getConfig,
389
+ fetchImplementation = globalThis.fetch,
390
+ defaultHeaders = {},
391
+ defaultSession = {},
392
+ transportOverride,
393
+ } = {}) {
394
+ let virtualTransport = null
395
+ let httpTransport = null
396
+
397
+ function getVirtualTransport() {
398
+ virtualTransport ||= createVirtualTransport({ sails })
399
+ return virtualTransport
400
+ }
401
+
402
+ function getHttpTransport() {
403
+ httpTransport ||= createHttpTransport({
404
+ sails,
405
+ getConfig,
406
+ fetchImplementation,
407
+ })
408
+ return httpTransport
409
+ }
410
+
411
+ async function send(method, target, payloadOrOptions, maybeOptions) {
412
+ const hasPayload = !['GET', 'HEAD'].includes(method)
413
+ const payload = hasPayload ? payloadOrOptions : undefined
414
+ const options = (hasPayload ? maybeOptions : payloadOrOptions) || {}
415
+ const headers = {
416
+ ...defaultHeaders,
417
+ ...(options.headers || {}),
418
+ }
419
+ const session = options.session
420
+ ? {
421
+ ...defaultSession,
422
+ ...options.session,
423
+ }
424
+ : defaultSession
425
+ const transport = resolveTransport({
426
+ sails,
427
+ getConfig,
428
+ target,
429
+ options: {
430
+ ...options,
431
+ transport: options.transport || transportOverride,
432
+ },
433
+ })
434
+
435
+ const transportOptions = {
436
+ ...options,
437
+ headers,
438
+ session,
439
+ }
440
+
441
+ if (transport === 'virtual') {
442
+ return getVirtualTransport().send(method, target, payload, transportOptions)
443
+ }
444
+
445
+ if (transport === 'http') {
446
+ return getHttpTransport().send(method, target, payload, transportOptions)
447
+ }
448
+
449
+ throw new Error(`Unknown Sounding request transport: ${transport}`)
450
+ }
451
+
452
+ return {
453
+ get transport() {
454
+ return transportOverride || resolveRequestConfig({ sails, getConfig }).transport || 'virtual'
455
+ },
456
+
457
+ async request(method, target, options = {}) {
458
+ return send(method.toUpperCase(), target, undefined, options)
459
+ },
460
+
461
+ get(target, options = {}) {
462
+ return send('GET', target, options)
463
+ },
464
+
465
+ head(target, options = {}) {
466
+ return send('HEAD', target, options)
467
+ },
468
+
469
+ post(target, payload, options = {}) {
470
+ return send('POST', target, payload, options)
471
+ },
472
+
473
+ put(target, payload, options = {}) {
474
+ return send('PUT', target, payload, options)
475
+ },
476
+
477
+ patch(target, payload, options = {}) {
478
+ return send('PATCH', target, payload, options)
479
+ },
480
+
481
+ delete(target, payload, options = {}) {
482
+ return send('DELETE', target, payload, options)
483
+ },
484
+
485
+ withHeaders(headers = {}) {
486
+ return createRequestClient({
487
+ sails,
488
+ getConfig,
489
+ fetchImplementation,
490
+ defaultHeaders: {
491
+ ...defaultHeaders,
492
+ ...headers,
493
+ },
494
+ defaultSession,
495
+ transportOverride,
496
+ })
497
+ },
498
+
499
+ withSession(session = {}) {
500
+ return createRequestClient({
501
+ sails,
502
+ getConfig,
503
+ fetchImplementation,
504
+ defaultHeaders,
505
+ defaultSession: {
506
+ ...defaultSession,
507
+ ...session,
508
+ },
509
+ transportOverride,
510
+ })
511
+ },
512
+
513
+ using(transport) {
514
+ return createRequestClient({
515
+ sails,
516
+ getConfig,
517
+ fetchImplementation,
518
+ defaultHeaders,
519
+ defaultSession,
520
+ transportOverride: transport,
521
+ })
522
+ },
523
+
524
+ as(actor) {
525
+ if (!actor) {
526
+ return this
527
+ }
528
+
529
+ const actorHeaders = actor.headers || actor.sounding?.headers || {}
530
+ const actorSession = actor.session ||
531
+ actor.sounding?.session || {
532
+ ...(actor.id ? { userId: actor.id } : {}),
533
+ ...(actor.team ? { teamId: actor.team } : {}),
534
+ }
535
+
536
+ return this.withHeaders(actorHeaders).withSession(actorSession)
537
+ },
538
+ }
539
+ }
540
+
541
+ module.exports = {
542
+ createRequestClient,
543
+ createVirtualTransport,
544
+ createHttpTransport,
545
+ normalizeResponse,
546
+ resolveBaseUrl,
547
+ resolveTransport,
548
+ resolveUrl,
549
+ }
@@ -0,0 +1,170 @@
1
+ const path = require('node:path')
2
+
3
+ const { createMailbox } = require('./create-mailbox')
4
+ const { createMailCapture } = require('./create-mail-capture')
5
+ const { createWorldEngine } = require('./create-world-engine')
6
+ const { loadWorldFiles } = require('./create-world-loader')
7
+ const { createHelperRunner } = require('./create-helper-runner')
8
+ const { createRequestClient } = require('./create-request-client')
9
+ const { createVisitClient } = require('./create-visit-client')
10
+ const { createBrowserManager } = require('./create-browser-manager')
11
+ const { createAuthHelpers } = require('./create-auth-helpers')
12
+ const { getDefaultConfig } = require('./default-config')
13
+ const { mergeConfig } = require('./merge-config')
14
+ const { normalizeUserConfig } = require('./normalize-config')
15
+ const { resolveDatastore } = require('./resolve-datastore')
16
+
17
+ function resolveConfig(sails) {
18
+ return mergeConfig(getDefaultConfig(), normalizeUserConfig(sails.config?.sounding || {}))
19
+ }
20
+
21
+ function resolveAppPath(sails, config) {
22
+ const basePath = sails?.config?.appPath || process.cwd()
23
+ return path.resolve(basePath, config.app?.path || '.')
24
+ }
25
+
26
+ function createRuntime(sails) {
27
+ const mailbox = createMailbox()
28
+ const world = createWorldEngine({ sails })
29
+ const helpers = createHelperRunner({ sails })
30
+ const request = createRequestClient({
31
+ sails,
32
+ getConfig: () => resolveConfig(sails),
33
+ })
34
+ const visit = createVisitClient({ request })
35
+ const browser = createBrowserManager({
36
+ sails,
37
+ getConfig: () => resolveConfig(sails),
38
+ appPathResolver: () => resolveAppPath(sails, resolveConfig(sails)),
39
+ })
40
+ const auth = createAuthHelpers({
41
+ sails,
42
+ world,
43
+ mailbox,
44
+ request,
45
+ })
46
+ const mailCapture = createMailCapture({
47
+ sails,
48
+ mailbox,
49
+ getConfig: () => resolveConfig(sails),
50
+ })
51
+ let bootState = null
52
+ let datastoreState = null
53
+
54
+ return {
55
+ get config() {
56
+ return resolveConfig(sails)
57
+ },
58
+
59
+ get appPath() {
60
+ return resolveAppPath(sails, this.config)
61
+ },
62
+
63
+ get mailbox() {
64
+ return mailbox
65
+ },
66
+
67
+ get world() {
68
+ return world
69
+ },
70
+
71
+ get helpers() {
72
+ return helpers
73
+ },
74
+
75
+ // Temporary compatibility alias while the DX settles.
76
+ get helper() {
77
+ return helpers
78
+ },
79
+
80
+ get request() {
81
+ return request
82
+ },
83
+
84
+ get visit() {
85
+ return visit
86
+ },
87
+
88
+ get browser() {
89
+ return browser
90
+ },
91
+
92
+ get auth() {
93
+ return auth
94
+ },
95
+
96
+ configure() {
97
+ datastoreState = resolveDatastore({
98
+ sails,
99
+ soundingConfig: this.config,
100
+ })
101
+
102
+ return datastoreState
103
+ },
104
+
105
+ get datastore() {
106
+ return datastoreState
107
+ },
108
+
109
+ async boot(options = {}) {
110
+ if (!datastoreState) {
111
+ datastoreState = this.configure()
112
+ }
113
+
114
+ world.reset({ preserveSequences: true })
115
+ const loadedWorldFiles = await loadWorldFiles({
116
+ world,
117
+ appPath: this.appPath,
118
+ config: this.config,
119
+ sails,
120
+ })
121
+ const captureInstalled = mailCapture.install()
122
+
123
+ bootState = {
124
+ bootedAt: new Date().toISOString(),
125
+ mode: options.mode || 'unit',
126
+ config: this.config,
127
+ datastore: datastoreState,
128
+ mail: {
129
+ captureEnabled: this.config.mail?.capture !== false,
130
+ captureInstalled,
131
+ },
132
+ world: {
133
+ loadedFiles: loadedWorldFiles,
134
+ },
135
+ }
136
+
137
+ return {
138
+ sails,
139
+ ...bootState,
140
+ helpers,
141
+ mailbox,
142
+ world,
143
+ request,
144
+ visit,
145
+ browser,
146
+ auth,
147
+ login: auth.login,
148
+ }
149
+ },
150
+
151
+ async lower() {
152
+ bootState = null
153
+ datastoreState = null
154
+ await browser.close()
155
+ mailCapture.uninstall()
156
+ mailbox.clear()
157
+ world.reset({ preserveSequences: true })
158
+ },
159
+
160
+ get state() {
161
+ return bootState
162
+ },
163
+ }
164
+ }
165
+
166
+ module.exports = {
167
+ createRuntime,
168
+ resolveConfig,
169
+ resolveAppPath,
170
+ }