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,228 @@
1
+ const nodeTest = require('node:test')
2
+ const { createAppManager } = require('./create-app-manager')
3
+ const { createExpect } = require('./create-expect')
4
+
5
+ let defaultAppManager = null
6
+ let defaultCleanupRegistered = false
7
+ let trialQueue = Promise.resolve()
8
+
9
+ function getDefaultAppManager() {
10
+ defaultAppManager ||= createAppManager()
11
+ return defaultAppManager
12
+ }
13
+
14
+ function ensureDefaultAppManagerCleanup() {
15
+ if (defaultCleanupRegistered || typeof nodeTest.after !== 'function') {
16
+ return
17
+ }
18
+
19
+ nodeTest.after(async () => {
20
+ if (defaultAppManager) {
21
+ await defaultAppManager.lower()
22
+ }
23
+ })
24
+
25
+ defaultCleanupRegistered = true
26
+ }
27
+
28
+ function normalizeTestArgs(title, optionsOrHandler, maybeHandler) {
29
+ if (typeof optionsOrHandler === 'function') {
30
+ return {
31
+ title,
32
+ options: {},
33
+ handler: optionsOrHandler,
34
+ }
35
+ }
36
+
37
+ return {
38
+ title,
39
+ options: optionsOrHandler || {},
40
+ handler: maybeHandler,
41
+ }
42
+ }
43
+
44
+ async function resolveRuntimeFromGlobals(options = {}) {
45
+ const runtime = globalThis.sounding || globalThis.sails?.sounding || globalThis.sails?.hooks?.sounding
46
+ const requiresHttp = Boolean(options.http || options.browser)
47
+ const httpServer = globalThis.sails?.hooks?.http?.server
48
+ const hasHttpServer = Boolean(
49
+ httpServer &&
50
+ (httpServer.listening ||
51
+ (typeof httpServer.address === 'function' && httpServer.address()))
52
+ )
53
+
54
+ if (runtime && (!requiresHttp || hasHttpServer)) {
55
+ return {
56
+ sounding: runtime,
57
+ teardown: async () => runtime.lower(),
58
+ }
59
+ }
60
+
61
+ const appManager = getDefaultAppManager()
62
+ ensureDefaultAppManagerCleanup()
63
+ const sounding = await appManager.runtime({ http: requiresHttp })
64
+ return {
65
+ sounding,
66
+ teardown: async () => sounding.lower(),
67
+ }
68
+ }
69
+
70
+ function bindRequestMethod(request, method) {
71
+ return typeof request?.[method] === 'function' ? request[method].bind(request) : undefined
72
+ }
73
+
74
+ function splitTestOptions(options = {}) {
75
+ const {
76
+ transport,
77
+ browser,
78
+ ...nodeOptions
79
+ } = options
80
+
81
+ return {
82
+ nodeOptions: {
83
+ concurrency: false,
84
+ ...nodeOptions,
85
+ },
86
+ trialOptions: {
87
+ transport,
88
+ browser,
89
+ },
90
+ }
91
+ }
92
+
93
+ async function runInTrialQueue(handler) {
94
+ const previous = trialQueue
95
+ let release = () => {}
96
+
97
+ trialQueue = new Promise((resolve) => {
98
+ release = resolve
99
+ })
100
+
101
+ await previous
102
+
103
+ try {
104
+ return await handler()
105
+ } finally {
106
+ release()
107
+ }
108
+ }
109
+
110
+ async function runTrial({ runtime, mode, nodeContext, handler, options = {} }) {
111
+ const activeRuntime = typeof runtime === 'function' ? await runtime() : runtime
112
+ const resolved = activeRuntime
113
+ ? {
114
+ sounding: activeRuntime,
115
+ teardown: async () => activeRuntime.lower(),
116
+ }
117
+ : await resolveRuntimeFromGlobals({
118
+ http: options.transport === 'http',
119
+ browser: Boolean(options.browser),
120
+ })
121
+ const sounding = resolved.sounding
122
+ const booted = await sounding.boot({ mode })
123
+ const sails = booted.sails || {}
124
+
125
+ sails.sounding ||= sounding
126
+ sails.hooks ||= {}
127
+ sails.hooks.sounding ||= sounding
128
+ sails.helpers ||= sounding.helpers
129
+
130
+ const request = options.transport ? sounding.request.using(options.transport) : sounding.request
131
+ const visit = options.transport ? sounding.visit.using(options.transport) : sounding.visit
132
+
133
+ let browserSession = null
134
+ if (options.browser) {
135
+ browserSession = await sounding.browser.open(options.browser === true ? {} : options.browser)
136
+ }
137
+
138
+ const expect = browserSession?.expect
139
+ ? createExpect.withFallback(browserSession.expect)
140
+ : createExpect
141
+
142
+ const context = {
143
+ ...nodeContext,
144
+ t: nodeContext,
145
+ expect,
146
+ sails,
147
+ request,
148
+ visit,
149
+ auth: sounding.auth,
150
+ login: sounding.auth?.login,
151
+ world: sounding.world,
152
+ mailbox: sounding.mailbox,
153
+ browser: browserSession?.browser,
154
+ browserContext: browserSession?.context,
155
+ page: browserSession?.page,
156
+ get: bindRequestMethod(request, 'get'),
157
+ head: bindRequestMethod(request, 'head'),
158
+ post: bindRequestMethod(request, 'post'),
159
+ put: bindRequestMethod(request, 'put'),
160
+ patch: bindRequestMethod(request, 'patch'),
161
+ del: bindRequestMethod(request, 'delete'),
162
+ }
163
+
164
+ try {
165
+ return await handler(context)
166
+ } finally {
167
+ await resolved.teardown()
168
+ }
169
+ }
170
+
171
+ function createTrialMethod(baseTest, runtime, mode) {
172
+ return function registerTrial(title, optionsOrHandler, maybeHandler) {
173
+ const { options, handler } = normalizeTestArgs(title, optionsOrHandler, maybeHandler)
174
+ const { nodeOptions, trialOptions } = splitTestOptions(options)
175
+
176
+ return baseTest(title, nodeOptions, async (nodeContext) => {
177
+ return runInTrialQueue(async () => {
178
+ return runTrial({
179
+ runtime,
180
+ mode,
181
+ nodeContext,
182
+ handler,
183
+ options: trialOptions,
184
+ })
185
+ })
186
+ })
187
+ }
188
+ }
189
+
190
+ function createTestApi({ baseTest = nodeTest, runtime } = {}) {
191
+ function soundingTest(title, optionsOrHandler, maybeHandler) {
192
+ const { options, handler } = normalizeTestArgs(title, optionsOrHandler, maybeHandler)
193
+ const { nodeOptions, trialOptions } = splitTestOptions(options)
194
+
195
+ return baseTest(title, nodeOptions, async (nodeContext) => {
196
+ return runInTrialQueue(async () => {
197
+ return runTrial({
198
+ runtime,
199
+ mode: 'trial',
200
+ nodeContext,
201
+ handler,
202
+ options: trialOptions,
203
+ })
204
+ })
205
+ })
206
+ }
207
+
208
+ soundingTest.skip = (...args) => baseTest.skip(...args)
209
+ soundingTest.todo = (...args) => baseTest.todo(...args)
210
+ if (typeof baseTest.only === 'function') {
211
+ soundingTest.only = (...args) => baseTest.only(...args)
212
+ }
213
+
214
+ // Temporary compatibility aliases while the public docs move fully to `test()`.
215
+ soundingTest.helper = createTrialMethod(baseTest, runtime, 'helper')
216
+ soundingTest.endpoint = createTrialMethod(baseTest, runtime, 'endpoint')
217
+
218
+ return soundingTest
219
+ }
220
+
221
+ module.exports = {
222
+ createTestApi,
223
+ normalizeTestArgs,
224
+ resolveRuntimeFromGlobals,
225
+ runInTrialQueue,
226
+ runTrial,
227
+ splitTestOptions,
228
+ }
@@ -0,0 +1,114 @@
1
+ const DEFAULT_HEADERS = {
2
+ 'x-inertia': 'true',
3
+ 'x-requested-with': 'XMLHttpRequest',
4
+ accept: 'text/html, application/xhtml+xml',
5
+ }
6
+
7
+ function joinHeaderValue(value) {
8
+ return Array.isArray(value) ? value.join(',') : value
9
+ }
10
+
11
+ function buildVisitHeaders(options = {}) {
12
+ const headers = {
13
+ ...(options.headers || {}),
14
+ }
15
+
16
+ if (options.version) {
17
+ headers['x-inertia-version'] = options.version
18
+ }
19
+
20
+ if (options.errorBag) {
21
+ headers['x-inertia-error-bag'] = options.errorBag
22
+ }
23
+
24
+ if (options.only?.length) {
25
+ if (!options.component) {
26
+ throw new Error('Sounding visit() requires `component` when using `only`.')
27
+ }
28
+
29
+ headers['x-inertia-partial-component'] = options.component
30
+ headers['x-inertia-partial-data'] = joinHeaderValue(options.only)
31
+ }
32
+
33
+ if (options.except?.length) {
34
+ if (!options.component) {
35
+ throw new Error('Sounding visit() requires `component` when using `except`.')
36
+ }
37
+
38
+ headers['x-inertia-partial-component'] = options.component
39
+ headers['x-inertia-partial-except'] = joinHeaderValue(options.except)
40
+ }
41
+
42
+ if (options.reset?.length) {
43
+ headers['x-inertia-reset'] = joinHeaderValue(options.reset)
44
+ }
45
+
46
+ return headers
47
+ }
48
+
49
+ function buildRequestOptions(options = {}) {
50
+ const {
51
+ component,
52
+ only,
53
+ except,
54
+ reset,
55
+ errorBag,
56
+ version,
57
+ ...requestOptions
58
+ } = options
59
+
60
+ const visitHeaders = buildVisitHeaders({
61
+ component,
62
+ only,
63
+ except,
64
+ reset,
65
+ errorBag,
66
+ version,
67
+ headers: requestOptions.headers,
68
+ })
69
+
70
+ const output = {
71
+ ...requestOptions,
72
+ }
73
+
74
+ if (Object.keys(visitHeaders).length > 0) {
75
+ output.headers = visitHeaders
76
+ }
77
+
78
+ return output
79
+ }
80
+
81
+ function createVisitClient({ request }) {
82
+ const client = request.withHeaders(DEFAULT_HEADERS)
83
+
84
+ function visit(target, options = {}) {
85
+ return client.get(target, buildRequestOptions(options))
86
+ }
87
+
88
+ visit.get = (target, options = {}) => client.get(target, buildRequestOptions(options))
89
+ visit.head = (target, options = {}) => client.head(target, buildRequestOptions(options))
90
+ visit.post = (target, payload, options = {}) => client.post(target, payload, buildRequestOptions(options))
91
+ visit.put = (target, payload, options = {}) => client.put(target, payload, buildRequestOptions(options))
92
+ visit.patch = (target, payload, options = {}) =>
93
+ client.patch(target, payload, buildRequestOptions(options))
94
+ visit.delete = (target, payload, options = {}) =>
95
+ client.delete(target, payload, buildRequestOptions(options))
96
+ visit.del = visit.delete
97
+ visit.using = (transport) => createVisitClient({ request: request.using(transport) })
98
+
99
+ Object.defineProperty(visit, 'transport', {
100
+ enumerable: true,
101
+ get() {
102
+ return client.transport
103
+ },
104
+ })
105
+
106
+ return visit
107
+ }
108
+
109
+ module.exports = {
110
+ createVisitClient,
111
+ DEFAULT_HEADERS,
112
+ buildVisitHeaders,
113
+ buildRequestOptions,
114
+ }
@@ -0,0 +1,300 @@
1
+ const {
2
+ isFactoryDefinition,
3
+ isScenarioDefinition,
4
+ } = require('./define-world')
5
+
6
+ function mergeValue(base, patch) {
7
+ if (typeof patch === 'function') {
8
+ return patch(base)
9
+ }
10
+
11
+ return {
12
+ ...base,
13
+ ...(patch || {}),
14
+ }
15
+ }
16
+
17
+ function createFakeHelpers({ sequence }) {
18
+ return {
19
+ person: {
20
+ fullName() {
21
+ return `Test User ${sequence('fake-person')}`
22
+ },
23
+ },
24
+ internet: {
25
+ email() {
26
+ return `user${sequence('fake-email')}@example.com`
27
+ },
28
+ },
29
+ lorem: {
30
+ words(count = 3) {
31
+ return Array.from({ length: count }, () => `word-${sequence('fake-word')}`).join(' ')
32
+ },
33
+ sentence(count = 6) {
34
+ return `${Array.from({ length: count }, () => `word-${sequence('fake-sentence')}`).join(' ')}.`
35
+ },
36
+ },
37
+ }
38
+ }
39
+
40
+ function createThenableBuilder(executor, initialOverrides = {}) {
41
+ const state = {
42
+ overrides: initialOverrides,
43
+ traits: [],
44
+ }
45
+
46
+ function run() {
47
+ return executor(state.overrides, {
48
+ traits: [...state.traits],
49
+ })
50
+ }
51
+
52
+ const builder = {
53
+ trait(name) {
54
+ state.traits.push(name)
55
+ return builder
56
+ },
57
+
58
+ traits(names = []) {
59
+ state.traits.push(...names)
60
+ return builder
61
+ },
62
+
63
+ with(overrides = {}) {
64
+ state.overrides = overrides
65
+ return builder
66
+ },
67
+
68
+ value() {
69
+ return run()
70
+ },
71
+
72
+ then(onFulfilled, onRejected) {
73
+ return Promise.resolve(run()).then(onFulfilled, onRejected)
74
+ },
75
+
76
+ catch(onRejected) {
77
+ return Promise.resolve(run()).catch(onRejected)
78
+ },
79
+
80
+ finally(onFinally) {
81
+ return Promise.resolve(run()).finally(onFinally)
82
+ },
83
+ }
84
+
85
+ return builder
86
+ }
87
+
88
+ function createWorldEngine({ sails }) {
89
+ const factories = new Map()
90
+ const scenarios = new Map()
91
+ const sequences = new Map()
92
+ let currentWorld = null
93
+ let currentSeed = null
94
+
95
+ function sequence(nameOrBuilder = 'default', maybeBuilder) {
96
+ const name = typeof nameOrBuilder === 'function' ? 'default' : nameOrBuilder
97
+ const builder = typeof nameOrBuilder === 'function' ? nameOrBuilder : maybeBuilder
98
+ const next = (sequences.get(name) || 0) + 1
99
+ sequences.set(name, next)
100
+ return typeof builder === 'function' ? builder(next) : next
101
+ }
102
+
103
+ function resolveFactory(name) {
104
+ const entry = factories.get(name)
105
+
106
+ if (!entry) {
107
+ throw new Error(`Unknown Sounding factory: ${name}`)
108
+ }
109
+
110
+ return entry
111
+ }
112
+
113
+ function buildOne(name, overrides = {}, options = {}) {
114
+ const entry = resolveFactory(name)
115
+ const helpers = {
116
+ fake: createFakeHelpers({ sequence }),
117
+ sequence,
118
+ seed: currentSeed,
119
+ sails,
120
+ }
121
+
122
+ let value =
123
+ typeof entry.definition === 'function'
124
+ ? entry.definition(helpers)
125
+ : { ...entry.definition }
126
+
127
+ for (const traitName of options.traits || []) {
128
+ if (!entry.traits.has(traitName)) {
129
+ throw new Error(`Unknown Sounding trait \`${traitName}\` for factory \`${name}\``)
130
+ }
131
+
132
+ value = mergeValue(value, entry.traits.get(traitName))
133
+ }
134
+
135
+ return {
136
+ ...value,
137
+ ...overrides,
138
+ }
139
+ }
140
+
141
+ async function createOne(name, overrides = {}, options = {}) {
142
+ const value = buildOne(name, overrides, options)
143
+ const model = sails?.models?.[name]
144
+
145
+ if (model?.create) {
146
+ const query = model.create(value)
147
+ return typeof query.fetch === 'function' ? query.fetch() : query
148
+ }
149
+
150
+ return value
151
+ }
152
+
153
+ function registerFactoryDefinition(entry) {
154
+ const nextEntry = {
155
+ definition: entry.definition,
156
+ traits: new Map(entry.traits || []),
157
+ }
158
+
159
+ factories.set(entry.name, nextEntry)
160
+
161
+ return {
162
+ trait(traitName, patch) {
163
+ nextEntry.traits.set(traitName, patch)
164
+ return this
165
+ },
166
+ }
167
+ }
168
+
169
+ function registerScenarioDefinition(entry) {
170
+ scenarios.set(entry.name, entry.definition)
171
+ return entry
172
+ }
173
+
174
+ function defineFactory(nameOrEntry, definition) {
175
+ if (isFactoryDefinition(nameOrEntry)) {
176
+ return registerFactoryDefinition(nameOrEntry)
177
+ }
178
+
179
+ const entry = {
180
+ name: nameOrEntry,
181
+ definition,
182
+ traits: [],
183
+ }
184
+
185
+ return registerFactoryDefinition(entry)
186
+ }
187
+
188
+ function defineScenario(nameOrEntry, definition) {
189
+ if (isScenarioDefinition(nameOrEntry)) {
190
+ return registerScenarioDefinition(nameOrEntry)
191
+ }
192
+
193
+ return registerScenarioDefinition({
194
+ name: nameOrEntry,
195
+ definition,
196
+ })
197
+ }
198
+
199
+ async function use(name, context = {}) {
200
+ const definition = scenarios.get(name)
201
+
202
+ if (!definition) {
203
+ throw new Error(`Unknown Sounding scenario: ${name}`)
204
+ }
205
+
206
+ currentWorld = await definition({
207
+ build(name, overrides = {}) {
208
+ return createThenableBuilder(
209
+ (nextOverrides, options) => buildOne(name, nextOverrides, options),
210
+ overrides
211
+ )
212
+ },
213
+ create(name, overrides = {}) {
214
+ return createThenableBuilder(
215
+ (nextOverrides, options) => createOne(name, nextOverrides, options),
216
+ overrides
217
+ )
218
+ },
219
+ defineFactory,
220
+ defineScenario,
221
+ sails,
222
+ sequence,
223
+ seed: currentSeed,
224
+ context,
225
+ })
226
+
227
+ return currentWorld
228
+ }
229
+
230
+ return {
231
+ build(name, overrides = {}, options = {}) {
232
+ return buildOne(name, overrides, options)
233
+ },
234
+
235
+ async buildMany(name, count, overrides = {}, options = {}) {
236
+ return Array.from({ length: count }, () => buildOne(name, overrides, options))
237
+ },
238
+
239
+ create(name, overrides = {}, options = {}) {
240
+ return createOne(name, overrides, options)
241
+ },
242
+
243
+ async createMany(name, count, overrides = {}, options = {}) {
244
+ const records = []
245
+ for (let index = 0; index < count; index += 1) {
246
+ records.push(await createOne(name, overrides, options))
247
+ }
248
+ return records
249
+ },
250
+
251
+ defineFactory,
252
+ defineScenario,
253
+
254
+ register(definition) {
255
+ if (isFactoryDefinition(definition)) {
256
+ return defineFactory(definition)
257
+ }
258
+
259
+ if (isScenarioDefinition(definition)) {
260
+ return defineScenario(definition)
261
+ }
262
+
263
+ throw new Error('Sounding could not register an unknown world definition.')
264
+ },
265
+
266
+ get current() {
267
+ return currentWorld
268
+ },
269
+
270
+ get factories() {
271
+ return Array.from(factories.keys())
272
+ },
273
+
274
+ get scenarios() {
275
+ return Array.from(scenarios.keys())
276
+ },
277
+
278
+ reset(options = {}) {
279
+ const { preserveSequences = false } = options
280
+
281
+ factories.clear()
282
+ scenarios.clear()
283
+ if (!preserveSequences) {
284
+ sequences.clear()
285
+ }
286
+ currentWorld = null
287
+ currentSeed = null
288
+ },
289
+
290
+ seed(value) {
291
+ currentSeed = value
292
+ return currentSeed
293
+ },
294
+
295
+ sequence,
296
+ use,
297
+ }
298
+ }
299
+
300
+ module.exports = { createWorldEngine }