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.
package/index.js CHANGED
@@ -1,4 +1,59 @@
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 soundingHook(sails) {
18
+ const runtime = createRuntime(sails)
19
+
20
+ return {
21
+ defaults: {
22
+ sounding: getDefaultConfig(),
23
+ },
24
+
25
+ configure() {
26
+ sails.hooks ||= {}
27
+ sails.sounding = runtime
28
+ sails.hooks.sounding = this
29
+ runtime.configure()
30
+ },
31
+
32
+ initialize(done) {
33
+ sails.sounding = runtime
34
+ Object.assign(this, runtime)
35
+ sails.hooks.sounding = this
36
+ return done()
37
+ },
38
+ }
4
39
  }
40
+
41
+ module.exports = soundingHook
42
+ module.exports.test = createTestApi()
43
+ module.exports.expect = createExpect
44
+ module.exports.defineFactory = defineFactory
45
+ module.exports.defineScenario = defineScenario
46
+ module.exports.createRuntime = createRuntime
47
+ module.exports.createAppManager = createAppManager
48
+ module.exports.createMailbox = createMailbox
49
+ module.exports.createWorldEngine = createWorldEngine
50
+ module.exports.loadWorldFiles = loadWorldFiles
51
+ module.exports.createHelperRunner = createHelperRunner
52
+ module.exports.createRequestClient = createRequestClient
53
+ module.exports.createVisitClient = createVisitClient
54
+ module.exports.createBrowserManager = createBrowserManager
55
+ module.exports.createAuthHelpers = createAuthHelpers
56
+ module.exports.createExpect = createExpect
57
+ module.exports.createTestApi = createTestApi
58
+ module.exports.getDefaultConfig = getDefaultConfig
59
+ 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,159 @@
1
+ function normalizeEmail(value) {
2
+ return String(value || '')
3
+ .trim()
4
+ .toLowerCase()
5
+ }
6
+
7
+ function looksLikeEmail(value) {
8
+ return typeof value === 'string' && value.includes('@')
9
+ }
10
+
11
+ function createAuthHelpers({ sails, world, mailbox, request }) {
12
+ function getUserModel() {
13
+ return sails?.models?.user || sails?.models?.User
14
+ }
15
+
16
+ async function createUserFromEmail(email, fullName) {
17
+ const User = getUserModel()
18
+
19
+ if (!sails?.helpers?.user?.signupWithTeam) {
20
+ throw new Error(
21
+ 'Sounding auth helpers could not find `sails.helpers.user.signupWithTeam`.'
22
+ )
23
+ }
24
+
25
+ const signupResult = await sails.helpers.user.signupWithTeam.with({
26
+ fullName: fullName || normalizeEmail(email).split('@')[0],
27
+ email: normalizeEmail(email),
28
+ tosAcceptedByIp: '127.0.0.1',
29
+ emailStatus: 'verified',
30
+ })
31
+
32
+ if (User?.updateOne) {
33
+ await User.updateOne({ id: signupResult.user.id }).set({
34
+ emailStatus: 'verified',
35
+ })
36
+ }
37
+
38
+ if (User?.findOne) {
39
+ return User.findOne({ id: signupResult.user.id })
40
+ }
41
+
42
+ return signupResult.user
43
+ }
44
+
45
+ async function resolveUser(actorOrEmail, options = {}) {
46
+ const User = getUserModel()
47
+
48
+ if (!actorOrEmail) {
49
+ throw new Error('Sounding auth helpers require an actor, user, or email address.')
50
+ }
51
+
52
+ let candidate = actorOrEmail
53
+
54
+ if (typeof candidate === 'string' && world.current?.users?.[candidate]) {
55
+ candidate = world.current.users[candidate]
56
+ }
57
+
58
+ if (candidate?.id && User?.findOne) {
59
+ return User.findOne({ id: candidate.id }) || candidate
60
+ }
61
+
62
+ const email = candidate?.email || (looksLikeEmail(candidate) ? candidate : null)
63
+
64
+ if (!email) {
65
+ throw new Error(
66
+ `Sounding auth helpers could not resolve an email address for actor \`${candidate}\`.`
67
+ )
68
+ }
69
+
70
+ const normalizedEmail = normalizeEmail(email)
71
+ let user = User?.findOne ? await User.findOne({ email: normalizedEmail }) : null
72
+
73
+ if (!user && options.createIfMissing !== false) {
74
+ user = await createUserFromEmail(normalizedEmail, candidate?.fullName || options.fullName)
75
+ }
76
+
77
+ if (!user) {
78
+ throw new Error(
79
+ `Sounding auth helpers could not find a user for ${normalizedEmail}.`
80
+ )
81
+ }
82
+
83
+ return user
84
+ }
85
+
86
+ async function issueMagicLink(actorOrEmail, options = {}) {
87
+ const User = getUserModel()
88
+
89
+ if (!User?.updateOne) {
90
+ throw new Error('Sounding auth helpers require a User model with updateOne().')
91
+ }
92
+
93
+ const user = await resolveUser(actorOrEmail, {
94
+ createIfMissing: true,
95
+ ...options,
96
+ })
97
+ const token = await sails.helpers.magicLink.generateToken()
98
+ const hashedToken = await sails.helpers.magicLink.hashToken(token)
99
+
100
+ await User.updateOne({ id: user.id }).set({
101
+ emailStatus: 'verified',
102
+ magicLinkToken: hashedToken,
103
+ magicLinkTokenExpiresAt: Date.now() + 15 * 60 * 1000,
104
+ magicLinkTokenUsedAt: null,
105
+ })
106
+
107
+ const refreshedUser = User.findOne ? await User.findOne({ id: user.id }) : user
108
+
109
+ return {
110
+ user: refreshedUser,
111
+ email: refreshedUser.email,
112
+ token,
113
+ url: `/magic-link/${token}`,
114
+ }
115
+ }
116
+
117
+ async function requestMagicLink(actorOrEmail, options = {}) {
118
+ const user = await resolveUser(actorOrEmail, {
119
+ createIfMissing: true,
120
+ ...options,
121
+ })
122
+
123
+ const response = await request.post(
124
+ '/magic-link',
125
+ {
126
+ email: user.email,
127
+ fullName: options.fullName || user.fullName,
128
+ redirectUrl: options.redirectUrl || '/login',
129
+ },
130
+ options.requestOptions || {}
131
+ )
132
+
133
+ return {
134
+ response,
135
+ email: user.email,
136
+ message: mailbox.latest(),
137
+ url: mailbox.latest()?.ctaUrl,
138
+ }
139
+ }
140
+
141
+ const login = {
142
+ async as(actorOrEmail, page, options = {}) {
143
+ const magicLink = await issueMagicLink(actorOrEmail, options)
144
+ await page.goto(magicLink.url)
145
+ return magicLink
146
+ },
147
+ }
148
+
149
+ return {
150
+ resolveUser,
151
+ issueMagicLink,
152
+ requestMagicLink,
153
+ login,
154
+ }
155
+ }
156
+
157
+ module.exports = {
158
+ createAuthHelpers,
159
+ }
@@ -0,0 +1,132 @@
1
+ const { resolveBaseUrl } = require('./create-request-client')
2
+
3
+ function resolveModuleFromApp(appPath, moduleId) {
4
+ return require(require.resolve(moduleId, { paths: [appPath, process.cwd(), __dirname] }))
5
+ }
6
+
7
+ function defaultLoadPlaywright(appPath) {
8
+ return resolveModuleFromApp(appPath, 'playwright')
9
+ }
10
+
11
+ function defaultLoadPlaywrightTest(appPath) {
12
+ return resolveModuleFromApp(appPath, '@playwright/test')
13
+ }
14
+
15
+ function resolveProjectOptions(projectName, devices = {}) {
16
+ if (projectName === 'mobile') {
17
+ return (
18
+ devices['iPhone 13'] || {
19
+ viewport: {
20
+ width: 390,
21
+ height: 844,
22
+ },
23
+ isMobile: true,
24
+ hasTouch: true,
25
+ }
26
+ )
27
+ }
28
+
29
+ return {}
30
+ }
31
+
32
+ function createBrowserManager({
33
+ sails,
34
+ getConfig,
35
+ appPathResolver = () => sails?.config?.appPath || process.cwd(),
36
+ loadPlaywright = defaultLoadPlaywright,
37
+ loadPlaywrightTest = defaultLoadPlaywrightTest,
38
+ } = {}) {
39
+ let session = null
40
+
41
+ async function open(options = {}) {
42
+ if (session) {
43
+ return session
44
+ }
45
+
46
+ const config = typeof getConfig === 'function' ? getConfig() : sails?.config?.sounding || {}
47
+
48
+ if (config.browser?.enabled === false) {
49
+ throw new Error('Sounding browser support is disabled in `config/sounding.js`.')
50
+ }
51
+
52
+ const appPath = appPathResolver()
53
+ const playwright = await loadPlaywright(appPath)
54
+ const playwrightTest = await Promise.resolve()
55
+ .then(() => loadPlaywrightTest(appPath))
56
+ .catch(() => null)
57
+
58
+ const browserTypeName = options.type || config.browser?.type || 'chromium'
59
+ const browserType = playwright?.[browserTypeName]
60
+
61
+ if (!browserType?.launch) {
62
+ throw new Error(
63
+ `Sounding could not find a Playwright browser type named \`${browserTypeName}\`.`
64
+ )
65
+ }
66
+
67
+ const projectName =
68
+ options.project ||
69
+ config.browser?.defaultProject ||
70
+ config.browser?.projects?.[0] ||
71
+ 'desktop'
72
+
73
+ const browser = await browserType.launch({
74
+ headless: true,
75
+ ...(config.browser?.launchOptions || {}),
76
+ ...(options.launchOptions || {}),
77
+ })
78
+
79
+ const context = await browser.newContext({
80
+ baseURL: resolveBaseUrl({ sails, getConfig }),
81
+ ...resolveProjectOptions(projectName, playwright.devices || {}),
82
+ ...(options.contextOptions || {}),
83
+ })
84
+
85
+ const page = await context.newPage()
86
+
87
+ session = {
88
+ playwright,
89
+ browser,
90
+ context,
91
+ page,
92
+ expect: playwrightTest?.expect,
93
+ project: projectName,
94
+ }
95
+
96
+ return session
97
+ }
98
+
99
+ async function close() {
100
+ if (!session) {
101
+ return
102
+ }
103
+
104
+ await session.context?.close?.()
105
+ await session.browser?.close?.()
106
+ session = null
107
+ }
108
+
109
+ return {
110
+ open,
111
+ close,
112
+ get active() {
113
+ return Boolean(session?.page)
114
+ },
115
+ get page() {
116
+ return session?.page
117
+ },
118
+ get context() {
119
+ return session?.context
120
+ },
121
+ get expect() {
122
+ return session?.expect
123
+ },
124
+ }
125
+ }
126
+
127
+ module.exports = {
128
+ createBrowserManager,
129
+ defaultLoadPlaywright,
130
+ defaultLoadPlaywrightTest,
131
+ resolveProjectOptions,
132
+ }