sounding 0.0.1 → 0.0.3

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 CHANGED
@@ -14,6 +14,8 @@ The canonical Sails-native surface is:
14
14
  - `sails.sounding`
15
15
  - `sails.helpers.user.signupWithTeam(...)` inside trials
16
16
  - `get('/api/issues')` or `sails.sounding.request.get('/api/issues')` inside endpoint-style trials
17
+ - `await auth.login.withPassword('creator@example.com', page, { password: 'secret123' })` inside browser trials
18
+ - `await auth.request.withPassword('creator@example.com', { password: 'secret123' })` inside request trials
17
19
  - request helpers default to Sails virtual requests powered by `sails.request()`
18
20
  - Inertia-style visits can use `visit('/pricing')` and partial reload options like `{ component, only }`
19
21
  - a trial can opt into stricter parity with `test('...', { transport: 'http' }, ...)`
@@ -26,12 +28,26 @@ Sounding also owns its own built-in world engine, so the same package can:
26
28
  - capture outgoing mail by wrapping `sails.helpers.mail.send` and storing normalized messages in `sails.sounding.mailbox`
27
29
 
28
30
  The default configuration story is intentionally calm:
31
+ - Sounding only enables its hook in the environments listed under `sounding.environments`
32
+ - the default is `['test']`, so non-test boot paths stay dark unless you opt in explicitly
33
+ - if you intentionally need Sounding in another environment, add that environment name to the list
34
+ - auth conventions auto-detect `User`/`userId` and `Creator`/`creatorId`, with `sounding.auth` available for overrides
29
35
  - Sounding manages a temporary `sails-sqlite` datastore by default
30
36
  - managed SQLite artifacts live under `.tmp/db`
31
37
  - the default datastore identity is `default`
32
38
  - browser projects start with `desktop`
33
39
  - `inherit` remains available when an app already has a serious test datastore story
34
40
 
41
+ For example:
42
+
43
+ ```js
44
+ module.exports.sounding = {
45
+ environments: ['test'],
46
+ }
47
+ ```
48
+
49
+ If you intentionally want Sounding during another boot path, widen the list explicitly, for example `['test', 'console']` or `['test', 'production']`.
50
+
35
51
  This repository starts with docs-driven product research and the first hook/runtime scaffolding for that vision.
36
52
 
37
53
  See `RESEARCH.md`.
package/index.js CHANGED
@@ -14,15 +14,38 @@ const { createExpect } = require('./lib/create-expect')
14
14
  const { createTestApi } = require('./lib/create-test-api')
15
15
  const { getDefaultConfig } = require('./lib/default-config')
16
16
 
