sounding 0.0.3 → 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.
package/README.md CHANGED
@@ -16,17 +16,63 @@ The canonical Sails-native surface is:
16
16
  - `get('/api/issues')` or `sails.sounding.request.get('/api/issues')` inside endpoint-style trials
17
17
  - `await auth.login.withPassword('creator@example.com', page, { password: 'secret123' })` inside browser trials
18
18
  - `await auth.request.withPassword('creator@example.com', { password: 'secret123' })` inside request trials
19
+ - `test('...', { world: 'signed-in-user' }, async ({ request }) => {})` can auto-load named worlds before the handler runs
20
+ - `request.as('owner')` and `visit.as('owner')` can resolve actor aliases from the current world
21
+ - `test('...', { browser: 'mobile' }, async ({ page }) => {})` can select a named browser project without extra ceremony
22
+ - failed browser trials capture the current URL and a full-page screenshot under `.tmp/sounding/artifacts`
19
23
  - request helpers default to Sails virtual requests powered by `sails.request()`
24
+ - virtual request responses expose the final `req.session` snapshot as `response.session`; HTTP responses leave it undefined
25
+ - request assertions can check auth/session state with `expect(response).toHaveSession('userId', user.id)` and flash messages with `expect(response).toHaveFlash('info', /welcome/i)`
26
+ - failed response assertions include concise request/response diagnostics; set `SOUNDING_DIAGNOSTICS=verbose` for full response excerpts
20
27
  - Inertia-style visits can use `visit('/pricing')` and partial reload options like `{ component, only }`
28
+ - mail assertions can check captured emails with `expect(mailbox).toHaveSentMail({ to, subject })` and `expect(mailbox.latest()).toHaveCtaUrl(/magic-link/)`
21
29
  - a trial can opt into stricter parity with `test('...', { transport: 'http' }, ...)`
30
+ - upload trials use `FormData` over the HTTP transport so Sails can exercise real Skipper streams
31
+ - independent trials can opt into concurrent execution with `test('...', { concurrent: true }, ...)` or `test.concurrent(...)`
22
32
  - any trial can also scope a request client with `sails.sounding.request.using('http')`
23
33
 
24
34
  Sounding also owns its own built-in world engine, so the same package can:
25
35
  - define factories under `tests/factories`
26
36
  - define scenarios under `tests/scenarios`
27
- - load named worlds for endpoint and browser trials
37
+ - auto-load named worlds for endpoint, Inertia, socket, and browser trials
38
+ - compose world records with fluent builders like `await create('user').trait('admin').with({ email })`
39
+ - merge repeated builder `.with()` calls, with `.withOnly()` available when you want to use only the next overrides
28
40
  - capture outgoing mail by wrapping `sails.helpers.mail.send` and storing normalized messages in `sails.sounding.mailbox`
29
41
 
42
+ Request-level Inertia trials can assert page contracts without launching a browser:
43
+
44
+ ```js
45
+ const { test } = require('sounding')
46
+
47
+ test('dashboard shows the signed-in creator', { world: 'signed-in-user' }, async ({ visit, expect }) => {
48
+ const page = await visit.as('owner')('/dashboard')
49
+
50
+ expect(page).toBeInertiaPage('dashboard/index')
51
+ expect(page).toHaveInertiaProps({
52
+ 'auth.user.email': 'owner@example.com',
53
+ 'stats.projects': 2,
54
+ projects: [{ name: 'Launch Plan' }],
55
+ })
56
+ expect(page).toHaveNoInertiaErrors()
57
+ })
58
+
59
+ test('dashboard partial reload returns only notifications', async ({ visit, expect }) => {
60
+ const page = await visit('/dashboard', {
61
+ component: 'dashboard/index',
62
+ only: ['notifications'],
63
+ reset: ['sidebar'],
64
+ })
65
+
66
+ expect(page).toBeInertiaPage('dashboard/index')
67
+ expect(page).toHaveInertiaPartialReload({
68
+ component: 'dashboard/index',
69
+ only: ['notifications'],
70
+ reset: ['sidebar'],
71
+ })
72
+ expect(page).toHaveOnlyInertiaProps(['notifications'])
73
+ })
74
+ ```
75
+
30
76
  The default configuration story is intentionally calm:
