sounding 0.0.0 → 0.0.2

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/index.js CHANGED
@@ -1,4 +1,75 @@
1
- module.exports = {
2
- name: 'sounding',
3
- version: '0.0.0'
1
+ const { createRuntime } = require('./lib/create-runtime')
2
+ const { createAppManager } = require('./lib/create-app-manager')
3
+ const { createMailbox } = require('./lib/create-mailbox')
4
+ const { createMailCapture } = require('./lib/create-mail-capture')
5
+ const { createWorldEngine } = require('./lib/create-world-engine')
6
+ const { loadWorldFiles } = require('./lib/create-world-loader')
7
+ const { defineFactory, defineScenario } = require('./lib/define-world')
8
+ const { createHelperRunner } = require('./lib/create-helper-runner')
9
+ const { createRequestClient } = require('./lib/create-request-client')
10
+ const { createVisitClient } = require('./lib/create-visit-client')
11
+ const { createBrowserManager } = require('./lib/create-browser-manager')
12
+ const { createAuthHelpers } = require('./lib/create-auth-helpers')
13
+ const { createExpect } = require('./lib/create-expect')
14
+ const { createTestApi } = require('./lib/create-test-api')
15
+ const { getDefaultConfig } = require('./lib/default-config')
16
+
17
+ function isProductionEnvironment(sails) {
18
+ return (sails.config?.environment || process.env.NODE_ENV) === 'production'
4
19
  }
20
+
21
+ function shouldEnableHook(sails) {
22
+ return sails.config?.sounding?.enableInProduction === true || !isProductionEnvironment(sails)
23
+ }
24
+
25
+ function soundingHook(sails) {
26
+ const runtime = createRuntime(sails)
27
+
28
+ return {
29
+ defaults: {
30
+ sounding: getDefaultConfig(),
31
+ },
32
+
33
+ configure() {
34
+ if (!shouldEnableHook(sails)) {
35
+ return
36
+ }
37
+
38
+ sails.hooks ||= {}
39
+ sails.sounding = runtime
40
+ sails.hooks.sounding = this
41
+ runtime.configure()
42
+ },
43
+
44
+ initialize(done) {
45
+ if (!shouldEnableHook(sails)) {
46
+ return done()
47
+ }
48
+
49
+ sails.sounding = runtime
50
+ Object.assign(this, runtime)
51
+ sails.hooks.sounding = this
52
+ return done()
53
+ },
54
+ }
55
+ }
56
+
57
+ module.exports = soundingHook
58
+ module.exports.test = createTestApi()
59
+ module.exports.expect = createExpect
60
+ module.exports.defineFactory = defineFactory
61
+ module.exports.defineScenario = defineScenario
62
+ module.exports.createRuntime = createRuntime
63
+ module.exports.createAppManager = createAppManager
64
+ module.exports.createMailbox = createMailbox
65
+ module.exports.createWorldEngine = createWorldEngine
66
+ module.exports.loadWorldFiles = loadWorldFiles
67
+ module.exports.createHelperRunner = createHelperRunner
68
+ module.exports.createRequestClient = createRequestClient
69
+ module.exports.createVisitClient = createVisitClient
70
+ module.exports.createBrowserManager = createBrowserManager
71
+ module.exports.createAuthHelpers = createAuthHelpers
72
+ module.exports.createExpect = createExpect
73
+ module.exports.createTestApi = createTestApi
74
+ module.exports.getDefaultConfig = getDefaultConfig
75
+ module.exports.createMailCapture = createMailCapture
@@ -0,0 +1,329 @@
1
+ const fs = require('node:fs')
2
+ const path = require('node:path')
3
+
4
+ const { getDefaultConfig } = require('./default-config')
5
+ const { mergeConfig } = require('./merge-config')
6
+ const { normalizeUserConfig } = require('./normalize-config')
7
+ const { buildManagedSqlitePath, resolveManagedRoot } = require('./resolve-datastore')
8
+
9
+ function resolveModuleFromApp(appPath, moduleId) {
10
+ return require(require.resolve(moduleId, { paths: [appPath, process.cwd(), __dirname] }))
11
+ }
12
+
13
+ function loadAppSoundingConfig(appPath) {
14
+ const configPath = path.join(appPath, 'config', 'sounding.js')
15
+
16
+ if (!fs.existsSync(configPath)) {
17
+ return getDefaultConfig()
18
+ }
19
+
20
+ delete require.cache[require.resolve(configPath)]
21
+ const loaded = require(configPath)
22
+ return mergeConfig(getDefaultConfig(), normalizeUserConfig(loaded?.sounding || {}))
23
+ }
24
+
25
+ function buildManagedDatastoreOverrides(config, appPath) {
26
+ if (config.datastore?.mode !== 'managed') {
27
+ return {}
28
+ }
29
+
30
+ const identity = config.datastore.identity || 'default'
31
+
32
+ return {
33
+ datastores: {
34
+ [identity]: {
35
+ adapter: config.datastore.adapter || 'sails-sqlite',
36
+ url: buildManagedSqlitePath({
37
+ root: resolveManagedRoot({
38
+ datastore: config.datastore,
39
+ appPath,
40
+ }),
41
+ identity,
42
+ isolation: config.datastore.isolation || 'worker',
43
+ }),
44
+ },
45
+ },
46
+ models: {
47
+ migrate: 'drop',
48
+ },
49
+ }
50
+ }
51
+
52
+ function createOutputFilter(config = {}) {
53
+ if (config.app?.quiet === false) {
54
+ return {
55
+ install() {},
56
+ uninstall() {},
57
+ }
58
+ }
59
+
60
+ const noisyPatterns = [
61
+ /start\s+build started/i,
62
+ /ready\s+built in/i,
63
+ /\[DEP0044\].*util\.isArray/i,
64
+ /node --trace-deprecation/i,
65
+ /^\s*info:\s/m,
66
+ /success:\s*true.*no new issues to notify/i,
67
+ ]
68
+
69
+ let installed = false
70
+ let originalStdoutWrite = null
71
+ let originalStderrWrite = null
72
+
73
+ function shouldSuppress(chunk) {
74
+ const value = String(chunk || '')
75
+ const normalizedValue = value.replace(/\u001b\[[0-9;]*m/g, '')
76
+ return noisyPatterns.some((pattern) => pattern.test(normalizedValue))
77
+ }
78
+
79
+ function wrapWrite(write) {
80
+ return function soundingWrite(chunk, encoding, callback) {
81
+ if (shouldSuppress(chunk)) {
82
+ if (typeof encoding === 'function') {
83
+ encoding()
84
+ }
85
+
86
+ if (typeof callback === 'function') {
87
+ callback()
88
+ }
89
+
90
+ return true
91
+ }
92
+
93
+ return write.call(this, chunk, encoding, callback)
94
+ }
95
+ }
96
+
97
+ return {
98
+ install() {
99
+ if (installed) {
100
+ return
101
+ }
102
+
103
+ originalStdoutWrite = process.stdout.write
104
+ originalStderrWrite = process.stderr.write
105
+ process.stdout.write = wrapWrite(originalStdoutWrite)
106
+ process.stderr.write = wrapWrite(originalStderrWrite)
107
+ installed = true
108
+ },
109
+
110
+ uninstall() {
111
+ if (!installed) {
112
+ return
113
+ }
114
+
115
+ process.stdout.write = originalStdoutWrite
116
+ process.stderr.write = originalStderrWrite
117
+ originalStdoutWrite = null
118
+ originalStderrWrite = null
119
+ installed = false
120
+ },
121
+ }
122
+ }
123
+
124
+ function resolveManagedDatastoreFile(config, appPath) {
125
+ if (config.datastore?.mode !== 'managed') {
126
+ return null
127
+ }
128
+
129
+ const identity = config.datastore.identity || 'default'
130
+
131
+ return buildManagedSqlitePath({
132
+ root: resolveManagedRoot({
133
+ datastore: config.datastore,
134
+ appPath,
135
+ }),
136
+ identity,
137
+ isolation: config.datastore.isolation || 'worker',
138
+ })
139
+ }
140
+
141
+ function createAppManager({
142
+ appPath = process.cwd(),
143
+ environment = 'test',
144
+ liftOptions = {},
145
+ SailsConstructor,
146
+ } = {}) {
147
+ let loadedApp = null
148
+ let liftedApp = null
149
+ let loadPromise = null
150
+ let liftPromise = null
151
+ const managedArtifacts = new Set()
152
+ let outputFilter = null
153
+
154
+ function resolveAppPath() {
155
+ return path.resolve(appPath)
156
+ }
157
+
158
+ function resolveConfig() {
159
+ return loadAppSoundingConfig(resolveAppPath())
160
+ }
161
+
162
+ function resolveSailsConstructor() {
163
+ if (SailsConstructor) {
164
+ return SailsConstructor
165
+ }
166
+
167
+ return resolveModuleFromApp(resolveAppPath(), 'sails').constructor
168
+ }
169
+
170
+ function buildOptions(mode) {
171
+ const config = resolveConfig()
172
+ outputFilter ||= createOutputFilter(config)
173
+ const managedFile = resolveManagedDatastoreFile(config, resolveAppPath())
174
+
175
+ if (managedFile) {
176
+ managedArtifacts.add(managedFile)
177
+ }
178
+
179
+ const appConfig = config.app || {}
180
+ const baseOptions = {
181
+ appPath: resolveAppPath(),
182
+ environment: appConfig.environment || environment,
183
+ ...buildManagedDatastoreOverrides(config, resolveAppPath()),
184
+ }
185
+ const modeOptions =
186
+ mode === 'load'
187
+ ? mergeConfig(
188
+ {
189
+ hooks: {
190
+ shipwright: false,
191
+ content: false,
192
+ },
193
+ },
194
+ appConfig.loadOptions || {}
195
+ )
196
+ : mergeConfig(appConfig.liftOptions || {}, liftOptions)
197
+ const nextOptions = mergeConfig(baseOptions, modeOptions)
198
+
199
+ if (mode === 'load' && nextOptions.datastores?.content) {
200
+ delete nextOptions.datastores.content
201
+ }
202
+
203
+ return nextOptions
204
+ }
205
+
206
+ async function cleanupManagedArtifacts() {
207
+ for (const filePath of managedArtifacts) {
208
+ const companionPaths = [
209
+ filePath,
210
+ `${filePath}-journal`,
211
+ `${filePath}-wal`,
212
+ `${filePath}-shm`,
213
+ ]
214
+
215
+ for (const artifactPath of companionPaths) {
216
+ await fs.promises.rm(artifactPath, { force: true }).catch(() => {})
217
+ }
218
+ }
219
+ }
220
+
221
+ async function load() {
222
+ if (loadedApp) {
223
+ return loadedApp
224
+ }
225
+
226
+ if (loadPromise) {
227
+ return loadPromise
228
+ }
229
+
230
+ const Sails = resolveSailsConstructor()
231
+ const sailsApp = new Sails()
232
+ const nextLoadOptions = buildOptions('load')
233
+ outputFilter?.install()
234
+
235
+ loadPromise = new Promise((resolve, reject) => {
236
+ sailsApp.load(nextLoadOptions, (error, loadedSails) => {
237
+ if (error) {
238
+ loadPromise = null
239
+ cleanupManagedArtifacts().catch(() => {})
240
+ outputFilter?.uninstall()
241
+ reject(error)
242
+ return
243
+ }
244
+
245
+ loadedApp = loadedSails
246
+ globalThis.sails = loadedSails
247
+ globalThis.sounding = loadedSails.sounding
248
+ resolve(loadedSails)
249
+ })
250
+ })
251
+
252
+ return loadPromise
253
+ }
254
+
255
+ async function lift() {
256
+ if (liftedApp) {
257
+ return liftedApp
258
+ }
259
+
260
+ if (liftPromise) {
261
+ return liftPromise
262
+ }
263
+
264
+ const Sails = resolveSailsConstructor()
265
+ const sailsApp = new Sails()
266
+ const nextLiftOptions = buildOptions('lift')
267
+ outputFilter?.install()
268
+
269
+ liftPromise = new Promise((resolve, reject) => {
270
+ sailsApp.lift(nextLiftOptions, (error, liftedSails) => {
271
+ if (error) {
272
+ liftPromise = null
273
+ cleanupManagedArtifacts().catch(() => {})
274
+ outputFilter?.uninstall()
275
+ reject(error)
276
+ return
277
+ }
278
+
279
+ liftedApp = liftedSails
280
+ globalThis.sails = liftedSails
281
+ globalThis.sounding = liftedSails.sounding
282
+ resolve(liftedSails)
283
+ })
284
+ })
285
+
286
+ return liftPromise
287
+ }
288
+
289
+ async function runtime(options = {}) {
290
+ const app = options.http ? await lift() : await load()
291
+ return app.sounding || app.hooks?.sounding
292
+ }
293
+
294
+ async function lower() {
295
+ const apps = [loadedApp, liftedApp].filter(Boolean)
296
+
297
+ loadedApp = null
298
+ liftedApp = null
299
+ loadPromise = null
300
+ liftPromise = null
301
+
302
+ await Promise.all(
303
+ apps.map(
304
+ (app) =>
305
+ new Promise((resolve) => {
306
+ app.lower(() => resolve())
307
+ })
308
+ )
309
+ )
310
+
311
+ delete globalThis.sails
312
+ delete globalThis.sounding
313
+ outputFilter?.uninstall()
314
+ await cleanupManagedArtifacts()
315
+ }
316
+
317
+ return {
318
+ load,
319
+ lift,
320
+ runtime,
321
+ lower,
322
+ resolveConfig,
323
+ }
324
+ }
325
+
326
+ module.exports = {
327
+ createAppManager,
328
+ loadAppSoundingConfig,
329
+ }
@@ -0,0 +1,279 @@
1
+ const { resolveAuthConfig } = require('./resolve-auth-config')
2
+
3
+ function normalizeEmail(value) {
4
+ return String(value || '')
5
+ .trim()
6
+ .toLowerCase()
7
+ }
8
+
9
+ function looksLikeEmail(value) {
10
+ return typeof value === 'string' && value.includes('@')
11
+ }
12
+
13
+ function createAuthHelpers({ sails, world, mailbox, request }) {
14
+ function getAuthConfig() {
15
+ return resolveAuthConfig({ sails })
16
+ }
17
+
18
+ function getAuthModel() {
19
+ return getAuthConfig().model
20
+ }
21
+
22
+ function resolveWorldActor(alias) {
23
+ if (!alias || typeof alias !== 'string') {
24
+ return null
25
+ }
26
+
27
+ const auth = getAuthConfig()
28
+
29
+ return (
30
+ world.current?.[auth.worldCollection]?.[alias] ||
31
+ world.current?.users?.[alias] ||
32
+ world.current?.creators?.[alias] ||
33
+ null
34
+ )
35
+ }
36
+
37
+ async function createUserFromEmail(email, fullName) {
38
+ const auth = getAuthConfig()
39
+ const User = getAuthModel()
40
+
41
+ if (auth.modelIdentity !== 'user') {
42
+ throw new Error(
43
+ `Sounding auth helpers could not auto-create a missing ${auth.modelIdentity} record.`
44
+ )
45
+ }
46
+
47
+ if (!sails?.helpers?.user?.signupWithTeam) {
48
+ throw new Error(
49
+ 'Sounding auth helpers could not find `sails.helpers.user.signupWithTeam`.'
50
+ )
51
+ }
52
+
53
+ const signupResult = await sails.helpers.user.signupWithTeam.with({
54
+ fullName: fullName || normalizeEmail(email).split('@')[0],
55
+ email: normalizeEmail(email),
56
+ tosAcceptedByIp: '127.0.0.1',
57
+ emailStatus: 'verified',
58
+ })
59
+
60
+ if (User?.updateOne) {
61
+ await User.updateOne({ id: signupResult.user.id }).set({
62
+ emailStatus: 'verified',
63
+ })
64
+ }
65
+
66
+ if (User?.findOne) {
67
+ return User.findOne({ id: signupResult.user.id })
68
+ }
69
+
70
+ return signupResult.user
71
+ }
72
+
73
+ async function resolveActor(actorOrEmail, options = {}) {
74
+ const auth = getAuthConfig()
75
+ const User = getAuthModel()
76
+
77
+ if (!actorOrEmail) {
78
+ throw new Error('Sounding auth helpers require an actor or email address.')
79
+ }
80
+
81
+ let candidate = actorOrEmail
82
+
83
+ if (typeof candidate === 'string' && !looksLikeEmail(candidate)) {
84
+ candidate = resolveWorldActor(candidate) || candidate
85
+ }
86
+
87
+ if (candidate?.id && User?.findOne) {
88
+ return User.findOne({ id: candidate.id }) || candidate
89
+ }
90
+
91
+ const email = candidate?.email || (looksLikeEmail(candidate) ? candidate : null)
92
+
93
+ if (!email) {
94
+ throw new Error(
95
+ `Sounding auth helpers could not resolve an email address for actor \`${candidate}\`.`
96
+ )
97
+ }
98
+
99
+ const normalizedEmail = normalizeEmail(email)
100
+ let user = User?.findOne ? await User.findOne({ email: normalizedEmail }) : null
101
+
102
+ if (!user && options.createIfMissing !== false) {
103
+ user = await createUserFromEmail(normalizedEmail, candidate?.fullName || options.fullName)
104
+ }
105
+
106
+ if (!user) {
107
+ throw new Error(
108
+ `Sounding auth helpers could not find a ${auth.modelIdentity} for ${normalizedEmail}.`
109
+ )
110
+ }
111
+
112
+ return user
113
+ }
114
+
115
+ async function issueMagicLink(actorOrEmail, options = {}) {
116
+ const User = getAuthModel()
117
+
118
+ if (!User?.updateOne) {
119
+ throw new Error('Sounding auth helpers require an auth model with updateOne().')
120
+ }
121
+
122
+ const user = await resolveActor(actorOrEmail, {
123
+ createIfMissing: true,
124
+ ...options,
125
+ })
126
+ const token = await sails.helpers.magicLink.generateToken()
127
+ const hashedToken = await sails.helpers.magicLink.hashToken(token)
128
+
129
+ await User.updateOne({ id: user.id }).set({
130
+ emailStatus: 'verified',
131
+ magicLinkToken: hashedToken,
132
+ magicLinkTokenExpiresAt: Date.now() + 15 * 60 * 1000,
133
+ magicLinkTokenUsedAt: null,
134
+ })
135
+
136
+ const refreshedUser = User.findOne ? await User.findOne({ id: user.id }) : user
137
+
138
+ return {
139
+ user: refreshedUser,
140
+ email: refreshedUser.email,
141
+ token,
142
+ url: `/magic-link/${token}`,
143
+ }
144
+ }
145
+
146
+ async function requestMagicLink(actorOrEmail, options = {}) {
147
+ const user = await resolveActor(actorOrEmail, {
148
+ createIfMissing: true,
149
+ ...options,
150
+ })
151
+
152
+ const response = await request.post(
153
+ '/magic-link',
154
+ {
155
+ email: user.email,
156
+ fullName: options.fullName || user.fullName,
157
+ redirectUrl: options.redirectUrl || '/login',
158
+ },
159
+ options.requestOptions || {}
160
+ )
161
+
162
+ return {
163
+ response,
164
+ email: user.email,
165
+ message: mailbox.latest(),
166
+ url: mailbox.latest()?.ctaUrl,
167
+ }
168
+ }
169
+
170
+ async function loginWithPassword(actorOrEmail, page, options = {}) {
171
+ if (!page || typeof page.goto !== 'function') {
172
+ throw new Error('Sounding password browser login requires a Playwright page.')
173
+ }
174
+
175
+ const auth = getAuthConfig()
176
+ const actor = await resolveActor(actorOrEmail, {
177
+ createIfMissing: false,
178
+ ...options,
179
+ })
180
+ const email = normalizeEmail(actor?.email || actorOrEmail)
181
+
182
+ if (!options.password) {
183
+ throw new Error('Sounding password login requires a `password` option.')
184
+ }
185
+
186
+ const loginUrl = new URL(auth.password.pagePath, 'http://sounding.local')
187
+
188
+ for (const [key, value] of Object.entries(auth.password.pageQuery || {})) {
189
+ if (value != null) {
190
+ loginUrl.searchParams.set(key, String(value))
191
+ }
192
+ }
193
+
194
+ if (options.returnUrl) {
195
+ loginUrl.searchParams.set(auth.password.form.returnUrl, options.returnUrl)
196
+ }
197
+
198
+ await page.goto(`${loginUrl.pathname}${loginUrl.search}`)
199
+ await page.fill(auth.password.selectors.email, email)
200
+ await page.fill(auth.password.selectors.password, options.password)
201
+
202
+ if (options.rememberMe && typeof page.check === 'function') {
203
+ await page.check(auth.password.selectors.rememberMe)
204
+ }
205
+
206
+ await page.click(auth.password.selectors.submit)
207
+
208
+ return {
209
+ actor,
210
+ email,
211
+ path: `${loginUrl.pathname}${loginUrl.search}`,
212
+ }
213
+ }
214
+
215
+ async function requestWithPassword(actorOrEmail, options = {}) {
216
+ const auth = getAuthConfig()
217
+ const actor = await resolveActor(actorOrEmail, {
218
+ createIfMissing: false,
219
+ ...options,
220
+ })
221
+ const email = normalizeEmail(actor?.email || actorOrEmail)
222
+
223
+ if (!options.password) {
224
+ throw new Error('Sounding password request auth requires a `password` option.')
225
+ }
226
+
227
+ const payload = {
228
+ [auth.password.form.email]: email,
229
+ [auth.password.form.password]: options.password,
230
+ }
231
+
232
+ if (options.rememberMe !== undefined) {
233
+ payload[auth.password.form.rememberMe] = options.rememberMe
234
+ }
235
+
236
+ if (options.returnUrl !== undefined) {
237
+ payload[auth.password.form.returnUrl] = options.returnUrl
238
+ }
239
+
240
+ const targetRequest = options.request || request
241
+ const response = await targetRequest.post(
242
+ auth.password.loginPath,
243
+ payload,
244
+ options.requestOptions || {}
245
+ )
246
+
247
+ return {
248
+ actor,
249
+ email,
250
+ request: targetRequest,
251
+ response,
252
+ }
253
+ }
254
+
255
+ const login = {
256
+ async as(actorOrEmail, page, options = {}) {
257
+ const magicLink = await issueMagicLink(actorOrEmail, options)
258
+ await page.goto(magicLink.url)
259
+ return magicLink
260
+ },
261
+ withPassword: loginWithPassword,
262
+ }
263
+
264
+ return {
265
+ conventions: getAuthConfig(),
266
+ resolveActor,
267
+ resolveUser: resolveActor,
268
+ issueMagicLink,
269
+ requestMagicLink,
270
+ request: {
271
+ withPassword: requestWithPassword,
272
+ },
273
+ login,
274
+ }
275
+ }
276
+
277
+ module.exports = {
278
+ createAuthHelpers,
279
+ }