17
+ function getCurrentEnvironment(sails) {
18
+ return sails.config?.environment || process.env.NODE_ENV
19
+ }
20
+
21
+ function getEnabledEnvironments(sails) {
22
+ const configured = sails.config?.sounding?.environments
23
+
24
+ if (Array.isArray(configured)) {
25
+ return configured
26
+ }
27
+
28
+ return getDefaultConfig().environments
29
+ }
30
+
31
+ function shouldEnableHook(sails) {
32
+ return getEnabledEnvironments(sails).includes(getCurrentEnvironment(sails))
33
+ }
34
+
17
35
  function soundingHook(sails) {
18
36
  const runtime = createRuntime(sails)
19
37
 
20
38
  return {
21
39
  defaults: {
40
+ // Hook defaults live under `sails.config.sounding`.
22
41
  sounding: getDefaultConfig(),
23
42
  },
24
43
 
25
44
  configure() {
45
+ if (!shouldEnableHook(sails)) {
46
+ return
47
+ }
48
+
26
49
  sails.hooks ||= {}
27
50
  sails.sounding = runtime
28
51
  sails.hooks.sounding = this
@@ -30,6 +53,10 @@ function soundingHook(sails) {
30
53
  },
31
54
 
32
55
  initialize(done) {
56
+ if (!shouldEnableHook(sails)) {
57
+ return done()
58
+ }
59
+
33
60
  sails.sounding = runtime
34
61
  Object.assign(this, runtime)
35
62
  sails.hooks.sounding = this
@@ -1,3 +1,5 @@
1
+ const { resolveAuthConfig } = require('./resolve-auth-config')
2
+
1
3
  function normalizeEmail(value) {
2
4
  return String(value || '')
3
5
  .trim()
@@ -9,12 +11,38 @@ function looksLikeEmail(value) {
9
11
  }
10
12
 
11
13
  function createAuthHelpers({ sails, world, mailbox, request }) {
12
- function getUserModel() {
13
- return sails?.models?.user || sails?.models?.User
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
+ )
14
35
  }
15
36
 
16
37
  async function createUserFromEmail(email, fullName) {
17
- const User = getUserModel()
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
+ }
18
46
 
19
47
  if (!sails?.helpers?.user?.signupWithTeam) {
20
48
  throw new Error(
@@ -42,17 +70,18 @@ function createAuthHelpers({ sails, world, mailbox, request }) {
42
70
  return signupResult.user
43
71
  }
44
72
 
45
- async function resolveUser(actorOrEmail, options = {}) {
46
- const User = getUserModel()
73
+ async function resolveActor(actorOrEmail, options = {}) {
74
+ const auth = getAuthConfig()
75
+ const User = getAuthModel()
47
76
 
48
77
  if (!actorOrEmail) {
49
- throw new Error('Sounding auth helpers require an actor, user, or email address.')
78
+ throw new Error('Sounding auth helpers require an actor or email address.')
50
79
  }
51
80
 
52
81
  let candidate = actorOrEmail
53
82
 
54
- if (typeof candidate === 'string' && world.current?.users?.[candidate]) {
55
- candidate = world.current.users[candidate]
83
+ if (typeof candidate === 'string' && !looksLikeEmail(candidate)) {
84
+ candidate = resolveWorldActor(candidate) || candidate
56
85
  }
57
86
 
58
87
  if (candidate?.id && User?.findOne) {
@@ -76,7 +105,7 @@ function createAuthHelpers({ sails, world, mailbox, request }) {
76
105
 
77
106
  if (!user) {
78
107
  throw new Error(
79
- `Sounding auth helpers could not find a user for ${normalizedEmail}.`
108
+ `Sounding auth helpers could not find a ${auth.modelIdentity} for ${normalizedEmail}.`
80
109
  )
81
110
  }
82
111
 
@@ -84,13 +113,13 @@ function createAuthHelpers({ sails, world, mailbox, request }) {
84
113
  }
85
114
 
86
115
  async function issueMagicLink(actorOrEmail, options = {}) {
87
- const User = getUserModel()
116
+ const User = getAuthModel()
88
117
 
89
118
  if (!User?.updateOne) {
90
- throw new Error('Sounding auth helpers require a User model with updateOne().')
119
+ throw new Error('Sounding auth helpers require an auth model with updateOne().')
91
120
  }
92
121
 
93
- const user = await resolveUser(actorOrEmail, {
122
+ const user = await resolveActor(actorOrEmail, {
94
123
  createIfMissing: true,
95
124
  ...options,
96
125
  })
@@ -115,7 +144,7 @@ function createAuthHelpers({ sails, world, mailbox, request }) {
115
144
  }
116
145
 
117
146
  async function requestMagicLink(actorOrEmail, options = {}) {
118
- const user = await resolveUser(actorOrEmail, {
147
+ const user = await resolveActor(actorOrEmail, {
119
148
  createIfMissing: true,
120
149
  ...options,
121
150
  })
@@ -138,18 +167,109 @@ function createAuthHelpers({ sails, world, mailbox, request }) {
138
167
  }
139
168
  }
140
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
+
141
255
  const login = {
142
256
  async as(actorOrEmail, page, options = {}) {
143
257
  const magicLink = await issueMagicLink(actorOrEmail, options)
144
258
  await page.goto(magicLink.url)
145
259
  return magicLink
146
260
  },
261
+ withPassword: loginWithPassword,
147
262
  }
148
263
 
149
264
  return {
150
- resolveUser,
265
+ conventions: getAuthConfig(),
266
+ resolveActor,
267
+ resolveUser: resolveActor,
151
268
  issueMagicLink,
152
269
  requestMagicLink,
270
+ request: {
271
+ withPassword: requestWithPassword,
272
+ },
153
273
  login,
154
274
  }
155
275
  }
@@ -1,5 +1,6 @@
1
1
  const { Transform } = require('node:stream')
2
2
  const QS = require('node:querystring')
3
+ const { resolveAuthConfig } = require('./resolve-auth-config')
3
4
 
4
5
  function isAbsoluteUrl(value) {
5
6
  return /^https?:\/\//i.test(value)
@@ -526,11 +527,13 @@ function createRequestClient({
526
527
  return this
527
528
  }
528
529
 
530
+ const auth = resolveAuthConfig({ sails, getConfig })
529
531
  const actorHeaders = actor.headers || actor.sounding?.headers || {}
530
532
  const actorSession = actor.session ||
531
533
  actor.sounding?.session || {
532
- ...(actor.id ? { userId: actor.id } : {}),
534
+ ...(actor.id ? { [auth.sessionKey]: actor.id } : {}),
533
535
  ...(actor.team ? { teamId: actor.team } : {}),
536
+ ...(actor.teamId ? { teamId: actor.teamId } : {}),
534
537
  }
535
538
 
536
539
  return this.withHeaders(actorHeaders).withSession(actorSession)
@@ -1,4 +1,7 @@
1
1
  const DEFAULT_CONFIG = Object.freeze({
2
+ // Sails environments where the Sounding hook should boot.
3
+ // Keep this test-only by default so non-test processes stay dark.
4
+ environments: ['test'],
2
5
  app: {
3
6
  path: '.',
4
7
  environment: 'test',
@@ -33,6 +36,21 @@ const DEFAULT_CONFIG = Object.freeze({
33
36
  },
34
37
  auth: {
35
38
  defaultActor: 'guest',
39
+ modelIdentity: null,
40
+ sessionKey: null,
41
+ worldCollection: null,
42
+ password: {
43
+ loginPath: '/login',
44
+ pagePath: '/login',
45
+ pageQuery: {},
46
+ form: {
47
+ email: 'email',
48
+ password: 'password',
49
+ rememberMe: 'rememberMe',
50
+ returnUrl: 'returnUrl',
51
+ },
52
+ selectors: {},
53
+ },
36
54
  },
37
55
  })
38
56
 
@@ -0,0 +1,93 @@
1
+ function isPlainObject(value) {
2
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
3
+ }
4
+
5
+ function normalizeIdentity(value) {
6
+ if (typeof value !== 'string') {
7
+ return null
8
+ }
9
+
10
+ const normalized = value.trim().toLowerCase()
11
+ return normalized || null
12
+ }
13
+
14
+ function titleCase(value) {
15
+ if (!value) {
16
+ return value
17
+ }
18
+
19
+ return `${value[0].toUpperCase()}${value.slice(1)}`
20
+ }
21
+
22
+ function resolveSoundingConfig({ sails, getConfig } = {}) {
23
+ return (typeof getConfig === 'function' ? getConfig() : null) || sails?.config?.sounding || {}
24
+ }
25
+
26
+ function getModelByIdentity(sails, identity) {
27
+ const normalizedIdentity = normalizeIdentity(identity)
28
+
29
+ if (!normalizedIdentity) {
30
+ return null
31
+ }
32
+
33
+ return sails?.models?.[normalizedIdentity] || sails?.models?.[titleCase(normalizedIdentity)] || null
34
+ }
35
+
36
+ function detectModelIdentity(sails) {
37
+ for (const candidate of ['user', 'creator']) {
38
+ if (getModelByIdentity(sails, candidate)) {
39
+ return candidate
40
+ }
41
+ }
42
+
43
+ return 'user'
44
+ }
45
+
46
+ function resolvePasswordConfig(authConfig = {}) {
47
+ const passwordConfig = isPlainObject(authConfig.password) ? authConfig.password : {}
48
+ const formConfig = isPlainObject(passwordConfig.form) ? passwordConfig.form : {}
49
+ const selectorConfig = isPlainObject(passwordConfig.selectors) ? passwordConfig.selectors : {}
50
+ const form = {
51
+ email: formConfig.email || 'email',
52
+ password: formConfig.password || 'password',
53
+ rememberMe: formConfig.rememberMe || 'rememberMe',
54
+ returnUrl: formConfig.returnUrl || 'returnUrl',
55
+ }
56
+
57
+ return {
58
+ loginPath: passwordConfig.loginPath || authConfig.loginPath || '/login',
59
+ pagePath: passwordConfig.pagePath || passwordConfig.loginPath || authConfig.loginPath || '/login',
60
+ pageQuery: isPlainObject(passwordConfig.pageQuery) ? passwordConfig.pageQuery : {},
61
+ form,
62
+ selectors: {
63
+ email: selectorConfig.email || `input[name="${form.email}"], input[type="email"]`,
64
+ password:
65
+ selectorConfig.password || `input[name="${form.password}"], input[type="password"]`,
66
+ rememberMe:
67
+ selectorConfig.rememberMe ||
68
+ `input[name="${form.rememberMe}"], input[id="${form.rememberMe}"], input[type="checkbox"]`,
69
+ submit: selectorConfig.submit || 'button[type="submit"], input[type="submit"]',
70
+ },
71
+ }
72
+ }
73
+
74
+ function resolveAuthConfig({ sails, getConfig } = {}) {
75
+ const soundingConfig = resolveSoundingConfig({ sails, getConfig })
76
+ const authConfig = isPlainObject(soundingConfig.auth) ? soundingConfig.auth : {}
77
+ const modelIdentity = normalizeIdentity(authConfig.modelIdentity) || detectModelIdentity(sails)
78
+
79
+ return {
80
+ modelIdentity,
81
+ model: getModelByIdentity(sails, modelIdentity),
82
+ sessionKey: authConfig.sessionKey || `${modelIdentity}Id`,
83
+ worldCollection: authConfig.worldCollection || `${modelIdentity}s`,
84
+ password: resolvePasswordConfig(authConfig),
85
+ }
86
+ }
87
+
88
+ module.exports = {
89
+ detectModelIdentity,
90
+ getModelByIdentity,
91
+ resolveAuthConfig,
92
+ resolveSoundingConfig,
93
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sounding",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "Testing framework for Sails applications and The Boring JavaScript Stack.",
5
5
  "main": "index.js",
6
6
  "license": "MIT",