sounding 0.0.4 → 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.
- package/README.md +334 -1
- package/bin/sounding.js +156 -0
- package/index.js +23 -0
- package/lib/create-app-manager.js +380 -26
- package/lib/create-auth-helpers.js +168 -21
- package/lib/create-browser-manager.js +578 -31
- package/lib/create-error.js +35 -0
- package/lib/create-expect.js +1070 -27
- package/lib/create-helper-runner.js +38 -2
- package/lib/create-mail-capture.js +125 -16
- package/lib/create-mailbox.js +20 -0
- package/lib/create-request-client.js +635 -57
- package/lib/create-runtime.js +222 -21
- package/lib/create-socket-manager.js +706 -0
- package/lib/create-test-api.js +491 -102
- package/lib/create-visit-client.js +40 -2
- package/lib/create-world-engine.js +106 -7
- package/lib/create-world-loader.js +150 -8
- package/lib/default-config.js +25 -0
- package/lib/define-world.js +27 -2
- package/lib/init-project.js +403 -0
- package/lib/merge-config.js +11 -0
- package/lib/normalize-config.js +16 -19
- package/lib/resolve-auth-config.js +36 -0
- package/lib/resolve-datastore.js +50 -7
- package/lib/resolve-dependency.js +145 -0
- package/lib/test-runner.js +427 -0
- package/lib/trial-context.js +29 -0
- package/lib/types.js +675 -0
- package/lib/validate-config.js +633 -0
- package/lib/validate-test-args.js +480 -0
- package/package.json +16 -2
|
@@ -1,31 +1,116 @@
|
|
|
1
1
|
const { Transform } = require('node:stream')
|
|
2
2
|
const QS = require('node:querystring')
|
|
3
3
|
const { resolveAuthConfig } = require('./resolve-auth-config')
|
|
4
|
-
|
|
4
|
+
const { createSoundingError } = require('./create-error')
|
|
5
|
+
|
|
6
|
+
/** @typedef {import('./types').AnyRecord} AnyRecord */
|
|
7
|
+
/** @typedef {import('./types').SoundingActor} SoundingActor */
|
|
8
|
+
/** @typedef {import('./types').SoundingRequestClient} SoundingRequestClient */
|
|
9
|
+
/** @typedef {import('./types').SoundingRequestOptions} SoundingRequestOptions */
|
|
10
|
+
/** @typedef {import('./types').SoundingResponse} SoundingResponse */
|
|
11
|
+
/** @typedef {import('./types').SoundingSailsApp} SoundingSailsApp */
|
|
12
|
+
/** @typedef {import('./types').SoundingTransport} SoundingTransport */
|
|
13
|
+
/** @typedef {import('./types').SoundingWorldEngine} SoundingWorldEngine */
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {string} value
|
|
17
|
+
* @returns {boolean}
|
|
18
|
+
*/
|
|
5
19
|
function isAbsoluteUrl(value) {
|
|
6
20
|
return /^https?:\/\//i.test(value)
|
|
7
21
|
}
|
|
8
22
|
|
|
23
|
+
/**
|
|
24
|
+
* @param {any} value
|
|
25
|
+
* @returns {value is AnyRecord}
|
|
26
|
+
*/
|
|
9
27
|
function isPlainObject(value) {
|
|
10
28
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
11
29
|
}
|
|
12
30
|
|
|
31
|
+
/**
|
|
32
|
+
* @param {string} value
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
13
35
|
function trimTrailingSlash(value) {
|
|
14
36
|
return value.replace(/\/$/, '')
|
|
15
37
|
}
|
|
16
38
|
|
|
17
|
-
|
|
39
|
+
/**
|
|
40
|
+
* @param {{ contentType?: string, body?: any }} input
|
|
41
|
+
* @returns {boolean}
|
|
42
|
+
*/
|
|
43
|
+
function shouldParseJson({ contentType, body }) {
|
|
18
44
|
if (!body) {
|
|
19
45
|
return false
|
|
20
46
|
}
|
|
21
47
|
|
|
22
|
-
|
|
48
|
+
const mediaType = contentType?.split(';')[0]?.trim().toLowerCase()
|
|
49
|
+
|
|
50
|
+
if (mediaType === 'application/json' || mediaType?.endsWith('+json')) {
|
|
23
51
|
return true
|
|
24
52
|
}
|
|
25
53
|
|
|
26
54
|
return /^[\[{]/.test(String(body).trim())
|
|
27
55
|
}
|
|
28
56
|
|
|
57
|
+
/**
|
|
58
|
+
* @param {{
|
|
59
|
+
* error: unknown,
|
|
60
|
+
* status: number,
|
|
61
|
+
* contentType: string,
|
|
62
|
+
* url?: string,
|
|
63
|
+
* body: string,
|
|
64
|
+
* }} input
|
|
65
|
+
* @returns {Error & { code: string, status: number, url?: string, contentType: string, body: string }}
|
|
66
|
+
*/
|
|
67
|
+
function createJsonParseError({ error, status, contentType, url, body }) {
|
|
68
|
+
const fromUrl = url ? ` from ${url}` : ''
|
|
69
|
+
return createSoundingError({
|
|
70
|
+
code: 'E_SOUNDING_JSON_PARSE',
|
|
71
|
+
message: `Sounding could not parse JSON response${fromUrl} (status ${status}).`,
|
|
72
|
+
details: {
|
|
73
|
+
status,
|
|
74
|
+
url,
|
|
75
|
+
contentType,
|
|
76
|
+
body,
|
|
77
|
+
},
|
|
78
|
+
cause: error,
|
|
79
|
+
name: 'SoundingJsonParseError',
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function createVirtualResponseStreamError(error) {
|
|
84
|
+
return createSoundingError({
|
|
85
|
+
code: 'E_SOUNDING_VIRTUAL_RESPONSE_STREAM',
|
|
86
|
+
message: 'Sounding virtual response stream failed.',
|
|
87
|
+
cause: error,
|
|
88
|
+
name: 'SoundingVirtualResponseStreamError',
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {{ body: string, status: number, contentType: string, url?: string }} input
|
|
94
|
+
* @returns {any}
|
|
95
|
+
*/
|
|
96
|
+
function parseJsonResponse({ body, status, contentType, url }) {
|
|
97
|
+
try {
|
|
98
|
+
return JSON.parse(body)
|
|
99
|
+
} catch (error) {
|
|
100
|
+
throw createJsonParseError({
|
|
101
|
+
error,
|
|
102
|
+
status,
|
|
103
|
+
contentType,
|
|
104
|
+
url,
|
|
105
|
+
body,
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* @param {any} value
|
|
112
|
+
* @returns {{ body: string, data: any }}
|
|
113
|
+
*/
|
|
29
114
|
function normalizeBodyValue(value) {
|
|
30
115
|
if (value === undefined || value === null) {
|
|
31
116
|
return {
|
|
@@ -47,6 +132,46 @@ function normalizeBodyValue(value) {
|
|
|
47
132
|
}
|
|
48
133
|
}
|
|
49
134
|
|
|
135
|
+
/**
|
|
136
|
+
* @param {HeadersInit | AnyRecord | undefined} headers
|
|
137
|
+
* @returns {HeadersInit | AnyRecord | undefined}
|
|
138
|
+
*/
|
|
139
|
+
function normalizeRequestHeadersMetadata(headers) {
|
|
140
|
+
if (!headers) {
|
|
141
|
+
return undefined
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (typeof headers.forEach === 'function') {
|
|
145
|
+
let count = 0
|
|
146
|
+
headers.forEach(() => {
|
|
147
|
+
count += 1
|
|
148
|
+
})
|
|
149
|
+
return count > 0 ? headers : undefined
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return Object.keys(headers).length > 0 ? headers : undefined
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* @param {{
|
|
157
|
+
* raw: unknown,
|
|
158
|
+
* status: number,
|
|
159
|
+
* statusText?: string,
|
|
160
|
+
* headers?: HeadersInit | AnyRecord,
|
|
161
|
+
* url?: string,
|
|
162
|
+
* redirected?: boolean,
|
|
163
|
+
* responseBody?: any,
|
|
164
|
+
* session?: AnyRecord,
|
|
165
|
+
* request?: {
|
|
166
|
+
* method: string,
|
|
167
|
+
* target: string,
|
|
168
|
+
* transport: SoundingTransport | 'socket',
|
|
169
|
+
* url?: string,
|
|
170
|
+
* headers?: HeadersInit | AnyRecord,
|
|
171
|
+
* },
|
|
172
|
+
* }} input
|
|
173
|
+
* @returns {SoundingResponse}
|
|
174
|
+
*/
|
|
50
175
|
function normalizeResponse({
|
|
51
176
|
raw,
|
|
52
177
|
status,
|
|
@@ -55,13 +180,20 @@ function normalizeResponse({
|
|
|
55
180
|
url,
|
|
56
181
|
redirected = false,
|
|
57
182
|
responseBody,
|
|
183
|
+
session,
|
|
184
|
+
request,
|
|
58
185
|
}) {
|
|
59
186
|
const normalizedHeaders = new Headers(headers)
|
|
60
187
|
const contentType = normalizedHeaders.get('content-type') || ''
|
|
61
188
|
let { body, data } = normalizeBodyValue(responseBody)
|
|
62
189
|
|
|
63
|
-
if (data === undefined &&
|
|
64
|
-
data =
|
|
190
|
+
if (data === undefined && shouldParseJson({ contentType, body })) {
|
|
191
|
+
data = parseJsonResponse({
|
|
192
|
+
body,
|
|
193
|
+
status,
|
|
194
|
+
contentType,
|
|
195
|
+
url,
|
|
196
|
+
})
|
|
65
197
|
}
|
|
66
198
|
|
|
67
199
|
return {
|
|
@@ -70,7 +202,9 @@ function normalizeResponse({
|
|
|
70
202
|
status,
|
|
71
203
|
statusText,
|
|
72
204
|
url,
|
|
205
|
+
request,
|
|
73
206
|
redirected,
|
|
207
|
+
session,
|
|
74
208
|
headers: normalizedHeaders,
|
|
75
209
|
body,
|
|
76
210
|
data,
|
|
@@ -86,6 +220,10 @@ function normalizeResponse({
|
|
|
86
220
|
}
|
|
87
221
|
}
|
|
88
222
|
|
|
223
|
+
/**
|
|
224
|
+
* @param {{ sails?: SoundingSailsApp, getConfig?: () => AnyRecord }} input
|
|
225
|
+
* @returns {AnyRecord}
|
|
226
|
+
*/
|
|
89
227
|
function resolveRequestConfig({ sails, getConfig }) {
|
|
90
228
|
const soundingConfig =
|
|
91
229
|
(typeof getConfig === 'function' ? getConfig() : null) || sails?.config?.sounding || {}
|
|
@@ -93,6 +231,10 @@ function resolveRequestConfig({ sails, getConfig }) {
|
|
|
93
231
|
return soundingConfig.request || {}
|
|
94
232
|
}
|
|
95
233
|
|
|
234
|
+
/**
|
|
235
|
+
* @param {{ sails?: SoundingSailsApp, getConfig?: () => AnyRecord, override?: string }} input
|
|
236
|
+
* @returns {string}
|
|
237
|
+
*/
|
|
96
238
|
function resolveBaseUrl({ sails, getConfig, override }) {
|
|
97
239
|
if (override) {
|
|
98
240
|
return trimTrailingSlash(override)
|
|
@@ -124,11 +266,17 @@ function resolveBaseUrl({ sails, getConfig, override }) {
|
|
|
124
266
|
return `http://127.0.0.1:${sails.config.port}`
|
|
125
267
|
}
|
|
126
268
|
|
|
127
|
-
throw
|
|
128
|
-
|
|
129
|
-
|
|
269
|
+
throw createSoundingError({
|
|
270
|
+
code: 'E_SOUNDING_BASE_URL_UNRESOLVED',
|
|
271
|
+
message:
|
|
272
|
+
'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.',
|
|
273
|
+
})
|
|
130
274
|
}
|
|
131
275
|
|
|
276
|
+
/**
|
|
277
|
+
* @param {{ sails?: SoundingSailsApp, getConfig?: () => AnyRecord, target: string, baseUrl?: string }} input
|
|
278
|
+
* @returns {string}
|
|
279
|
+
*/
|
|
132
280
|
function resolveUrl({ sails, getConfig, target, baseUrl }) {
|
|
133
281
|
if (isAbsoluteUrl(target)) {
|
|
134
282
|
return target
|
|
@@ -147,6 +295,11 @@ function resolveUrl({ sails, getConfig, target, baseUrl }) {
|
|
|
147
295
|
return `${resolvedBaseUrl}/${target}`
|
|
148
296
|
}
|
|
149
297
|
|
|
298
|
+
/**
|
|
299
|
+
* @param {string} method
|
|
300
|
+
* @param {any} payload
|
|
301
|
+
* @returns {any}
|
|
302
|
+
*/
|
|
150
303
|
function normalizePayload(method, payload) {
|
|
151
304
|
if (payload === undefined) {
|
|
152
305
|
return undefined
|
|
@@ -163,6 +316,160 @@ function normalizePayload(method, payload) {
|
|
|
163
316
|
return payload
|
|
164
317
|
}
|
|
165
318
|
|
|
319
|
+
/**
|
|
320
|
+
* @param {any} value
|
|
321
|
+
* @returns {string}
|
|
322
|
+
*/
|
|
323
|
+
function normalizeEmail(value) {
|
|
324
|
+
return String(value || '')
|
|
325
|
+
.trim()
|
|
326
|
+
.toLowerCase()
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* @param {any} value
|
|
331
|
+
* @returns {value is string}
|
|
332
|
+
*/
|
|
333
|
+
function looksLikeEmail(value) {
|
|
334
|
+
return typeof value === 'string' && value.includes('@')
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* @param {string[]} values
|
|
339
|
+
* @returns {string}
|
|
340
|
+
*/
|
|
341
|
+
function formatAvailable(values) {
|
|
342
|
+
return values.length ? values.join(', ') : 'none'
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* @param {{ world?: SoundingWorldEngine, sails?: SoundingSailsApp, getConfig?: () => AnyRecord }} input
|
|
347
|
+
* @returns {string[]}
|
|
348
|
+
*/
|
|
349
|
+
function availableWorldActors({ world, sails, getConfig }) {
|
|
350
|
+
const auth = resolveAuthConfig({ sails, getConfig })
|
|
351
|
+
const aliases = new Set()
|
|
352
|
+
|
|
353
|
+
for (const collection of [auth.worldCollection, 'users', 'creators']) {
|
|
354
|
+
const entries = world?.current?.[collection]
|
|
355
|
+
|
|
356
|
+
if (entries && typeof entries === 'object' && !Array.isArray(entries)) {
|
|
357
|
+
for (const alias of Object.keys(entries)) {
|
|
358
|
+
aliases.add(alias)
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return Array.from(aliases).sort()
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* @param {{ actor: string, world?: SoundingWorldEngine, sails?: SoundingSailsApp, getConfig?: () => AnyRecord }} input
|
|
368
|
+
* @returns {SoundingActor | null}
|
|
369
|
+
*/
|
|
370
|
+
function resolveWorldActor({ actor, world, sails, getConfig }) {
|
|
371
|
+
const auth = resolveAuthConfig({ sails, getConfig })
|
|
372
|
+
|
|
373
|
+
return (
|
|
374
|
+
world?.current?.[auth.worldCollection]?.[actor] ||
|
|
375
|
+
world?.current?.users?.[actor] ||
|
|
376
|
+
world?.current?.creators?.[actor] ||
|
|
377
|
+
world?.current?.[actor] ||
|
|
378
|
+
null
|
|
379
|
+
)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* @param {{ actor: string, world?: SoundingWorldEngine, sails?: SoundingSailsApp, getConfig?: () => AnyRecord, email?: string, modelIdentity?: string }} input
|
|
384
|
+
* @returns {Error}
|
|
385
|
+
*/
|
|
386
|
+
function createRequestActorUnresolvedError({
|
|
387
|
+
actor,
|
|
388
|
+
world,
|
|
389
|
+
sails,
|
|
390
|
+
getConfig,
|
|
391
|
+
email,
|
|
392
|
+
modelIdentity,
|
|
393
|
+
}) {
|
|
394
|
+
const availableActors = availableWorldActors({ world, sails, getConfig })
|
|
395
|
+
const availableMessage = `Available actors: ${formatAvailable(availableActors)}.`
|
|
396
|
+
const emailMessage = email ? ` Could not find ${modelIdentity || 'actor'} with email \`${email}\`.` : ''
|
|
397
|
+
|
|
398
|
+
return createSoundingError({
|
|
399
|
+
code: 'E_SOUNDING_REQUEST_ACTOR_UNRESOLVED',
|
|
400
|
+
message: `Sounding request.as() could not resolve actor \`${actor}\`. ${availableMessage}${emailMessage}`,
|
|
401
|
+
details: {
|
|
402
|
+
actor,
|
|
403
|
+
availableActors,
|
|
404
|
+
...(email ? { email } : {}),
|
|
405
|
+
...(modelIdentity ? { modelIdentity } : {}),
|
|
406
|
+
},
|
|
407
|
+
})
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* @param {{ actor: string, sails?: SoundingSailsApp, getConfig?: () => AnyRecord, world?: SoundingWorldEngine }} input
|
|
412
|
+
* @returns {Promise<SoundingActor>}
|
|
413
|
+
*/
|
|
414
|
+
async function resolveEmailActor({ actor, sails, getConfig, world }) {
|
|
415
|
+
const auth = resolveAuthConfig({ sails, getConfig })
|
|
416
|
+
const email = normalizeEmail(actor)
|
|
417
|
+
|
|
418
|
+
if (!auth.model?.findOne) {
|
|
419
|
+
throw createRequestActorUnresolvedError({
|
|
420
|
+
actor,
|
|
421
|
+
world,
|
|
422
|
+
sails,
|
|
423
|
+
getConfig,
|
|
424
|
+
email,
|
|
425
|
+
modelIdentity: auth.modelIdentity,
|
|
426
|
+
})
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const resolvedActor = await auth.model.findOne({ email })
|
|
430
|
+
|
|
431
|
+
if (!resolvedActor) {
|
|
432
|
+
throw createRequestActorUnresolvedError({
|
|
433
|
+
actor,
|
|
434
|
+
world,
|
|
435
|
+
sails,
|
|
436
|
+
getConfig,
|
|
437
|
+
email,
|
|
438
|
+
modelIdentity: auth.modelIdentity,
|
|
439
|
+
})
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return resolvedActor
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* @param {SoundingActor} actor
|
|
447
|
+
* @returns {HeadersInit | AnyRecord}
|
|
448
|
+
*/
|
|
449
|
+
function resolveActorHeaders(actor) {
|
|
450
|
+
return actor.headers || actor.sounding?.headers || {}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* @param {SoundingActor} actor
|
|
455
|
+
* @param {AnyRecord} auth
|
|
456
|
+
* @returns {AnyRecord}
|
|
457
|
+
*/
|
|
458
|
+
function resolveActorSession(actor, auth) {
|
|
459
|
+
return (
|
|
460
|
+
actor.session ||
|
|
461
|
+
actor.sounding?.session || {
|
|
462
|
+
...(actor.id ? { [auth.sessionKey]: actor.id } : {}),
|
|
463
|
+
...(actor.team ? { teamId: actor.team } : {}),
|
|
464
|
+
...(actor.teamId ? { teamId: actor.teamId } : {}),
|
|
465
|
+
}
|
|
466
|
+
)
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* @param {{ sails?: SoundingSailsApp, getConfig?: () => AnyRecord, target: string, options?: SoundingRequestOptions }} input
|
|
471
|
+
* @returns {SoundingTransport}
|
|
472
|
+
*/
|
|
166
473
|
function resolveTransport({ sails, getConfig, target, options = {} }) {
|
|
167
474
|
if (options.transport) {
|
|
168
475
|
return options.transport
|
|
@@ -176,6 +483,12 @@ function resolveTransport({ sails, getConfig, target, options = {} }) {
|
|
|
176
483
|
return requestConfig.transport || 'virtual'
|
|
177
484
|
}
|
|
178
485
|
|
|
486
|
+
/**
|
|
487
|
+
* @param {string} method
|
|
488
|
+
* @param {string} target
|
|
489
|
+
* @param {any} payload
|
|
490
|
+
* @returns {string}
|
|
491
|
+
*/
|
|
179
492
|
function normalizeVirtualUrl(method, target, payload) {
|
|
180
493
|
if (
|
|
181
494
|
(method === 'GET' || method === 'HEAD' || method === 'DELETE') &&
|
|
@@ -194,6 +507,10 @@ function normalizeVirtualUrl(method, target, payload) {
|
|
|
194
507
|
return target
|
|
195
508
|
}
|
|
196
509
|
|
|
510
|
+
/**
|
|
511
|
+
* @param {AnyRecord} [session]
|
|
512
|
+
* @returns {(key: string, value?: any) => any[]}
|
|
513
|
+
*/
|
|
197
514
|
function createFlash(session = {}) {
|
|
198
515
|
const flashStore = (session.__soundingFlashStore ||= {})
|
|
199
516
|
|
|
@@ -217,61 +534,173 @@ class MockClientResponse extends Transform {
|
|
|
217
534
|
}
|
|
218
535
|
}
|
|
219
536
|
|
|
537
|
+
let nextVirtualRequestId = 0
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* @param {AnyRecord} target
|
|
541
|
+
* @param {AnyRecord} source
|
|
542
|
+
*/
|
|
543
|
+
function replaceObjectContents(target, source) {
|
|
544
|
+
const flashStore = target.__soundingFlashStore
|
|
545
|
+
|
|
546
|
+
for (const key of Object.keys(target)) {
|
|
547
|
+
delete target[key]
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
for (const key of Object.keys(source)) {
|
|
551
|
+
target[key] = source[key]
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (flashStore && !Object.prototype.hasOwnProperty.call(source, '__soundingFlashStore')) {
|
|
555
|
+
target.__soundingFlashStore = flashStore
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* @param {any} value
|
|
561
|
+
* @returns {any}
|
|
562
|
+
*/
|
|
563
|
+
function cloneSessionValue(value) {
|
|
564
|
+
if (Array.isArray(value)) {
|
|
565
|
+
return value.map(cloneSessionValue)
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (isPlainObject(value)) {
|
|
569
|
+
return Object.fromEntries(
|
|
570
|
+
Object.entries(value).map(([key, nested]) => [key, cloneSessionValue(nested)])
|
|
571
|
+
)
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
return value
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* @param {AnyRecord} session
|
|
579
|
+
* @returns {AnyRecord}
|
|
580
|
+
*/
|
|
581
|
+
function cloneSessionSnapshot(session) {
|
|
582
|
+
return cloneSessionValue(session)
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Sails' virtual router deep-copies the partial request before routing it.
|
|
587
|
+
* Track the routed request so session mutations made by real route handlers
|
|
588
|
+
* can flow back into Sounding's shared virtual session object.
|
|
589
|
+
*
|
|
590
|
+
* @param {{ sails?: SoundingSailsApp, session: AnyRecord }} input
|
|
591
|
+
* @returns {{ requestId: string, sync(): void, close(): void }}
|
|
592
|
+
*/
|
|
593
|
+
function createVirtualSessionTracker({ sails, session }) {
|
|
594
|
+
const requestId = `sounding-${++nextVirtualRequestId}`
|
|
595
|
+
let routedReq = null
|
|
596
|
+
|
|
597
|
+
function onRoute(requestState) {
|
|
598
|
+
if (requestState?.req?._soundingRequestId === requestId) {
|
|
599
|
+
routedReq = requestState.req
|
|
600
|
+
|
|
601
|
+
if (isPlainObject(routedReq.session)) {
|
|
602
|
+
routedReq.flash = createFlash(routedReq.session)
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
if (typeof sails?.on === 'function') {
|
|
608
|
+
sails.on('router:route', onRoute)
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
return {
|
|
612
|
+
requestId,
|
|
613
|
+
sync() {
|
|
614
|
+
if (isPlainObject(session) && isPlainObject(routedReq?.session)) {
|
|
615
|
+
replaceObjectContents(session, routedReq.session)
|
|
616
|
+
}
|
|
617
|
+
},
|
|
618
|
+
close() {
|
|
619
|
+
if (typeof sails?.off === 'function') {
|
|
620
|
+
sails.off('router:route', onRoute)
|
|
621
|
+
return
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (typeof sails?.removeListener === 'function') {
|
|
625
|
+
sails.removeListener('router:route', onRoute)
|
|
626
|
+
}
|
|
627
|
+
},
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* @param {{ sails?: SoundingSailsApp }} input
|
|
633
|
+
* @returns {{ send(method: string, target: string, payload?: any, options?: SoundingRequestOptions): Promise<SoundingResponse> }}
|
|
634
|
+
*/
|
|
220
635
|
function createVirtualTransport({ sails }) {
|
|
221
636
|
if (typeof sails?.router?.route !== 'function') {
|
|
222
|
-
throw
|
|
223
|
-
|
|
224
|
-
|
|
637
|
+
throw createSoundingError({
|
|
638
|
+
code: 'E_SOUNDING_VIRTUAL_TRANSPORT_UNAVAILABLE',
|
|
639
|
+
message:
|
|
640
|
+
'Sounding could not find `sails.router.route()`. Virtual request transport requires a loaded Sails app.',
|
|
641
|
+
})
|
|
225
642
|
}
|
|
226
643
|
|
|
227
644
|
return {
|
|
228
645
|
async send(method, target, payload, options = {}) {
|
|
229
646
|
return new Promise((resolve, reject) => {
|
|
230
647
|
const session = options.session || defaultSessionState()
|
|
648
|
+
const sessionTracker = createVirtualSessionTracker({ sails, session })
|
|
649
|
+
const virtualUrl = normalizeVirtualUrl(method, target, normalizePayload(method, payload))
|
|
650
|
+
/** @type {MockClientResponse & { body?: any, headers?: AnyRecord, statusCode?: number, statusMessage?: string }} */
|
|
231
651
|
const clientRes = new MockClientResponse()
|
|
232
652
|
|
|
233
653
|
try {
|
|
234
654
|
clientRes.on('finish', () => {
|
|
235
655
|
try {
|
|
656
|
+
sessionTracker.sync()
|
|
236
657
|
clientRes.body = clientRes.read()
|
|
237
658
|
clientRes.body = clientRes.body?.toString()
|
|
238
|
-
} catch {}
|
|
239
659
|
|
|
240
|
-
|
|
241
|
-
|
|
660
|
+
if (!clientRes.body) {
|
|
661
|
+
delete clientRes.body
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const status = clientRes.statusCode || 500
|
|
665
|
+
const responseBody = clientRes.body
|
|
666
|
+
const requestHeaders = normalizeRequestHeadersMetadata(options.headers)
|
|
667
|
+
|
|
668
|
+
resolve(
|
|
669
|
+
normalizeResponse({
|
|
670
|
+
raw: clientRes,
|
|
671
|
+
status,
|
|
672
|
+
statusText: clientRes.statusMessage || '',
|
|
673
|
+
headers: clientRes.headers || {},
|
|
674
|
+
url: target,
|
|
675
|
+
redirected: status >= 300 && status < 400,
|
|
676
|
+
responseBody,
|
|
677
|
+
session: cloneSessionSnapshot(session),
|
|
678
|
+
request: {
|
|
679
|
+
method,
|
|
680
|
+
target,
|
|
681
|
+
transport: 'virtual',
|
|
682
|
+
url: virtualUrl,
|
|
683
|
+
...(requestHeaders ? { headers: requestHeaders } : {}),
|
|
684
|
+
},
|
|
685
|
+
})
|
|
686
|
+
)
|
|
687
|
+
} catch (error) {
|
|
688
|
+
reject(error)
|
|
689
|
+
} finally {
|
|
690
|
+
sessionTracker.close()
|
|
242
691
|
}
|
|
243
|
-
|
|
244
|
-
if (
|
|
245
|
-
clientRes.body !== undefined &&
|
|
246
|
-
clientRes.headers?.['content-type'] === 'application/json'
|
|
247
|
-
) {
|
|
248
|
-
clientRes.body = JSON.parse(clientRes.body)
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
const status = clientRes.statusCode || 500
|
|
252
|
-
const responseBody = clientRes.body
|
|
253
|
-
|
|
254
|
-
resolve(
|
|
255
|
-
normalizeResponse({
|
|
256
|
-
raw: clientRes,
|
|
257
|
-
status,
|
|
258
|
-
statusText: clientRes.statusMessage || '',
|
|
259
|
-
headers: clientRes.headers || {},
|
|
260
|
-
url: target,
|
|
261
|
-
redirected: status >= 300 && status < 400,
|
|
262
|
-
responseBody,
|
|
263
|
-
})
|
|
264
|
-
)
|
|
265
692
|
})
|
|
266
693
|
|
|
267
694
|
clientRes.on('error', (error) => {
|
|
268
|
-
|
|
695
|
+
sessionTracker.close()
|
|
696
|
+
reject(createVirtualResponseStreamError(error))
|
|
269
697
|
})
|
|
270
698
|
|
|
271
699
|
sails.router.route(
|
|
272
700
|
{
|
|
273
701
|
method,
|
|
274
|
-
url:
|
|
702
|
+
url: virtualUrl,
|
|
703
|
+
_soundingRequestId: sessionTracker.requestId,
|
|
275
704
|
body: ['GET', 'HEAD', 'DELETE'].includes(method)
|
|
276
705
|
? undefined
|
|
277
706
|
: normalizePayload(method, payload),
|
|
@@ -287,6 +716,7 @@ function createVirtualTransport({ sails }) {
|
|
|
287
716
|
}
|
|
288
717
|
)
|
|
289
718
|
} catch (error) {
|
|
719
|
+
sessionTracker.close()
|
|
290
720
|
reject(error)
|
|
291
721
|
return
|
|
292
722
|
}
|
|
@@ -295,10 +725,19 @@ function createVirtualTransport({ sails }) {
|
|
|
295
725
|
}
|
|
296
726
|
}
|
|
297
727
|
|
|
728
|
+
/**
|
|
729
|
+
* @returns {AnyRecord}
|
|
730
|
+
*/
|
|
298
731
|
function defaultSessionState() {
|
|
299
732
|
return {}
|
|
300
733
|
}
|
|
301
734
|
|
|
735
|
+
/**
|
|
736
|
+
* @param {string} method
|
|
737
|
+
* @param {any} payload
|
|
738
|
+
* @param {Headers} headers
|
|
739
|
+
* @returns {{ body: any, headers: Headers }}
|
|
740
|
+
*/
|
|
302
741
|
function normalizeBodyAndHeaders(method, payload, headers) {
|
|
303
742
|
if (payload === undefined || method === 'GET' || method === 'HEAD') {
|
|
304
743
|
return {
|
|
@@ -337,13 +776,24 @@ function normalizeBodyAndHeaders(method, payload, headers) {
|
|
|
337
776
|
}
|
|
338
777
|
}
|
|
339
778
|
|
|
779
|
+
/**
|
|
780
|
+
* @param {{
|
|
781
|
+
* sails?: SoundingSailsApp,
|
|
782
|
+
* getConfig?: () => AnyRecord,
|
|
783
|
+
* fetchImplementation?: typeof fetch,
|
|
784
|
+
* }} input
|
|
785
|
+
* @returns {{ send(method: string, target: string, payload?: any, options?: SoundingRequestOptions): Promise<SoundingResponse> }}
|
|
786
|
+
*/
|
|
340
787
|
function createHttpTransport({
|
|
341
788
|
sails,
|
|
342
789
|
getConfig,
|
|
343
790
|
fetchImplementation = globalThis.fetch,
|
|
344
791
|
}) {
|
|
345
792
|
if (typeof fetchImplementation !== 'function') {
|
|
346
|
-
throw
|
|
793
|
+
throw createSoundingError({
|
|
794
|
+
code: 'E_SOUNDING_HTTP_FETCH_UNAVAILABLE',
|
|
795
|
+
message: 'Sounding could not find a fetch implementation for HTTP request trials.',
|
|
796
|
+
})
|
|
347
797
|
}
|
|
348
798
|
|
|
349
799
|
return {
|
|
@@ -354,14 +804,16 @@ function createHttpTransport({
|
|
|
354
804
|
})
|
|
355
805
|
|
|
356
806
|
const { body, headers: finalHeaders } = normalizeBodyAndHeaders(method, payload, headers)
|
|
807
|
+
const requestHeaders = normalizeRequestHeadersMetadata(finalHeaders)
|
|
808
|
+
const url = resolveUrl({
|
|
809
|
+
sails,
|
|
810
|
+
getConfig,
|
|
811
|
+
target,
|
|
812
|
+
baseUrl: options.baseUrl,
|
|
813
|
+
})
|
|
357
814
|
|
|
358
815
|
const response = await fetchImplementation(
|
|
359
|
-
|
|
360
|
-
sails,
|
|
361
|
-
getConfig,
|
|
362
|
-
target,
|
|
363
|
-
baseUrl: options.baseUrl,
|
|
364
|
-
}),
|
|
816
|
+
url,
|
|
365
817
|
{
|
|
366
818
|
method,
|
|
367
819
|
redirect: options.redirect || 'manual',
|
|
@@ -379,11 +831,31 @@ function createHttpTransport({
|
|
|
379
831
|
url: response.url,
|
|
380
832
|
redirected: response.redirected,
|
|
381
833
|
responseBody: await response.text(),
|
|
834
|
+
request: {
|
|
835
|
+
method,
|
|
836
|
+
target,
|
|
837
|
+
transport: 'http',
|
|
838
|
+
url,
|
|
839
|
+
...(requestHeaders ? { headers: requestHeaders } : {}),
|
|
840
|
+
},
|
|
382
841
|
})
|
|
383
842
|
},
|
|
384
843
|
}
|
|
385
844
|
}
|
|
386
845
|
|
|
846
|
+
/**
|
|
847
|
+
* @param {{
|
|
848
|
+
* sails?: SoundingSailsApp,
|
|
849
|
+
* getConfig?: () => AnyRecord,
|
|
850
|
+
* fetchImplementation?: typeof fetch,
|
|
851
|
+
* defaultHeaders?: HeadersInit | AnyRecord,
|
|
852
|
+
* defaultSession?: AnyRecord,
|
|
853
|
+
* transportOverride?: SoundingTransport,
|
|
854
|
+
* world?: SoundingWorldEngine,
|
|
855
|
+
* defaultActor?: string,
|
|
856
|
+
* }} [options]
|
|
857
|
+
* @returns {SoundingRequestClient}
|
|
858
|
+
*/
|
|
387
859
|
function createRequestClient({
|
|
388
860
|
sails,
|
|
389
861
|
getConfig,
|
|
@@ -391,9 +863,20 @@ function createRequestClient({
|
|
|
391
863
|
defaultHeaders = {},
|
|
392
864
|
defaultSession = {},
|
|
393
865
|
transportOverride,
|
|
866
|
+
world,
|
|
867
|
+
defaultActor,
|
|
394
868
|
} = {}) {
|
|
395
869
|
let virtualTransport = null
|
|
396
870
|
let httpTransport = null
|
|
871
|
+
let defaultActorContextPromise = null
|
|
872
|
+
|
|
873
|
+
function resetDefaultSession() {
|
|
874
|
+
for (const key of Reflect.ownKeys(defaultSession)) {
|
|
875
|
+
Reflect.deleteProperty(defaultSession, key)
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
defaultActorContextPromise = null
|
|
879
|
+
}
|
|
397
880
|
|
|
398
881
|
function getVirtualTransport() {
|
|
399
882
|
virtualTransport ||= createVirtualTransport({ sails })
|
|
@@ -409,20 +892,80 @@ function createRequestClient({
|
|
|
409
892
|
return httpTransport
|
|
410
893
|
}
|
|
411
894
|
|
|
895
|
+
/**
|
|
896
|
+
* @returns {Promise<{ headers: HeadersInit | AnyRecord, session: AnyRecord } | null>}
|
|
897
|
+
*/
|
|
898
|
+
async function getDefaultActorContext() {
|
|
899
|
+
if (!defaultActor) {
|
|
900
|
+
return null
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
defaultActorContextPromise ||= resolveEmailActor({
|
|
904
|
+
actor: defaultActor,
|
|
905
|
+
sails,
|
|
906
|
+
getConfig,
|
|
907
|
+
world,
|
|
908
|
+
}).then((actor) => {
|
|
909
|
+
const auth = resolveAuthConfig({ sails, getConfig })
|
|
910
|
+
|
|
911
|
+
return {
|
|
912
|
+
headers: resolveActorHeaders(actor),
|
|
913
|
+
session: {
|
|
914
|
+
...defaultSession,
|
|
915
|
+
...resolveActorSession(actor, auth),
|
|
916
|
+
},
|
|
917
|
+
}
|
|
918
|
+
})
|
|
919
|
+
|
|
920
|
+
return defaultActorContextPromise
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* @param {SoundingActor} actor
|
|
925
|
+
* @returns {SoundingRequestClient}
|
|
926
|
+
*/
|
|
927
|
+
function withActor(actor) {
|
|
928
|
+
const auth = resolveAuthConfig({ sails, getConfig })
|
|
929
|
+
|
|
930
|
+
return createRequestClient({
|
|
931
|
+
sails,
|
|
932
|
+
getConfig,
|
|
933
|
+
fetchImplementation,
|
|
934
|
+
defaultHeaders: {
|
|
935
|
+
...defaultHeaders,
|
|
936
|
+
...resolveActorHeaders(actor),
|
|
937
|
+
},
|
|
938
|
+
defaultSession: {
|
|
939
|
+
...defaultSession,
|
|
940
|
+
...resolveActorSession(actor, auth),
|
|
941
|
+
},
|
|
942
|
+
transportOverride,
|
|
943
|
+
world,
|
|
944
|
+
})
|
|
945
|
+
}
|
|
946
|
+
|
|
412
947
|
async function send(method, target, payloadOrOptions, maybeOptions) {
|
|
413
948
|
const hasPayload = !['GET', 'HEAD'].includes(method)
|
|
414
949
|
const payload = hasPayload ? payloadOrOptions : undefined
|
|
415
950
|
const options = (hasPayload ? maybeOptions : payloadOrOptions) || {}
|
|
951
|
+
const defaultActorContext = await getDefaultActorContext()
|
|
952
|
+
const baseHeaders = defaultActorContext
|
|
953
|
+
? {
|
|
954
|
+
...defaultHeaders,
|
|
955
|
+
...defaultActorContext.headers,
|
|
956
|
+
}
|
|
957
|
+
: defaultHeaders
|
|
958
|
+
const baseSession = defaultActorContext?.session || defaultSession
|
|
416
959
|
const headers = {
|
|
417
|
-
...
|
|
960
|
+
...baseHeaders,
|
|
418
961
|
...(options.headers || {}),
|
|
419
962
|
}
|
|
420
963
|
const session = options.session
|
|
421
964
|
? {
|
|
422
|
-
...
|
|
965
|
+
...baseSession,
|
|
423
966
|
...options.session,
|
|
424
967
|
}
|
|
425
|
-
:
|
|
968
|
+
: baseSession
|
|
426
969
|
const transport = resolveTransport({
|
|
427
970
|
sails,
|
|
428
971
|
getConfig,
|
|
@@ -447,7 +990,13 @@ function createRequestClient({
|
|
|
447
990
|
return getHttpTransport().send(method, target, payload, transportOptions)
|
|
448
991
|
}
|
|
449
992
|
|
|
450
|
-
throw
|
|
993
|
+
throw createSoundingError({
|
|
994
|
+
code: 'E_SOUNDING_UNKNOWN_TRANSPORT',
|
|
995
|
+
message: `Unknown Sounding request transport: ${transport}`,
|
|
996
|
+
details: {
|
|
997
|
+
transport,
|
|
998
|
+
},
|
|
999
|
+
})
|
|
451
1000
|
}
|
|
452
1001
|
|
|
453
1002
|
return {
|
|
@@ -483,6 +1032,10 @@ function createRequestClient({
|
|
|
483
1032
|
return send('DELETE', target, payload, options)
|
|
484
1033
|
},
|
|
485
1034
|
|
|
1035
|
+
clearSession() {
|
|
1036
|
+
resetDefaultSession()
|
|
1037
|
+
},
|
|
1038
|
+
|
|
486
1039
|
withHeaders(headers = {}) {
|
|
487
1040
|
return createRequestClient({
|
|
488
1041
|
sails,
|
|
@@ -494,6 +1047,8 @@ function createRequestClient({
|
|
|
494
1047
|
},
|
|
495
1048
|
defaultSession,
|
|
496
1049
|
transportOverride,
|
|
1050
|
+
world,
|
|
1051
|
+
defaultActor,
|
|
497
1052
|
})
|
|
498
1053
|
},
|
|
499
1054
|
|
|
@@ -508,6 +1063,8 @@ function createRequestClient({
|
|
|
508
1063
|
...session,
|
|
509
1064
|
},
|
|
510
1065
|
transportOverride,
|
|
1066
|
+
world,
|
|
1067
|
+
defaultActor,
|
|
511
1068
|
})
|
|
512
1069
|
},
|
|
513
1070
|
|
|
@@ -519,6 +1076,8 @@ function createRequestClient({
|
|
|
519
1076
|
defaultHeaders,
|
|
520
1077
|
defaultSession,
|
|
521
1078
|
transportOverride: transport,
|
|
1079
|
+
world,
|
|
1080
|
+
defaultActor,
|
|
522
1081
|
})
|
|
523
1082
|
},
|
|
524
1083
|
|
|
@@ -527,16 +1086,35 @@ function createRequestClient({
|
|
|
527
1086
|
return this
|
|
528
1087
|
}
|
|
529
1088
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
1089
|
+
if (typeof actor === 'string') {
|
|
1090
|
+
if (looksLikeEmail(actor)) {
|
|
1091
|
+
return createRequestClient({
|
|
1092
|
+
sails,
|
|
1093
|
+
getConfig,
|
|
1094
|
+
fetchImplementation,
|
|
1095
|
+
defaultHeaders,
|
|
1096
|
+
defaultSession,
|
|
1097
|
+
transportOverride,
|
|
1098
|
+
world,
|
|
1099
|
+
defaultActor: actor,
|
|
1100
|
+
})
|
|
537
1101
|
}
|
|
538
1102
|
|
|
539
|
-
|
|
1103
|
+
const resolvedActor = resolveWorldActor({ actor, world, sails, getConfig })
|
|
1104
|
+
|
|
1105
|
+
if (!resolvedActor) {
|
|
1106
|
+
throw createRequestActorUnresolvedError({
|
|
1107
|
+
actor,
|
|
1108
|
+
world,
|
|
1109
|
+
sails,
|
|
1110
|
+
getConfig,
|
|
1111
|
+
})
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
return withActor(resolvedActor)
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
return withActor(actor)
|
|
540
1118
|
},
|
|
541
1119
|
}
|
|
542
1120
|
}
|