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.
@@ -1,13 +1,30 @@
1
+ const { createSoundingError } = require('./create-error')
2
+
1
3
  const DEFAULT_HEADERS = {
2
4
  'x-inertia': 'true',
3
5
  'x-requested-with': 'XMLHttpRequest',
4
6
  accept: 'text/html, application/xhtml+xml',
5
7
  }
6
8
 
9
+ /** @typedef {import('./types').AnyRecord} AnyRecord */
10
+ /** @typedef {import('./types').SoundingRequestClient} SoundingRequestClient */
11
+ /** @typedef {import('./types').SoundingRequestOptions} SoundingRequestOptions */
12
+ /** @typedef {import('./types').SoundingTransport} SoundingTransport */
13
+ /** @typedef {import('./types').SoundingVisitClient} SoundingVisitClient */
14
+ /** @typedef {import('./types').SoundingVisitOptions} SoundingVisitOptions */
15
+
16
+ /**
17
+ * @param {string | string[]} value
18
+ * @returns {string}
19
+ */
7
20
  function joinHeaderValue(value) {
8
21
  return Array.isArray(value) ? value.join(',') : value
9
22
  }
10
23
 
24
+ /**
25
+ * @param {SoundingVisitOptions} [options]
26
+ * @returns {AnyRecord}
27
+ */
11
28
  function buildVisitHeaders(options = {}) {
12
29
  const headers = {
13
30
  ...(options.headers || {}),
@@ -23,7 +40,13 @@ function buildVisitHeaders(options = {}) {
23
40
 
24
41
  if (options.only?.length) {
25
42
  if (!options.component) {
26
- throw new Error('Sounding visit() requires `component` when using `only`.')
43
+ throw createSoundingError({
44
+ code: 'E_SOUNDING_VISIT_COMPONENT_REQUIRED',
45
+ message: 'Sounding visit() requires `component` when using `only`.',
46
+ details: {
47
+ partialReload: 'only',
48
+ },
49
+ })
27
50
  }
28
51
 
29
52
  headers['x-inertia-partial-component'] = options.component
@@ -32,7 +55,13 @@ function buildVisitHeaders(options = {}) {
32
55
 
33
56
  if (options.except?.length) {
34
57
  if (!options.component) {
35
- throw new Error('Sounding visit() requires `component` when using `except`.')
58
+ throw createSoundingError({
59
+ code: 'E_SOUNDING_VISIT_COMPONENT_REQUIRED',
60
+ message: 'Sounding visit() requires `component` when using `except`.',
61
+ details: {
62
+ partialReload: 'except',
63
+ },
64
+ })
36
65
  }
37
66
 
38
67
  headers['x-inertia-partial-component'] = options.component
@@ -46,6 +75,10 @@ function buildVisitHeaders(options = {}) {
46
75
  return headers
47
76
  }
48
77
 
78
+ /**
79
+ * @param {SoundingVisitOptions} [options]
80
+ * @returns {SoundingRequestOptions}
81
+ */
49
82
  function buildRequestOptions(options = {}) {
50
83
  const {
51
84
  component,
@@ -78,6 +111,10 @@ function buildRequestOptions(options = {}) {
78
111
  return output
79
112
  }
80
113
 
114
+ /**
115
+ * @param {{ request: SoundingRequestClient }} input
116
+ * @returns {SoundingVisitClient}
117
+ */
81
118
  function createVisitClient({ request }) {
82
119
  const client = request.withHeaders(DEFAULT_HEADERS)
83
120
 
@@ -95,6 +132,7 @@ function createVisitClient({ request }) {
95
132
  client.delete(target, payload, buildRequestOptions(options))
96
133
  visit.del = visit.delete
97
134
  visit.using = (transport) => createVisitClient({ request: request.using(transport) })
135
+ visit.as = (actor) => createVisitClient({ request: request.as(actor) })
98
136
 
99
137
  Object.defineProperty(visit, 'transport', {
100
138
  enumerable: true,
@@ -2,10 +2,27 @@ const {
2
2
  isFactoryDefinition,
3
3
  isScenarioDefinition,
4
4
  } = require('./define-world')
5
-
5
+ const { createSoundingError } = require('./create-error')
6
+
7
+ /** @typedef {import('./types').AnyRecord} AnyRecord */
8
+ /** @typedef {import('./types').SoundingBuilder} SoundingBuilder */
9
+ /** @typedef {import('./types').SoundingFactoryDefinition} SoundingFactoryDefinition */
10
+ /** @typedef {import('./types').SoundingFactoryRegistration} SoundingFactoryRegistration */
11
+ /** @typedef {import('./types').SoundingSailsApp} SoundingSailsApp */
12
+ /** @typedef {import('./types').SoundingScenarioDefinition} SoundingScenarioDefinition */
13
+ /** @typedef {import('./types').SoundingWorldEngine} SoundingWorldEngine */
14
+
15
+ /**
16
+ * @param {AnyRecord} base
17
+ * @param {AnyRecord | ((base: AnyRecord) => AnyRecord)} patch
18
+ * @returns {AnyRecord}
19
+ */
6
20
  function mergeValue(base, patch) {
7
21
  if (typeof patch === 'function') {
8
- return patch(base)
22
+ return {
23
+ ...base,
24
+ ...(patch(base) || {}),
25
+ }
9
26
  }
10
27
 
11
28
  return {
@@ -14,6 +31,10 @@ function mergeValue(base, patch) {
14
31
  }
15
32
  }
16
33
 
34
+ /**
35
+ * @param {{ sequence: Function }} input
36
+ * @returns {import('./types').SoundingFactoryHelpers['fake']}
37
+ */
17
38
  function createFakeHelpers({ sequence }) {
18
39
  return {
19
40
  person: {
@@ -37,6 +58,11 @@ function createFakeHelpers({ sequence }) {
37
58
  }
38
59
  }
39
60
 
61
+ /**
62
+ * @param {(overrides: AnyRecord, options: { traits: string[] }) => any} executor
63
+ * @param {AnyRecord} [initialOverrides]
64
+ * @returns {SoundingBuilder}
65
+ */
40
66
  function createThenableBuilder(executor, initialOverrides = {}) {
41
67
  const state = {
42
68
  overrides: initialOverrides,
@@ -61,6 +87,14 @@ function createThenableBuilder(executor, initialOverrides = {}) {
61
87
  },
62
88
 
63
89
  with(overrides = {}) {
90
+ state.overrides = {
91
+ ...state.overrides,
92
+ ...overrides,
93
+ }
94
+ return builder
95
+ },
96
+
97
+ withOnly(overrides = {}) {
64
98
  state.overrides = overrides
65
99
  return builder
66
100
  },
@@ -85,6 +119,18 @@ function createThenableBuilder(executor, initialOverrides = {}) {
85
119
  return builder
86
120
  }
87
121
 
122
+ /**
123
+ * @param {string[]} values
124
+ * @returns {string}
125
+ */
126
+ function formatAvailable(values) {
127
+ return values.length ? values.join(', ') : 'none'
128
+ }
129
+
130
+ /**
131
+ * @param {{ sails?: SoundingSailsApp }} input
132
+ * @returns {SoundingWorldEngine}
133
+ */
88
134
  function createWorldEngine({ sails }) {
89
135
  const factories = new Map()
90
136
  const scenarios = new Map()
@@ -104,7 +150,15 @@ function createWorldEngine({ sails }) {
104
150
  const entry = factories.get(name)
105
151
 
106
152
  if (!entry) {
107
- throw new Error(`Unknown Sounding factory: ${name}`)
153
+ const availableFactories = Array.from(factories.keys()).sort()
154
+ throw createSoundingError({
155
+ code: 'E_SOUNDING_WORLD_FACTORY_UNKNOWN',
156
+ message: `Unknown Sounding factory: ${name}. Available factories: ${formatAvailable(availableFactories)}.`,
157
+ details: {
158
+ factory: name,
159
+ availableFactories,
160
+ },
161
+ })
108
162
  }
109
163
 
110
164
  return entry
@@ -126,7 +180,16 @@ function createWorldEngine({ sails }) {
126
180
 
127
181
  for (const traitName of options.traits || []) {
128
182
  if (!entry.traits.has(traitName)) {
129
- throw new Error(`Unknown Sounding trait \`${traitName}\` for factory \`${name}\``)
183
+ const availableTraits = Array.from(entry.traits.keys()).sort()
184
+ throw createSoundingError({
185
+ code: 'E_SOUNDING_WORLD_TRAIT_UNKNOWN',
186
+ message: `Unknown Sounding trait \`${traitName}\` for factory \`${name}\`. Available traits for \`${name}\`: ${formatAvailable(availableTraits)}.`,
187
+ details: {
188
+ factory: name,
189
+ trait: traitName,
190
+ availableTraits,
191
+ },
192
+ })
130
193
  }
131
194
 
132
195
  value = mergeValue(value, entry.traits.get(traitName))
@@ -150,6 +213,10 @@ function createWorldEngine({ sails }) {
150
213
  return value
151
214
  }
152
215
 
216
+ /**
217
+ * @param {any} entry
218
+ * @returns {SoundingFactoryRegistration}
219
+ */
153
220
  function registerFactoryDefinition(entry) {
154
221
  const nextEntry = {
155
222
  definition: entry.definition,
@@ -166,11 +233,20 @@ function createWorldEngine({ sails }) {
166
233
  }
167
234
  }
168
235
 
236
+ /**
237
+ * @param {any} entry
238
+ * @returns {SoundingScenarioDefinition}
239
+ */
169
240
  function registerScenarioDefinition(entry) {
170
241
  scenarios.set(entry.name, entry.definition)
171
242
  return entry
172
243
  }
173
244
 
245
+ /**
246
+ * @param {string | any} nameOrEntry
247
+ * @param {any} [definition]
248
+ * @returns {SoundingFactoryRegistration}
249
+ */
174
250
  function defineFactory(nameOrEntry, definition) {
175
251
  if (isFactoryDefinition(nameOrEntry)) {
176
252
  return registerFactoryDefinition(nameOrEntry)
@@ -185,6 +261,11 @@ function createWorldEngine({ sails }) {
185
261
  return registerFactoryDefinition(entry)
186
262
  }
187
263
 
264
+ /**
265
+ * @param {string | any} nameOrEntry
266
+ * @param {any} [definition]
267
+ * @returns {SoundingScenarioDefinition}
268
+ */
188
269
  function defineScenario(nameOrEntry, definition) {
189
270
  if (isScenarioDefinition(nameOrEntry)) {
190
271
  return registerScenarioDefinition(nameOrEntry)
@@ -200,7 +281,15 @@ function createWorldEngine({ sails }) {
200
281
  const definition = scenarios.get(name)
201
282
 
202
283
  if (!definition) {
203
- throw new Error(`Unknown Sounding scenario: ${name}`)
284
+ const availableScenarios = Array.from(scenarios.keys()).sort()
285
+ throw createSoundingError({
286
+ code: 'E_SOUNDING_WORLD_SCENARIO_UNKNOWN',
287
+ message: `Unknown Sounding scenario: ${name}. Available scenarios: ${formatAvailable(availableScenarios)}.`,
288
+ details: {
289
+ scenario: name,
290
+ availableScenarios,
291
+ },
292
+ })
204
293
  }
205
294
 
206
295
  currentWorld = await definition({
@@ -237,7 +326,14 @@ function createWorldEngine({ sails }) {
237
326
  },
238
327
 
239
328
  create(name, overrides = {}, options = {}) {
240
- return createOne(name, overrides, options)
329
+ return createThenableBuilder(
330
+ (nextOverrides, nextOptions) =>
331
+ createOne(name, nextOverrides, {
332
+ ...options,
333
+ traits: [...(options.traits || []), ...(nextOptions.traits || [])],
334
+ }),
335
+ overrides
336
+ )
241
337
  },
242
338
 
243
339
  async createMany(name, count, overrides = {}, options = {}) {
@@ -260,7 +356,10 @@ function createWorldEngine({ sails }) {
260
356
  return defineScenario(definition)
261
357
  }
262
358
 
263
- throw new Error('Sounding could not register an unknown world definition.')
359
+ throw createSoundingError({
360
+ code: 'E_SOUNDING_WORLD_DEFINITION_UNKNOWN',
361
+ message: 'Sounding could not register an unknown world definition.',
362
+ })
264
363
  },
265
364
 
266
365
  get current() {
@@ -8,17 +8,29 @@ const {
8
8
  isFactoryDefinition,
9
9
  isScenarioDefinition,
10
10
  } = require('./define-world')
11
+ const { createSoundingError } = require('./create-error')
12
+
13
+ /** @typedef {import('./types').AnyRecord} AnyRecord */
14
+ /** @typedef {import('./types').SoundingConfig} SoundingConfig */
15
+ /** @typedef {import('./types').SoundingSailsApp} SoundingSailsApp */
16
+ /** @typedef {import('./types').SoundingWorldEngine} SoundingWorldEngine */
11
17
 
12
18
  const WORLD_EXTENSIONS = new Set(['.js', '.cjs', '.mjs'])
13
19
 
14
- function listDefinitionFiles(directory) {
20
+ /**
21
+ * @param {string} directory
22
+ * @param {{ directories?: string[] }} [options]
23
+ * @returns {string[]}
24
+ */
25
+ function listDefinitionFiles(directory, options = {}) {
15
26
  const output = []
27
+ options.directories?.push(directory)
16
28
 
17
29
  for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
18
30
  const nextPath = path.join(directory, entry.name)
19
31
 
20
32
  if (entry.isDirectory()) {
21
- output.push(...listDefinitionFiles(nextPath))
33
+ output.push(...listDefinitionFiles(nextPath, options))
22
34
  continue
23
35
  }
24
36
 
@@ -30,6 +42,91 @@ function listDefinitionFiles(directory) {
30
42
  return output.sort()
31
43
  }
32
44
 
45
+ /**
46
+ * @param {string} entryPath
47
+ * @returns {{ path: string, mtimeMs: number, size: number }}
48
+ */
49
+ function getFileSignature(entryPath) {
50
+ const stat = fs.statSync(entryPath)
51
+
52
+ return {
53
+ path: entryPath,
54
+ mtimeMs: stat.mtimeMs,
55
+ size: stat.size,
56
+ }
57
+ }
58
+
59
+ /**
60
+ * @returns {{
61
+ * directories: Map<string, { signatures: Array<{ path: string, mtimeMs: number, size: number }>, files: string[] }>,
62
+ * modules: Map<string, { signature: { path: string, mtimeMs: number, size: number }, value: any }>,
63
+ * stats: { directoryScans: number, moduleLoads: number },
64
+ * clear(): void,
65
+ * }}
66
+ */
67
+ function createWorldLoaderCache() {
68
+ const stats = {
69
+ directoryScans: 0,
70
+ moduleLoads: 0,
71
+ }
72
+
73
+ return {
74
+ directories: new Map(),
75
+ modules: new Map(),
76
+ stats,
77
+ clear() {
78
+ this.directories.clear()
79
+ this.modules.clear()
80
+ stats.directoryScans = 0
81
+ stats.moduleLoads = 0
82
+ },
83
+ }
84
+ }
85
+
86
+ /**
87
+ * @param {{ path: string, mtimeMs: number, size: number }} expected
88
+ * @returns {boolean}
89
+ */
90
+ function signatureMatches(expected) {
91
+ if (!fs.existsSync(expected.path)) {
92
+ return false
93
+ }
94
+
95
+ const actual = getFileSignature(expected.path)
96
+ return actual.mtimeMs === expected.mtimeMs && actual.size === expected.size
97
+ }
98
+
99
+ /**
100
+ * @param {string} directory
101
+ * @param {ReturnType<typeof createWorldLoaderCache> | undefined} cache
102
+ * @returns {string[]}
103
+ */
104
+ function getDefinitionFiles(directory, cache) {
105
+ if (!cache) {
106
+ return listDefinitionFiles(directory)
107
+ }
108
+
109
+ const cached = cache.directories.get(directory)
110
+
111
+ if (cached && cached.signatures.every(signatureMatches)) {
112
+ return cached.files
113
+ }
114
+
115
+ const directories = []
116
+ const files = listDefinitionFiles(directory, { directories })
117
+ cache.stats.directoryScans += 1
118
+ cache.directories.set(directory, {
119
+ signatures: directories.map(getFileSignature),
120
+ files,
121
+ })
122
+
123
+ return files
124
+ }
125
+
126
+ /**
127
+ * @param {string} filePath
128
+ * @returns {Promise<any>}
129
+ */
33
130
  async function loadModule(filePath) {
34
131
  try {
35
132
  delete require.cache[require.resolve(filePath)]
@@ -43,6 +140,42 @@ async function loadModule(filePath) {
43
140
  }
44
141
  }
45
142
 
143
+ /**
144
+ * @param {string} filePath
145
+ * @param {ReturnType<typeof createWorldLoaderCache> | undefined} cache
146
+ * @returns {Promise<any>}
147
+ */
148
+ async function loadCachedModule(filePath, cache) {
149
+ if (!cache) {
150
+ return loadModule(filePath)
151
+ }
152
+
153
+ const signature = getFileSignature(filePath)
154
+ const cached = cache.modules.get(filePath)
155
+
156
+ if (
157
+ cached &&
158
+ cached.signature.mtimeMs === signature.mtimeMs &&
159
+ cached.signature.size === signature.size
160
+ ) {
161
+ return cached.value
162
+ }
163
+
164
+ const value = await loadModule(filePath)
165
+ cache.stats.moduleLoads += 1
166
+ cache.modules.set(filePath, {
167
+ signature,
168
+ value,
169
+ })
170
+ return value
171
+ }
172
+
173
+ /**
174
+ * @param {any} value
175
+ * @param {AnyRecord} api
176
+ * @param {string} source
177
+ * @returns {Promise<void>}
178
+ */
46
179
  async function registerExport(value, api, source) {
47
180
  const entry = value?.default ?? value
48
181
 
@@ -84,12 +217,20 @@ async function registerExport(value, api, source) {
84
217
  return
85
218
  }
86
219
 
87
- throw new Error(
88
- `Sounding could not understand the world definition exported from ${source}.`
89
- )
220
+ throw createSoundingError({
221
+ code: 'E_SOUNDING_WORLD_DEFINITION_UNKNOWN',
222
+ message: `Sounding could not understand the world definition exported from ${source}.`,
223
+ details: {
224
+ source,
225
+ },
226
+ })
90
227
  }
91
228
 
92
- async function loadWorldFiles({ world, appPath, config, sails }) {
229
+ /**
230
+ * @param {{ world: SoundingWorldEngine, appPath: string, config: SoundingConfig, sails?: SoundingSailsApp, cache?: ReturnType<typeof createWorldLoaderCache> }} input
231
+ * @returns {Promise<string[]>}
232
+ */
233
+ async function loadWorldFiles({ world, appPath, config, sails, cache }) {
93
234
  const directories = [config.world?.factories, config.world?.scenarios]
94
235
  .filter(Boolean)
95
236
  .map((relativePath) => path.resolve(appPath, relativePath))
@@ -111,8 +252,8 @@ async function loadWorldFiles({ world, appPath, config, sails }) {
111
252
  continue
112
253
  }
113
254
 
114
- for (const filePath of listDefinitionFiles(directory)) {
115
- const loaded = await loadModule(filePath)
255
+ for (const filePath of getDefinitionFiles(directory, cache)) {
256
+ const loaded = await loadCachedModule(filePath, cache)
116
257
  await registerExport(loaded, api, filePath)
117
258
  loadedFiles.push(filePath)
118
259
  }
@@ -122,6 +263,7 @@ async function loadWorldFiles({ world, appPath, config, sails }) {
122
263
  }
123
264
 
124
265
  module.exports = {
266
+ createWorldLoaderCache,
125
267
  loadWorldFiles,
126
268
  listDefinitionFiles,
127
269
  loadModule,
@@ -1,3 +1,6 @@
1
+ /** @typedef {import('./types').SoundingConfig} SoundingConfig */
2
+
3
+ /** @type {Readonly<SoundingConfig>} */
1
4
  const DEFAULT_CONFIG = Object.freeze({
2
5
  // Sails environments where the Sounding hook should boot.
3
6
  // Keep this test-only by default so non-test processes stay dark.
@@ -27,6 +30,13 @@ const DEFAULT_CONFIG = Object.freeze({
27
30
  launchOptions: {
28
31
  headless: true,
29
32
  },
33
+ artifacts: {
34
+ outputDir: '.tmp/sounding/artifacts',
35
+ screenshot: true,
36
+ trace: false,
37
+ video: false,
38
+ currentUrl: true,
39
+ },
30
40
  },
31
41
  mail: {
32
42
  capture: true,
@@ -35,6 +45,14 @@ const DEFAULT_CONFIG = Object.freeze({
35
45
  request: {
36
46
  transport: 'virtual',
37
47
  },
48
+ sockets: {
49
+ enabled: true,
50
+ timeout: 1000,
51
+ transports: ['websocket'],
52
+ path: '/socket.io',
53
+ headers: {},
54
+ initialConnectionHeaders: {},
55
+ },
38
56
  auth: {
39
57
  defaultActor: 'guest',
40
58
  modelIdentity: null,
@@ -55,6 +73,10 @@ const DEFAULT_CONFIG = Object.freeze({
55
73
  },
56
74
  })
57
75
 
76
+ /**
77
+ * @param {any} value
78
+ * @returns {any}
79
+ */
58
80
  function cloneValue(value) {
59
81
  if (Array.isArray(value)) {
60
82
  return value.map(cloneValue)
@@ -69,6 +91,9 @@ function cloneValue(value) {
69
91
  return value
70
92
  }
71
93
 
94
+ /**
95
+ * @returns {SoundingConfig}
96
+ */
72
97
  function getDefaultConfig() {
73
98
  return cloneValue(DEFAULT_CONFIG)
74
99
  }
@@ -1,6 +1,16 @@
1
+ /** @typedef {import('./types').SoundingFactoryDefinition} SoundingFactoryDefinition */
2
+ /** @typedef {import('./types').SoundingScenarioDefinition} SoundingScenarioDefinition */
3
+
4
+ /**
5
+ * Define a reusable record factory for Sounding worlds.
6
+ *
7
+ * @param {string} name
8
+ * @param {SoundingFactoryDefinition['definition']} definition
9
+ * @returns {SoundingFactoryDefinition}
10
+ */
1
11
  function defineFactory(name, definition) {
2
12
  const entry = {
3
- __soundingType: 'factory',
13
+ __soundingType: /** @type {'factory'} */ ('factory'),
4
14
  name,
5
15
  definition,
6
16
  traits: [],
@@ -13,18 +23,33 @@ function defineFactory(name, definition) {
13
23
  return entry
14
24
  }
15
25
 
26
+ /**
27
+ * Define a named scenario that can seed a trial with product-language data.
28
+ *
29
+ * @param {string} name
30
+ * @param {SoundingScenarioDefinition['definition']} definition
31
+ * @returns {SoundingScenarioDefinition}
32
+ */
16
33
  function defineScenario(name, definition) {
17
34
  return {
18
- __soundingType: 'scenario',
35
+ __soundingType: /** @type {'scenario'} */ ('scenario'),
19
36
  name,
20
37
  definition,
21
38
  }
22
39
  }
23
40
 
41
+ /**
42
+ * @param {any} value
43
+ * @returns {value is SoundingFactoryDefinition}
44
+ */
24
45
  function isFactoryDefinition(value) {
25
46
  return value?.__soundingType === 'factory'
26
47
  }
27
48
 
49
+ /**
50
+ * @param {any} value
51
+ * @returns {value is SoundingScenarioDefinition}
52
+ */
28
53
  function isScenarioDefinition(value) {
29
54
  return value?.__soundingType === 'scenario'
30
55
  }