31
77
  - Sounding only enables its hook in the environments listed under `sounding.environments`
32
78
  - the default is `['test']`, so non-test boot paths stay dark unless you opt in explicitly
@@ -36,6 +82,10 @@ The default configuration story is intentionally calm:
36
82
  - managed SQLite artifacts live under `.tmp/db`
37
83
  - the default datastore identity is `default`
38
84
  - browser projects start with `desktop`
85
+ - browser projects can be strings or named project objects with `type`, `device`, `viewport`, `contextOptions`, and `launchOptions`
86
+ - browser failure artifacts store screenshots and current URLs by default, while traces and videos are opt-in
87
+ - mail capture previews use the `mail` layout by default, matching the current `sails-hook-mail` convention
88
+ - apps with a different mail layout can set `sounding.mail.layout`, for example `layout-email`
39
89
  - `inherit` remains available when an app already has a serious test datastore story
40
90
 
41
91
  For example:
@@ -48,6 +98,291 @@ module.exports.sounding = {
48
98
 
49
99
  If you intentionally want Sounding during another boot path, widen the list explicitly, for example `['test', 'console']` or `['test', 'production']`.
50
100
 
101
+ ## Upload trials
102
+
103
+ Use HTTP request trials for Sails file uploads. Uploads in Sails are streaming
104
+ Skipper requests, so they need the HTTP stack that Sails uses for real multipart
105
+ forms.
106
+
107
+ ```js
108
+ const { test } = require('sounding')
109
+
110
+ test(
111
+ 'creator can upload a receipt',
112
+ { transport: 'http' },
113
+ async ({ request, expect }) => {
114
+ const form = new FormData()
115
+
116
+ form.append('description', 'Home office monitor')
117
+ form.append('amount', '1200')
118
+ form.append(
119
+ 'receipt',
120
+ new Blob(['receipt bytes'], { type: 'application/pdf' }),
121
+ 'receipt.pdf'
122
+ )
123
+
124
+ const response = await request.post('/expenses', form)
125
+
126
+ expect(response).toRedirectTo('/expenses/new')
127
+ }
128
+ )
129
+ ```
130
+
131
+ When a multipart form mixes text fields and files, append the text fields before
132
+ the files. That matches Sails and Skipper's streaming model, where actions can
133
+ start while file streams are still arriving.
134
+
135
+ Do not use the virtual transport for upload behavior. Virtual requests are still
136
+ right for normal endpoints, JSON APIs, session assertions, redirects, and
137
+ Inertia contracts, but real `req.file()` uploads are HTTP-only in Sails.
138
+
139
+ ## Project init
140
+
141
+ Use the initializer from a Sails app to add the first Sounding test lane:
142
+
143
+ ```sh
144
+ npx sounding init
145
+ ```
146
+
147
+ It updates `package.json`, creates `tests/factories`, `tests/scenarios`, and `tests/sounding`, and writes starter examples without overwriting existing files. The default setup relies on Sounding's built-in conventions, so it skips `config/sounding.js` unless you ask for an editable config scaffold:
148
+
149
+ ```sh
150
+ npx sounding init --config
151
+ ```
152
+
153
+ Typical output looks like:
154
+
155
+ ```txt
156
+ Sounding initialized /path/to/my-sails-app
157
+ Auth convention: User
158
+ ~ Updated package.json (added `npm test`, added `sounding` devDependency, added `sails-sqlite` devDependency)
159
+ + Created tests
160
+ + Created tests/factories
161
+ + Created tests/scenarios
162
+ + Created tests/sounding
163
+ + Created tests/factories/user.js
164
+ + Created tests/scenarios/signed-in-user.js
165
+ + Created tests/sounding/examples.test.js
166
+ - Skipped config/sounding.js because Sounding defaults are enough
167
+
168
+ Next: run npm install, then npm test.
169
+ ```
170
+
171
+ ## CLI test runner
172
+
173
+ Run a Sounding suite with the framework-level runner:
174
+
175
+ ```sh
176
+ npx sounding test
177
+ ```
178
+
179
+ The command discovers `.test.js` files under `tests/` and `test/`, then runs Node's native test runner with Sounding-friendly filters:
180
+
181
+ ```sh
182
+ npx sounding test --grep "dashboard"
183
+ npx sounding test --file tests/sounding/examples.test.js
184
+ npx sounding test --lane browser
185
+ npx sounding test --watch
186
+ ```
187
+
188
+ Common Node test flags pass through, and CI reporters are available without memorizing the longer Node flag names:
189
+
190
+ ```sh
191
+ npx sounding test --reporter spec
192
+ npx sounding test --junit reports/sounding-junit.xml
193
+ npx sounding test --json
194
+ npx sounding test --coverage
195
+ ```
196
+
197
+ Use `--dry-run` to inspect the exact `node --test` command before running it.
198
+
199
+ ## App lifecycle
200
+
201
+ Sounding keeps a warm Sails app by default. Virtual request trials load the app without opening an HTTP listener, while HTTP, socket, and browser-capable trials lift the app so the network stack exists.
202
+
203
+ The app manager makes those lanes explicit:
204
+
205
+ ```js
206
+ const { createAppManager } = require('sounding')
207
+
208
+ const manager = createAppManager()
209
+
210
+ const virtualRuntime = await manager.runtime({ app: 'load' })
211
+ const httpRuntime = await manager.runtime({ app: 'lift' })
212
+ const alsoHttpRuntime = await manager.runtime({ transport: 'http' })
213
+ ```
214
+
215
+ Use the warm default for most suites. When a trial mutates process-global app state and needs a fresh Sails instance, force a reload:
216
+
217
+ ```js
218
+ const freshRuntime = await manager.runtime({ app: 'load', reload: true })
219
+ ```
220
+
221
+ Lifecycle timings are available for diagnostics:
222
+
223
+ ```js
224
+ console.log(manager.lifecycle.load.durationMs)
225
+ console.log(manager.lifecycle.lift.status)
226
+ ```
227
+
228
+ Set `SOUNDING_LIFECYCLE=verbose` or `SOUNDING_DIAGNOSTICS=verbose` to print app load/lift timing messages while the suite runs.
229
+
230
+ ## Concurrent trials
231
+
232
+ Sounding runs trials serially by default. That keeps shared Sails app state boring while request sessions, worlds, mailboxes, sockets, and browser sessions continue to reset between trials.
233
+
234
+ Independent trials can opt into Node test concurrency:
235
+
236
+ ```js
237
+ test.concurrent('health check is isolated', async ({ get, expect }) => {
238
+ const response = await get('/health')
239
+
240
+ expect(response).toHaveStatus(200)
241
+ })
242
+
243
+ test('dashboard contract is isolated too', { concurrent: true }, async ({ visit, expect }) => {
244
+ const page = await visit('/dashboard')
245
+
246
+ expect(page).toBeInertiaPage('dashboard/index')
247
+ })
248
+ ```
249
+
250
+ Concurrent Sounding trials bypass the global serial queue and receive isolated runtime state. Their request session, mailbox, world, sockets, and browser manager are separate from other concurrent trials. Managed SQLite datastore paths remain isolated by worker using `.tmp/db/<identity>/worker-<token>.db`, where the worker token comes from `SOUNDING_WORKER_INDEX`, `PLAYWRIGHT_WORKER_INDEX`, `TEST_WORKER_INDEX`, or the process id.
251
+
252
+ Use concurrent mode for trials that do not mutate process-global app state. If you build a custom `createTestApi({ runtime })`, pass a runtime factory such as `() => createRuntime(sails)` for concurrent trials; a single shared runtime object stays serial-only.
253
+
254
+ ## Typing and editor support
255
+
256
+ Sounding is JSDoc-first today. The public API types live beside the CommonJS source, with shared typedefs in `lib/types.js`, so JavaScript Sails apps get autocomplete and inline docs without a separate hand-maintained declaration surface.
257
+
258
+ The type smoke test in `typecheck/public-api-smoke.js` checks the exported API that consumers use: `test()`, request and visit clients, worlds, mail, auth, browser, socket helpers, runtime factories, and default config. Run it with:
259
+
260
+ ```sh
261
+ npm run typecheck
262
+ ```
263
+
264
+ Sounding does not ship hand-written `.d.ts` files right now. If TypeScript consumers need declaration files later, they should be generated from the JSDoc source of truth and verified against the same public API smoke test.
265
+
266
+ ## Browser projects
267
+
268
+ Browser trials start on the `desktop` project:
269
+
270
+ ```js
271
+ test('subscriber can read a gated issue', { browser: true }, async ({ page }) => {
272
+ await page.goto('/issues/the-nerve-to-build')
273
+ })
274
+ ```
275
+
276
+ Use a string when a trial needs a named project:
277
+
278
+ ```js
279
+ test('mobile navigation opens the account menu', { browser: 'mobile' }, async ({ page }) => {
280
+ await page.goto('/dashboard')
281
+ })
282
+ ```
283
+
284
+ Configure named projects in `config/sounding.js` when an app needs mobile devices, WebKit, or custom context options:
285
+
286
+ ```js
287
+ module.exports.sounding = {
288
+ browser: {
289
+ projects: {
290
+ desktop: {},
291
+ mobile: {
292
+ device: 'iPhone 13'
293
+ },
294
+ safari: {
295
+ type: 'webkit',
296
+ viewport: {
297
+ width: 1280,
298
+ height: 720
299
+ }
300
+ }
301
+ }
302
+ }
303
+ }
304
+ ```
305
+
306
+ The object form stays Sails-simple while still passing through to Playwright where it matters.
307
+
308
+ ## Browser failure artifacts
309
+
310
+ Browser-capable trials should be easy to debug without turning every run into a heavyweight recording session.
311
+
312
+ By default, a failed `{ browser: true }` trial writes:
313
+
314
+ - `current-url.txt`
315
+ - `screenshot.png`
316
+
317
+ under a stable, readable directory:
318
+
319
+ ```txt
320
+ .tmp/sounding/artifacts/<trial-name>/<browser-project>/
321
+ ```
322
+
323
+ For a trial named `dashboard shows owner stats` on the default `desktop` project, that becomes:
324
+
325
+ ```txt
326
+ .tmp/sounding/artifacts/dashboard-shows-owner-stats/desktop/
327
+ ```
328
+
329
+ When a failure happens, Sounding appends the current URL and artifact paths to the thrown error so the terminal output points straight at the evidence.
330
+
331
+ Traces and videos are intentionally off by default because they cost more disk and time. Turn them on for a whole app:
332
+
333
+ ```js
334
+ module.exports.sounding = {
335
+ browser: {
336
+ artifacts: {
337
+ trace: true,
338
+ video: true
339
+ }
340
+ }
341
+ }
342
+ ```
343
+
344
+ Or scope them to one suspicious trial:
345
+
346
+ ```js
347
+ test(
348
+ 'checkout keeps the cart after refresh',
349
+ {
350
+ browser: {
351
+ artifacts: {
352
+ trace: true,
353
+ video: true
354
+ }
355
+ }
356
+ },
357
+ async ({ page, expect }) => {
358
+ await page.goto('/checkout')
359
+ await page.reload()
360
+
361
+ await expect(page.getByText('Your cart')).toBeVisible()
362
+ }
363
+ )
364
+ ```
365
+
366
+ Use `false` as a concise off switch:
367
+
368
+ ```js
369
+ test('fast smoke flow', { browser: { artifacts: false } }, async ({ page }) => {
370
+ await page.goto('/health')
371
+ })
372
+ ```
373
+
374
+ For artifact settings, `true` means “keep this when the trial fails.” If you need an artifact on successful browser trials too, use `on` instead:
375
+
376
+ ```js
377
+ module.exports.sounding = {
378
+ browser: {
379
+ artifacts: {
380
+ trace: 'on'
381
+ }
382
+ }
383
+ }
384
+ ```
385
+
51
386
  This repository starts with docs-driven product research and the first hook/runtime scaffolding for that vision.
52
387
 
53
388
  See `RESEARCH.md`.
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { initProject } = require('../lib/init-project')
4
+ const { formatTestCommand, runTests } = require('../lib/test-runner')
5
+
6
+ function printHelp() {
7
+ process.stdout.write(`Sounding
8
+
9
+ Usage:
10
+ sounding init [--app <path>] [--config]
11
+ sounding test [options] [files or folders]
12
+
13
+ Commands:
14
+ init Scaffold Sounding tests, worlds, and package scripts in a Sails app.
15
+ test Run Sounding trials through the Node.js test runner.
16
+
17
+ Options:
18
+ --app Target app directory. Defaults to the current working directory.
19
+ --config Also create config/sounding.js when it does not already exist.
20
+ --help Show this help.
21
+ `)
22
+ }
23
+
24
+ function printTestHelp() {
25
+ process.stdout.write(`Sounding test
26
+
27
+ Usage:
28
+ sounding test [options] [files or folders]
29
+
30
+ Options:
31
+ --app <path> Target app directory.
32
+ --grep <pattern> Forward to --test-name-pattern.
33
+ --file <path> Run one file. May be repeated.
34
+ --lane <name> Run tests under tests/<name> or test/<name>.
35
+ --changed Run changed .test.js files when git metadata is available.
36
+ --watch Forward to Node watch mode.
37
+ --reporter <name> Forward to --test-reporter.
38
+ --reporter-destination <path> Forward to --test-reporter-destination.
39
+ --junit [path] Use the junit reporter.
40
+ --json Use the json reporter.
41
+ --coverage Enable Node test coverage.
42
+ --dry-run Print the Node command without running it.
43
+
44
+ Unknown --test-* and Node flags pass through to node.
45
+ `)
46
+ }
47
+
48
+ function parseArgs(argv) {
49
+ const args = [...argv]
50
+ const options = {}
51
+ const command = args.shift()
52
+
53
+ if (command === '--help' || command === '-h') {
54
+ return {
55
+ command: null,
56
+ options: {
57
+ help: true,
58
+ },
59
+ }
60
+ }
61
+
62
+ if (command === 'test') {
63
+ return {
64
+ command,
65
+ options: {
66
+ argv: args,
67
+ },
68
+ }
69
+ }
70
+
71
+ while (args.length > 0) {
72
+ const arg = args.shift()
73
+
74
+ if (arg === '--help' || arg === '-h') {
75
+ options.help = true
76
+ continue
77
+ }
78
+
79
+ if (arg === '--config') {
80
+ options.config = true
81
+ continue
82
+ }
83
+
84
+ if (arg === '--app') {
85
+ options.appPath = args.shift()
86
+ if (!options.appPath) {
87
+ throw new Error('Sounding option `--app` requires a path.')
88
+ }
89
+ continue
90
+ }
91
+
92
+ throw new Error(`Unknown Sounding option: ${arg}`)
93
+ }
94
+
95
+ return {
96
+ command,
97
+ options,
98
+ }
99
+ }
100
+
101
+ function printInitResult(result) {
102
+ process.stdout.write(`Sounding initialized ${result.appPath}\n`)
103
+ process.stdout.write(
104
+ `Auth convention: ${result.auth.modelName}${result.auth.detected ? '' : ' (default)'}\n`
105
+ )
106
+
107
+ for (const action of result.actions) {
108
+ const marker = action.type === 'created' ? '+' : action.type === 'updated' ? '~' : '-'
109
+ process.stdout.write(`${marker} ${action.message}\n`)
110
+ }
111
+
112
+ process.stdout.write('\nNext: run npm install, then npm test.\n')
113
+ }
114
+
115
+ async function main() {
116
+ const { command, options } = parseArgs(process.argv.slice(2))
117
+
118
+ if (!command || options.help) {
119
+ printHelp()
120
+ return
121
+ }
122
+
123
+ if (command === 'init') {
124
+ const result = initProject(options)
125
+ printInitResult(result)
126
+ return
127
+ }
128
+
129
+ if (command === 'test') {
130
+ if (options.argv.includes('--help') || options.argv.includes('-h')) {
131
+ printTestHelp()
132
+ return
133
+ }
134
+
135
+ const result = await runTests({
136
+ argv: options.argv,
137
+ stdio: 'inherit',
138
+ })
139
+
140
+ if (result.command.dryRun) {
141
+ process.stdout.write(`${formatTestCommand(result.command)}\n`)
142
+ }
143
+
144
+ process.exitCode = result.status
145
+ return
146
+ }
147
+
148
+ {
149
+ throw new Error(`Unknown Sounding command: ${command}`)
150
+ }
151
+ }
152
+
153
+ main().catch((error) => {
154
+ process.stderr.write(`${error.message}\n`)
155
+ process.exitCode = 1
156
+ })
package/index.js CHANGED
@@ -9,15 +9,27 @@ const { createHelperRunner } = require('./lib/create-helper-runner')
9
9
  const { createRequestClient } = require('./lib/create-request-client')
10
10
  const { createVisitClient } = require('./lib/create-visit-client')
11
11
  const { createBrowserManager } = require('./lib/create-browser-manager')
12
+ const { createSocketManager } = require('./lib/create-socket-manager')
12
13
  const { createAuthHelpers } = require('./lib/create-auth-helpers')
13
14
  const { createExpect } = require('./lib/create-expect')
14
15
  const { createTestApi } = require('./lib/create-test-api')
15
16
  const { getDefaultConfig } = require('./lib/default-config')
16
17
 
18
+ /** @typedef {import('./lib/types').SoundingSailsApp} SoundingSailsApp */
19
+ /** @typedef {import('./lib/types').SoundingSailsHook} SoundingSailsHook */
20
+
21
+ /**
22
+ * @param {SoundingSailsApp} sails
23
+ * @returns {string | undefined}
24
+ */
17
25
  function getCurrentEnvironment(sails) {
18
26
  return sails.config?.environment || process.env.NODE_ENV
19
27
  }
20
28
 
29
+ /**
30
+ * @param {SoundingSailsApp} sails
31
+ * @returns {string[]}
32
+ */
21
33
  function getEnabledEnvironments(sails) {
22
34
  const configured = sails.config?.sounding?.environments
23
35
 
@@ -28,10 +40,20 @@ function getEnabledEnvironments(sails) {
28
40
  return getDefaultConfig().environments
29
41
  }
30
42
 
43
+ /**
44
+ * @param {SoundingSailsApp} sails
45
+ * @returns {boolean}
46
+ */
31
47
  function shouldEnableHook(sails) {
32
48
  return getEnabledEnvironments(sails).includes(getCurrentEnvironment(sails))
33
49
  }
34
50
 
51
+ /**
52
+ * Sails hook factory.
53
+ *
54
+ * @param {SoundingSailsApp} sails
55
+ * @returns {SoundingSailsHook}
56
+ */
35
57
  function soundingHook(sails) {
36
58
  const runtime = createRuntime(sails)
37
59
 
@@ -79,6 +101,7 @@ module.exports.createHelperRunner = createHelperRunner
79
101
  module.exports.createRequestClient = createRequestClient
80
102
  module.exports.createVisitClient = createVisitClient
81
103
  module.exports.createBrowserManager = createBrowserManager
104
+ module.exports.createSocketManager = createSocketManager
82
105
  module.exports.createAuthHelpers = createAuthHelpers
83
106
  module.exports.createExpect = createExpect
84
107
  module.exports.createTestApi = createTestApi