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.
@@ -0,0 +1,403 @@
1
+ const fs = require('node:fs')
2
+ const path = require('node:path')
3
+
4
+ const packageJson = require('../package.json')
5
+
6
+ const TEST_COMMAND = 'sounding test'
7
+ const SOUNDING_VERSION = `^${packageJson.version}`
8
+ const SQLITE_VERSION = packageJson.devDependencies?.['sails-sqlite'] || '^0.2.6'
9
+
10
+ /**
11
+ * @typedef {{
12
+ * type: 'created' | 'updated' | 'skipped',
13
+ * path?: string,
14
+ * message: string,
15
+ * }} InitProjectAction
16
+ */
17
+
18
+ /**
19
+ * @typedef {{
20
+ * appPath?: string,
21
+ * config?: boolean,
22
+ * }} InitProjectOptions
23
+ */
24
+
25
+ /**
26
+ * @typedef {{
27
+ * appPath: string,
28
+ * auth: {
29
+ * identity: string,
30
+ * modelName: string,
31
+ * collection: string,
32
+ * scenario: string,
33
+ * detected: boolean,
34
+ * },
35
+ * actions: InitProjectAction[],
36
+ * }} InitProjectResult
37
+ */
38
+
39
+ /**
40
+ * @param {string} value
41
+ * @returns {string}
42
+ */
43
+ function titleCase(value) {
44
+ return `${value[0].toUpperCase()}${value.slice(1)}`
45
+ }
46
+
47
+ /**
48
+ * @param {string} value
49
+ * @returns {string}
50
+ */
51
+ function pluralize(value) {
52
+ if (value.endsWith('s')) {
53
+ return value
54
+ }
55
+
56
+ return `${value}s`
57
+ }
58
+
59
+ /**
60
+ * @param {string} appPath
61
+ * @returns {InitProjectResult['auth']}
62
+ */
63
+ function detectAuthConvention(appPath) {
64
+ const candidates = ['user', 'creator']
65
+
66
+ for (const identity of candidates) {
67
+ const modelName = titleCase(identity)
68
+ const modelPaths = [
69
+ path.join(appPath, 'api', 'models', `${modelName}.js`),
70
+ path.join(appPath, 'api', 'models', `${identity}.js`),
71
+ ]
72
+
73
+ if (modelPaths.some((modelPath) => fs.existsSync(modelPath))) {
74
+ return {
75
+ identity,
76
+ modelName,
77
+ collection: pluralize(identity),
78
+ scenario: `signed-in-${identity}`,
79
+ detected: true,
80
+ }
81
+ }
82
+ }
83
+
84
+ return {
85
+ identity: 'user',
86
+ modelName: 'User',
87
+ collection: 'users',
88
+ scenario: 'signed-in-user',
89
+ detected: false,
90
+ }
91
+ }
92
+
93
+ /**
94
+ * @param {string} filePath
95
+ * @returns {any}
96
+ */
97
+ function readJsonFile(filePath) {
98
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'))
99
+ }
100
+
101
+ /**
102
+ * @param {string} filePath
103
+ * @param {any} value
104
+ * @returns {void}
105
+ */
106
+ function writeJsonFile(filePath, value) {
107
+ fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`)
108
+ }
109
+
110
+ /**
111
+ * @param {string} appPath
112
+ * @param {string} targetPath
113
+ * @returns {string}
114
+ */
115
+ function formatPath(appPath, targetPath) {
116
+ return path.relative(appPath, targetPath) || path.basename(targetPath)
117
+ }
118
+
119
+ /**
120
+ * @param {InitProjectAction[]} actions
121
+ * @param {string} appPath
122
+ * @param {string} directory
123
+ * @returns {void}
124
+ */
125
+ function ensureDirectory(actions, appPath, directory) {
126
+ if (fs.existsSync(directory)) {
127
+ actions.push({
128
+ type: 'skipped',
129
+ path: directory,
130
+ message: `Kept existing ${formatPath(appPath, directory)}`,
131
+ })
132
+ return
133
+ }
134
+
135
+ fs.mkdirSync(directory, { recursive: true })
136
+ actions.push({
137
+ type: 'created',
138
+ path: directory,
139
+ message: `Created ${formatPath(appPath, directory)}`,
140
+ })
141
+ }
142
+
143
+ /**
144
+ * @param {InitProjectAction[]} actions
145
+ * @param {string} appPath
146
+ * @param {string} filePath
147
+ * @param {string} contents
148
+ * @returns {void}
149
+ */
150
+ function writeFileIfMissing(actions, appPath, filePath, contents) {
151
+ if (fs.existsSync(filePath)) {
152
+ actions.push({
153
+ type: 'skipped',
154
+ path: filePath,
155
+ message: `Kept existing ${formatPath(appPath, filePath)}`,
156
+ })
157
+ return
158
+ }
159
+
160
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
161
+ fs.writeFileSync(filePath, contents)
162
+ actions.push({
163
+ type: 'created',
164
+ path: filePath,
165
+ message: `Created ${formatPath(appPath, filePath)}`,
166
+ })
167
+ }
168
+
169
+ /**
170
+ * @param {any} pkg
171
+ * @param {string} name
172
+ * @returns {boolean}
173
+ */
174
+ function hasDependency(pkg, name) {
175
+ return Boolean(pkg.dependencies?.[name] || pkg.devDependencies?.[name])
176
+ }
177
+
178
+ /**
179
+ * @param {any} pkg
180
+ * @param {string} name
181
+ * @param {string} version
182
+ * @returns {boolean}
183
+ */
184
+ function ensureDevDependency(pkg, name, version) {
185
+ if (hasDependency(pkg, name)) {
186
+ return false
187
+ }
188
+
189
+ pkg.devDependencies ||= {}
190
+ pkg.devDependencies[name] = version
191
+ return true
192
+ }
193
+
194
+ /**
195
+ * @param {InitProjectAction[]} actions
196
+ * @param {string} appPath
197
+ * @returns {void}
198
+ */
199
+ function updatePackageJson(actions, appPath) {
200
+ const packagePath = path.join(appPath, 'package.json')
201
+ const packageExisted = fs.existsSync(packagePath)
202
+ const pkg = packageExisted ? readJsonFile(packagePath) : {}
203
+ let changed = false
204
+ const details = []
205
+
206
+ pkg.scripts ||= {}
207
+
208
+ if (!pkg.scripts.test) {
209
+ pkg.scripts.test = TEST_COMMAND
210
+ changed = true
211
+ details.push('added `npm test`')
212
+ } else if (pkg.scripts.test !== TEST_COMMAND && !pkg.scripts['test:sounding']) {
213
+ pkg.scripts['test:sounding'] = TEST_COMMAND
214
+ changed = true
215
+ details.push('added `npm run test:sounding`')
216
+ }
217
+
218
+ if (ensureDevDependency(pkg, 'sounding', SOUNDING_VERSION)) {
219
+ changed = true
220
+ details.push('added `sounding` devDependency')
221
+ }
222
+
223
+ if (ensureDevDependency(pkg, 'sails-sqlite', SQLITE_VERSION)) {
224
+ changed = true
225
+ details.push('added `sails-sqlite` devDependency')
226
+ }
227
+
228
+ if (!changed) {
229
+ actions.push({
230
+ type: 'skipped',
231
+ path: packagePath,
232
+ message: 'Kept existing package.json Sounding setup',
233
+ })
234
+ return
235
+ }
236
+
237
+ writeJsonFile(packagePath, pkg)
238
+ actions.push({
239
+ type: packageExisted ? 'updated' : 'created',
240
+ path: packagePath,
241
+ message: `${packageExisted ? 'Updated' : 'Created'} package.json (${details.join(', ')})`,
242
+ })
243
+ }
244
+
245
+ /**
246
+ * @param {InitProjectResult['auth']} auth
247
+ * @returns {string}
248
+ */
249
+ function buildFactoryTemplate(auth) {
250
+ return `module.exports = ({ defineFactory }) =>
251
+ defineFactory('${auth.identity}', ({ sequence }) => ({
252
+ email: sequence('${auth.identity}-email', (next) => \`${auth.identity}-\${next}@example.com\`),
253
+ fullName: 'Test ${auth.modelName}'
254
+ }))
255
+ `
256
+ }
257
+
258
+ /**
259
+ * @param {InitProjectResult['auth']} auth
260
+ * @returns {string}
261
+ */
262
+ function buildScenarioTemplate(auth) {
263
+ return `module.exports = ({ defineScenario }) =>
264
+ defineScenario('${auth.scenario}', async ({ create }) => {
265
+ const member = await create('${auth.identity}')
266
+
267
+ return {
268
+ ${auth.collection}: {
269
+ member
270
+ }
271
+ }
272
+ })
273
+ `
274
+ }
275
+
276
+ /**
277
+ * @param {InitProjectResult['auth']} auth
278
+ * @returns {string}
279
+ */
280
+ function buildExamplesTemplate(auth) {
281
+ return `const { test } = require('sounding')
282
+
283
+ test('Sounding boots this Sails app', async ({ sails, expect }) => {
284
+ expect(sails.config.environment).toBe('test')
285
+ })
286
+
287
+ test('virtual request example reaches Sails', async ({ get, expect }) => {
288
+ const response = await get('/')
289
+
290
+ expect(response.status).toBeDefined()
291
+ })
292
+
293
+ test('helper trial example has access to Sails helpers', async ({ sails, expect }) => {
294
+ expect(sails.helpers).toBeDefined()
295
+ })
296
+
297
+ test.skip('authenticated request example', { world: '${auth.scenario}' }, async ({ request, world, expect }) => {
298
+ const response = await request.as('member').get('/account')
299
+
300
+ expect(response).toHaveStatus(200)
301
+ expect(response).toHaveSession('${auth.identity}Id', world.current.${auth.collection}.member.id)
302
+ })
303
+
304
+ test.skip('Inertia page contract example', async ({ visit, expect }) => {
305
+ const page = await visit('/dashboard')
306
+
307
+ expect(page).toBeInertiaPage('dashboard/index')
308
+ expect(page).toHaveNoInertiaErrors()
309
+ })
310
+
311
+ test.skip('captured mail example', async ({ sails, mailbox, expect }) => {
312
+ await sails.helpers.mail.send.with({
313
+ to: 'reader@example.com',
314
+ subject: 'Welcome',
315
+ template: 'welcome'
316
+ })
317
+
318
+ expect(mailbox).toHaveSentMail({ to: 'reader@example.com' })
319
+ })
320
+
321
+ test.skip('browser journey example', { browser: true }, async ({ page, expect }) => {
322
+ await page.goto('/')
323
+
324
+ await expect(page).toBeDefined()
325
+ })
326
+ `
327
+ }
328
+
329
+ /**
330
+ * @returns {string}
331
+ */
332
+ function buildConfigTemplate() {
333
+ return `module.exports.sounding = {
334
+ // Defaults are enough for most apps. Keep app-specific overrides here.
335
+ environments: ['test']
336
+ }
337
+ `
338
+ }
339
+
340
+ /**
341
+ * @param {InitProjectOptions} [options]
342
+ * @returns {InitProjectResult}
343
+ */
344
+ function initProject(options = {}) {
345
+ const appPath = path.resolve(options.appPath || process.cwd())
346
+ /** @type {InitProjectAction[]} */
347
+ const actions = []
348
+ const auth = detectAuthConvention(appPath)
349
+
350
+ updatePackageJson(actions, appPath)
351
+
352
+ const testsPath = path.join(appPath, 'tests')
353
+ const factoriesPath = path.join(testsPath, 'factories')
354
+ const scenariosPath = path.join(testsPath, 'scenarios')
355
+ const examplesPath = path.join(testsPath, 'sounding')
356
+
357
+ ensureDirectory(actions, appPath, testsPath)
358
+ ensureDirectory(actions, appPath, factoriesPath)
359
+ ensureDirectory(actions, appPath, scenariosPath)
360
+ ensureDirectory(actions, appPath, examplesPath)
361
+
362
+ writeFileIfMissing(
363
+ actions,
364
+ appPath,
365
+ path.join(factoriesPath, `${auth.identity}.js`),
366
+ buildFactoryTemplate(auth)
367
+ )
368
+ writeFileIfMissing(
369
+ actions,
370
+ appPath,
371
+ path.join(scenariosPath, `${auth.scenario}.js`),
372
+ buildScenarioTemplate(auth)
373
+ )
374
+ writeFileIfMissing(
375
+ actions,
376
+ appPath,
377
+ path.join(examplesPath, 'examples.test.js'),
378
+ buildExamplesTemplate(auth)
379
+ )
380
+
381
+ const configPath = path.join(appPath, 'config', 'sounding.js')
382
+ if (options.config) {
383
+ writeFileIfMissing(actions, appPath, configPath, buildConfigTemplate())
384
+ } else {
385
+ actions.push({
386
+ type: 'skipped',
387
+ path: configPath,
388
+ message: 'Skipped config/sounding.js because Sounding defaults are enough',
389
+ })
390
+ }
391
+
392
+ return {
393
+ appPath,
394
+ auth,
395
+ actions,
396
+ }
397
+ }
398
+
399
+ module.exports = {
400
+ TEST_COMMAND,
401
+ detectAuthConvention,
402
+ initProject,
403
+ }
@@ -1,7 +1,18 @@
1
+ /** @typedef {import('./types').AnyRecord} AnyRecord */
2
+
3
+ /**
4
+ * @param {any} value
5
+ * @returns {value is AnyRecord}
6
+ */
1
7
  function isPlainObject(value) {
2
8
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
3
9
  }
4
10
 
11
+ /**
12
+ * @param {AnyRecord} base
13
+ * @param {AnyRecord} override
14
+ * @returns {AnyRecord}
15
+ */
5
16
  function mergeConfig(base, override) {
6
17
  const output = { ...base }
7
18
 
@@ -1,7 +1,18 @@
1
+ /** @typedef {import('./types').AnyRecord} AnyRecord */
2
+ /** @typedef {import('./types').SoundingUserConfig} SoundingUserConfig */
3
+
4
+ /**
5
+ * @param {any} value
6
+ * @returns {value is AnyRecord}
7
+ */
1
8
  function isPlainObject(value) {
2
9
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
3
10
  }
4
11
 
12
+ /**
13
+ * @param {any} datastore
14
+ * @returns {any}
15
+ */
5
16
  function normalizeDatastore(datastore) {
6
17
  if (typeof datastore === 'string') {
7
18
  return {
@@ -13,27 +24,13 @@ function normalizeDatastore(datastore) {
13
24
  return datastore
14
25
  }
15
26
 
16
- const normalized = { ...datastore }
17
- const legacyManaged = isPlainObject(normalized.managed) ? normalized.managed : {}
18
-
19
- if (normalized.adapter == null && legacyManaged.adapter != null) {
20
- normalized.adapter = legacyManaged.adapter
21
- }
22
-
23
- if (normalized.root == null) {
24
- normalized.root = legacyManaged.root ?? legacyManaged.directory
25
- }
26
-
27
- if (normalized.isolation == null && legacyManaged.isolation != null) {
28
- normalized.isolation = legacyManaged.isolation
29
- }
30
-
31
- delete normalized.managed
32
- delete normalized.directory
33
-
34
- return normalized
27
+ return { ...datastore }
35
28
  }
36
29
 
30
+ /**
31
+ * @param {any} [config]
32
+ * @returns {SoundingUserConfig}
33
+ */
37
34
  function normalizeUserConfig(config = {}) {
38
35
  if (!isPlainObject(config)) {
39
36
  return {}
@@ -1,7 +1,18 @@
1
+ /** @typedef {import('./types').AnyRecord} AnyRecord */
2
+ /** @typedef {import('./types').SoundingSailsApp} SoundingSailsApp */
3
+
4
+ /**
5
+ * @param {any} value
6
+ * @returns {value is AnyRecord}
7
+ */
1
8
  function isPlainObject(value) {
2
9
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
3
10
  }
4
11
 
12
+ /**
13
+ * @param {any} value
14
+ * @returns {string | null}
15
+ */
5
16
  function normalizeIdentity(value) {
6
17
  if (typeof value !== 'string') {
7
18
  return null
@@ -11,6 +22,10 @@ function normalizeIdentity(value) {
11
22
  return normalized || null
12
23
  }
13
24
 
25
+ /**
26
+ * @param {string | null} value
27
+ * @returns {string | null}
28
+ */
14
29
  function titleCase(value) {
15
30
  if (!value) {
16
31
  return value
@@ -19,10 +34,19 @@ function titleCase(value) {
19
34
  return `${value[0].toUpperCase()}${value.slice(1)}`
20
35
  }
21
36
 
37
+ /**
38
+ * @param {{ sails?: SoundingSailsApp, getConfig?: () => AnyRecord }} [input]
39
+ * @returns {AnyRecord}
40
+ */
22
41
  function resolveSoundingConfig({ sails, getConfig } = {}) {
23
42
  return (typeof getConfig === 'function' ? getConfig() : null) || sails?.config?.sounding || {}
24
43
  }
25
44
 
45
+ /**
46
+ * @param {SoundingSailsApp} sails
47
+ * @param {string | null} identity
48
+ * @returns {AnyRecord | null}
49
+ */
26
50
  function getModelByIdentity(sails, identity) {
27
51
  const normalizedIdentity = normalizeIdentity(identity)
28
52
 
@@ -33,6 +57,10 @@ function getModelByIdentity(sails, identity) {
33
57
  return sails?.models?.[normalizedIdentity] || sails?.models?.[titleCase(normalizedIdentity)] || null
34
58
  }
35
59
 
60
+ /**
61
+ * @param {SoundingSailsApp} sails
62
+ * @returns {string}
63
+ */
36
64
  function detectModelIdentity(sails) {
37
65
  for (const candidate of ['user', 'creator']) {
38
66
  if (getModelByIdentity(sails, candidate)) {
@@ -43,6 +71,10 @@ function detectModelIdentity(sails) {
43
71
  return 'user'
44
72
  }
45
73
 
74
+ /**
75
+ * @param {AnyRecord} [authConfig]
76
+ * @returns {AnyRecord}
77
+ */
46
78
  function resolvePasswordConfig(authConfig = {}) {
47
79
  const passwordConfig = isPlainObject(authConfig.password) ? authConfig.password : {}
48
80
  const formConfig = isPlainObject(passwordConfig.form) ? passwordConfig.form : {}
@@ -71,6 +103,10 @@ function resolvePasswordConfig(authConfig = {}) {
71
103
  }
72
104
  }
73
105
 
106
+ /**
107
+ * @param {{ sails?: SoundingSailsApp, getConfig?: () => AnyRecord }} [input]
108
+ * @returns {AnyRecord}
109
+ */
74
110
  function resolveAuthConfig({ sails, getConfig } = {}) {
75
111
  const soundingConfig = resolveSoundingConfig({ sails, getConfig })
76
112
  const authConfig = isPlainObject(soundingConfig.auth) ? soundingConfig.auth : {}
@@ -1,5 +1,15 @@
1
1
  const path = require('node:path')
2
+ const { createSoundingError } = require('./create-error')
2
3
 
4
+ /** @typedef {import('./types').AnyRecord} AnyRecord */
5
+ /** @typedef {import('./types').SoundingConfig} SoundingConfig */
6
+ /** @typedef {import('./types').SoundingDatastoreState} SoundingDatastoreState */
7
+ /** @typedef {import('./types').SoundingSailsApp} SoundingSailsApp */
8
+
9
+ /**
10
+ * @param {NodeJS.ProcessEnv | AnyRecord} [env]
11
+ * @returns {string}
12
+ */
3
13
  function getWorkerToken(env = process.env) {
4
14
  return (
5
15
  env.SOUNDING_WORKER_INDEX ||
@@ -9,6 +19,15 @@ function getWorkerToken(env = process.env) {
9
19
  )
10
20
  }
11
21
 
22
+ /**
23
+ * @param {{
24
+ * root?: string,
25
+ * identity?: string,
26
+ * isolation?: string,
27
+ * env?: NodeJS.ProcessEnv | AnyRecord,
28
+ * }} options
29
+ * @returns {string}
30
+ */
12
31
  function buildManagedSqlitePath({
13
32
  root = path.join(process.cwd(), '.tmp', 'db'),
14
33
  identity = 'default',
@@ -19,6 +38,10 @@ function buildManagedSqlitePath({
19
38
  return path.join(root, identity, `${workerToken}.db`)
20
39
  }
21
40
 
41
+ /**
42
+ * @param {{ sails?: SoundingSailsApp, datastore?: AnyRecord, appPath?: string }} options
43
+ * @returns {string}
44
+ */
22
45
  function resolveManagedRoot({ sails, datastore = {}, appPath } = {}) {
23
46
  const configuredRoot = datastore.root || path.join('.tmp', 'db')
24
47
 
@@ -29,7 +52,12 @@ function resolveManagedRoot({ sails, datastore = {}, appPath } = {}) {
29
52
  return path.resolve(appPath || sails?.config?.appPath || process.cwd(), configuredRoot)
30
53
  }
31
54
 
55
+ /**
56
+ * @param {{ sails: SoundingSailsApp, soundingConfig: SoundingConfig }} input
57
+ * @returns {SoundingDatastoreState & { managed: boolean, filePath?: string }}
58
+ */
32
59
  function resolveDatastore({ sails, soundingConfig }) {
60
+ /** @type {AnyRecord} */
33
61
  const datastoreConfig = soundingConfig.datastore || {}
34
62
  const mode = datastoreConfig.mode || 'managed'
35
63
  const identity = datastoreConfig.identity || 'default'
@@ -39,9 +67,14 @@ function resolveDatastore({ sails, soundingConfig }) {
39
67
  const configuredDatastore = datastores[identity]
40
68
 
41
69
  if (!configuredDatastore) {
42
- throw new Error(
43
- `Sounding could not find datastore \`${identity}\` in sails.config.datastores for mode \`${mode}\`.`
44
- )
70
+ throw createSoundingError({
71
+ code: 'E_SOUNDING_DATASTORE_CONFIG_MISSING',
72
+ message: `Sounding could not find datastore \`${identity}\` in sails.config.datastores for mode \`${mode}\`.`,
73
+ details: {
74
+ mode,
75
+ identity,
76
+ },
77
+ })
45
78
  }
46
79
 
47
80
  return {
@@ -56,9 +89,13 @@ function resolveDatastore({ sails, soundingConfig }) {
56
89
  const adapter = datastoreConfig.adapter || 'sails-sqlite'
57
90
 
58
91
  if (adapter !== 'sails-sqlite') {
59
- throw new Error(
60
- `Sounding only supports managed adapter \`sails-sqlite\` in v0.0.1. Received \`${adapter}\`.`
61
- )
92
+ throw createSoundingError({
93
+ code: 'E_SOUNDING_DATASTORE_ADAPTER_UNSUPPORTED',
94
+ message: `Sounding only supports managed adapter \`sails-sqlite\` in v0.0.1. Received \`${adapter}\`.`,
95
+ details: {
96
+ adapter,
97
+ },
98
+ })
62
99
  }
63
100
 
64
101
  const filePath = buildManagedSqlitePath({
@@ -87,7 +124,13 @@ function resolveDatastore({ sails, soundingConfig }) {
87
124
  }
88
125
  }
89
126
 
90
- throw new Error(`Unknown Sounding datastore mode: ${mode}`)
127
+ throw createSoundingError({
128
+ code: 'E_SOUNDING_DATASTORE_MODE_UNKNOWN',
129
+ message: `Unknown Sounding datastore mode: ${mode}`,
130
+ details: {
131
+ mode,
132
+ },
133
+ })
91
134
  }
92
135
 
93
136
  module.exports = {