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.
@@ -1,34 +1,371 @@
1
+ const fs = require('node:fs/promises')
2
+ const path = require('node:path')
3
+
1
4
  const { resolveBaseUrl } = require('./create-request-client')
5
+ const { createSoundingError } = require('./create-error')
6
+ const { loadDependencyFromApp } = require('./resolve-dependency')
7
+
8
+ /** @typedef {import('./types').AnyRecord} AnyRecord */
9
+ /** @typedef {import('./types').SoundingBrowserArtifactMode} SoundingBrowserArtifactMode */
10
+ /** @typedef {import('./types').SoundingBrowserArtifacts} SoundingBrowserArtifacts */
11
+ /** @typedef {import('./types').SoundingBrowserResolvedArtifactsConfig} SoundingBrowserResolvedArtifactsConfig */
12
+ /** @typedef {import('./types').SoundingBrowserManager} SoundingBrowserManager */
13
+ /** @typedef {import('./types').SoundingBrowserOpenOptions} SoundingBrowserOpenOptions */
14
+ /** @typedef {import('./types').SoundingBrowserSession} SoundingBrowserSession */
15
+ /** @typedef {import('./types').SoundingSailsApp} SoundingSailsApp */
2
16
 
3
- function resolveModuleFromApp(appPath, moduleId) {
4
- return require(require.resolve(moduleId, { paths: [appPath, process.cwd(), __dirname] }))
17
+ const ARTIFACT_MODES = ['off', 'on', 'on-failure']
18
+ const MOBILE_FALLBACK_OPTIONS = {
19
+ viewport: {
20
+ width: 390,
21
+ height: 844,
22
+ },
23
+ isMobile: true,
24
+ hasTouch: true,
5
25
  }
6
26
 
7
- function defaultLoadPlaywright(appPath) {
8
- return resolveModuleFromApp(appPath, 'playwright')
27
+ /**
28
+ * @param {string} appPath
29
+ * @param {{ resolveImplementation?: (moduleId: string, options?: { paths?: string[] }) => string }} [options]
30
+ * @returns {any}
31
+ */
32
+ function defaultLoadPlaywright(appPath, options = {}) {
33
+ return loadDependencyFromApp({
34
+ appPath,
35
+ moduleId: 'playwright',
36
+ purpose: 'open browser trials',
37
+ install: 'npm install -D playwright',
38
+ resolveImplementation: options.resolveImplementation,
39
+ })
9
40
  }
10
41
 
11
- function defaultLoadPlaywrightTest(appPath) {
12
- return resolveModuleFromApp(appPath, '@playwright/test')
42
+ /**
43
+ * @param {string} appPath
44
+ * @param {{ resolveImplementation?: (moduleId: string, options?: { paths?: string[] }) => string }} [options]
45
+ * @returns {any}
46
+ */
47
+ function defaultLoadPlaywrightTest(appPath, options = {}) {
48
+ return loadDependencyFromApp({
49
+ appPath,
50
+ moduleId: '@playwright/test',
51
+ purpose: 'use Playwright expect fallback in browser trials',
52
+ install: 'npm install -D @playwright/test',
53
+ optional: true,
54
+ resolveImplementation: options.resolveImplementation,
55
+ })
13
56
  }
14
57
 
58
+ /**
59
+ * @param {string} projectName
60
+ * @param {AnyRecord} [devices]
61
+ * @returns {AnyRecord}
62
+ */
15
63
  function resolveProjectOptions(projectName, devices = {}) {
16
64
  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
- )
65
+ return devices['iPhone 13'] || MOBILE_FALLBACK_OPTIONS
27
66
  }
28
67
 
29
68
  return {}
30
69
  }
31
70
 
71
+ /**
72
+ * @param {any} projects
73
+ * @returns {AnyRecord[]}
74
+ */
75
+ function normalizeBrowserProjects(projects) {
76
+ if (Array.isArray(projects)) {
77
+ const entries = projects
78
+ .map((entry) => {
79
+ if (typeof entry === 'string') {
80
+ return {
81
+ name: entry,
82
+ }
83
+ }
84
+
85
+ if (isPlainObject(entry) && typeof entry.name === 'string') {
86
+ return {
87
+ ...entry,
88
+ name: entry.name,
89
+ }
90
+ }
91
+
92
+ return null
93
+ })
94
+ .filter(Boolean)
95
+
96
+ return entries.length ? entries : [{ name: 'desktop' }]
97
+ }
98
+
99
+ if (isPlainObject(projects)) {
100
+ const entries = Object.entries(projects).map(([name, value]) => ({
101
+ ...(isPlainObject(value) ? value : {}),
102
+ name,
103
+ }))
104
+
105
+ return entries.length ? entries : [{ name: 'desktop' }]
106
+ }
107
+
108
+ return [{ name: 'desktop' }]
109
+ }
110
+
111
+ /**
112
+ * @param {any} projects
113
+ * @returns {string[]}
114
+ */
115
+ function getBrowserProjectNames(projects) {
116
+ return normalizeBrowserProjects(projects).map((project) => project.name)
117
+ }
118
+
119
+ /**
120
+ * @param {AnyRecord} project
121
+ * @param {AnyRecord} devices
122
+ * @returns {AnyRecord}
123
+ */
124
+ function resolveProjectDeviceOptions(project, devices = {}) {
125
+ if (project.device) {
126
+ const deviceOptions = devices[project.device]
127
+
128
+ if (!deviceOptions) {
129
+ throw createSoundingError({
130
+ code: 'E_SOUNDING_BROWSER_DEVICE_UNAVAILABLE',
131
+ message: `Sounding could not find a Playwright device named \`${project.device}\` for browser project \`${project.name}\`.`,
132
+ details: {
133
+ project: project.name,
134
+ device: project.device,
135
+ availableDevices: Object.keys(devices).sort(),
136
+ },
137
+ })
138
+ }
139
+
140
+ return deviceOptions
141
+ }
142
+
143
+ return resolveProjectOptions(project.name, devices)
144
+ }
145
+
146
+ /**
147
+ * @param {{
148
+ * config: AnyRecord,
149
+ * options?: SoundingBrowserOpenOptions,
150
+ * devices?: AnyRecord,
151
+ * }} input
152
+ * @returns {{ name: string, type?: string, launchOptions: AnyRecord, contextOptions: AnyRecord }}
153
+ */
154
+ function resolveBrowserProject({ config, options = {}, devices = {} }) {
155
+ const projects = normalizeBrowserProjects(config.browser?.projects)
156
+ const projectName =
157
+ options.project ||
158
+ config.browser?.defaultProject ||
159
+ projects[0]?.name ||
160
+ 'desktop'
161
+ const project = projects.find((candidate) => candidate.name === projectName)
162
+
163
+ if (!project) {
164
+ throw createSoundingError({
165
+ code: 'E_SOUNDING_BROWSER_PROJECT_UNAVAILABLE',
166
+ message: `Sounding could not find a browser project named \`${projectName}\`.`,
167
+ details: {
168
+ project: projectName,
169
+ availableProjects: projects.map((candidate) => candidate.name),
170
+ },
171
+ })
172
+ }
173
+
174
+ return {
175
+ name: project.name,
176
+ type: project.type,
177
+ launchOptions: project.launchOptions || {},
178
+ contextOptions: {
179
+ ...resolveProjectDeviceOptions(project, devices),
180
+ ...(project.viewport ? { viewport: project.viewport } : {}),
181
+ ...(project.contextOptions || {}),
182
+ },
183
+ }
184
+ }
185
+
186
+ /**
187
+ * @param {any} value
188
+ * @param {SoundingBrowserArtifactMode} fallback
189
+ * @returns {SoundingBrowserArtifactMode}
190
+ */
191
+ function normalizeArtifactMode(value, fallback) {
192
+ if (value === undefined) {
193
+ return fallback
194
+ }
195
+
196
+ if (value === true) {
197
+ return 'on-failure'
198
+ }
199
+
200
+ if (value === false || value === null) {
201
+ return 'off'
202
+ }
203
+
204
+ if (ARTIFACT_MODES.includes(value)) {
205
+ return value
206
+ }
207
+
208
+ return fallback
209
+ }
210
+
211
+ /**
212
+ * @param {SoundingBrowserArtifactMode} mode
213
+ * @returns {boolean}
214
+ */
215
+ function recordsArtifact(mode) {
216
+ return mode === 'on' || mode === 'on-failure'
217
+ }
218
+
219
+ /**
220
+ * @param {SoundingBrowserArtifactMode} mode
221
+ * @returns {boolean}
222
+ */
223
+ function capturesFailureArtifact(mode) {
224
+ return mode === 'on' || mode === 'on-failure'
225
+ }
226
+
227
+ /**
228
+ * @param {SoundingBrowserArtifactMode} mode
229
+ * @returns {boolean}
230
+ */
231
+ function capturesSuccessArtifact(mode) {
232
+ return mode === 'on'
233
+ }
234
+
235
+ /**
236
+ * @param {any} value
237
+ * @returns {value is AnyRecord}
238
+ */
239
+ function isPlainObject(value) {
240
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
241
+ }
242
+
243
+ /**
244
+ * @param {AnyRecord | boolean | undefined} value
245
+ * @param {SoundingBrowserResolvedArtifactsConfig} fallback
246
+ * @returns {SoundingBrowserResolvedArtifactsConfig}
247
+ */
248
+ function mergeArtifactsConfig(value, fallback) {
249
+ if (value === false) {
250
+ return {
251
+ ...fallback,
252
+ screenshot: 'off',
253
+ trace: 'off',
254
+ video: 'off',
255
+ currentUrl: false,
256
+ }
257
+ }
258
+
259
+ if (value === true || value === undefined || value === null) {
260
+ return fallback
261
+ }
262
+
263
+ if (!isPlainObject(value)) {
264
+ return fallback
265
+ }
266
+
267
+ return {
268
+ outputDir:
269
+ typeof value.outputDir === 'string' && value.outputDir.trim()
270
+ ? value.outputDir
271
+ : fallback.outputDir,
272
+ screenshot: normalizeArtifactMode(value.screenshot, fallback.screenshot),
273
+ trace: normalizeArtifactMode(value.trace, fallback.trace),
274
+ video: normalizeArtifactMode(value.video, fallback.video),
275
+ currentUrl:
276
+ typeof value.currentUrl === 'boolean' ? value.currentUrl : fallback.currentUrl,
277
+ }
278
+ }
279
+
280
+ /**
281
+ * @param {AnyRecord} config
282
+ * @param {SoundingBrowserOpenOptions} options
283
+ * @returns {SoundingBrowserResolvedArtifactsConfig}
284
+ */
285
+ function resolveArtifactsConfig(config, options = {}) {
286
+ const defaults = /** @type {SoundingBrowserResolvedArtifactsConfig} */ ({
287
+ outputDir: '.tmp/sounding/artifacts',
288
+ screenshot: 'on-failure',
289
+ trace: 'off',
290
+ video: 'off',
291
+ currentUrl: true,
292
+ })
293
+ const globalArtifacts = mergeArtifactsConfig(config.browser?.artifacts, defaults)
294
+
295
+ return mergeArtifactsConfig(options.artifacts, globalArtifacts)
296
+ }
297
+
298
+ /**
299
+ * @param {string} value
300
+ * @returns {string}
301
+ */
302
+ function slugifyArtifactSegment(value) {
303
+ const slug = String(value || '')
304
+ .toLowerCase()
305
+ .replace(/['"`]/g, '')
306
+ .replace(/[^a-z0-9]+/g, '-')
307
+ .replace(/^-+|-+$/g, '')
308
+
309
+ return slug || 'browser-trial'
310
+ }
311
+
312
+ /**
313
+ * @param {string} appPath
314
+ * @param {SoundingBrowserResolvedArtifactsConfig} artifacts
315
+ * @param {{ trialName?: string, projectName: string }} metadata
316
+ */
317
+ function resolveArtifactPaths(appPath, artifacts, metadata) {
318
+ const outputRoot = path.isAbsolute(artifacts.outputDir)
319
+ ? artifacts.outputDir
320
+ : path.resolve(appPath, artifacts.outputDir)
321
+ const trialSlug = slugifyArtifactSegment(metadata.trialName || 'browser-trial')
322
+ const projectSlug = slugifyArtifactSegment(metadata.projectName || 'desktop')
323
+ const directory = path.join(outputRoot, trialSlug, projectSlug)
324
+
325
+ return {
326
+ outputRoot,
327
+ directory,
328
+ currentUrl: path.join(directory, 'current-url.txt'),
329
+ screenshot: path.join(directory, 'screenshot.png'),
330
+ trace: path.join(directory, 'trace.zip'),
331
+ video: path.join(directory, 'video.webm'),
332
+ }
333
+ }
334
+
335
+ /**
336
+ * @param {unknown} error
337
+ * @returns {string}
338
+ */
339
+ function formatCaptureError(error) {
340
+ if (error instanceof Error) {
341
+ return error.message
342
+ }
343
+
344
+ return String(error)
345
+ }
346
+
347
+ /**
348
+ * @param {SoundingBrowserArtifacts['errors']} errors
349
+ * @param {string} artifact
350
+ * @param {unknown} error
351
+ */
352
+ function pushCaptureError(errors, artifact, error) {
353
+ errors.push({
354
+ artifact,
355
+ message: formatCaptureError(error),
356
+ })
357
+ }
358
+
359
+ /**
360
+ * @param {{
361
+ * sails?: SoundingSailsApp,
362
+ * getConfig?: () => AnyRecord,
363
+ * appPathResolver?: () => string,
364
+ * loadPlaywright?: (appPath: string) => any,
365
+ * loadPlaywrightTest?: (appPath: string) => any,
366
+ * }} [options]
367
+ * @returns {SoundingBrowserManager}
368
+ */
32
369
  function createBrowserManager({
33
370
  sails,
34
371
  getConfig,
@@ -38,6 +375,10 @@ function createBrowserManager({
38
375
  } = {}) {
39
376
  let session = null
40
377
 
378
+ /**
379
+ * @param {SoundingBrowserOpenOptions} [options]
380
+ * @returns {Promise<SoundingBrowserSession>}
381
+ */
41
382
  async function open(options = {}) {
42
383
  if (session) {
43
384
  return session
@@ -46,43 +387,238 @@ function createBrowserManager({
46
387
  const config = typeof getConfig === 'function' ? getConfig() : sails?.config?.sounding || {}
47
388
 
48
389
  if (config.browser?.enabled === false) {
49
- throw new Error('Sounding browser support is disabled in `config/sounding.js`.')
390
+ throw createSoundingError({
391
+ code: 'E_SOUNDING_BROWSER_DISABLED',
392
+ message: 'Sounding browser support is disabled in `config/sounding.js`.',
393
+ })
50
394
  }
51
395
 
52
396
  const appPath = appPathResolver()
53
397
  const playwright = await loadPlaywright(appPath)
54
398
  const playwrightTest = await Promise.resolve()
55
399
  .then(() => loadPlaywrightTest(appPath))
56
- .catch(() => null)
400
+ .catch((error) => {
401
+ if (
402
+ error?.code === 'E_SOUNDING_DEPENDENCY_MISSING' &&
403
+ error.dependency === '@playwright/test'
404
+ ) {
405
+ return null
406
+ }
407
+
408
+ throw error
409
+ })
57
410
 
58
- const browserTypeName = options.type || config.browser?.type || 'chromium'
411
+ const project = resolveBrowserProject({
412
+ config,
413
+ options,
414
+ devices: playwright.devices || {},
415
+ })
416
+ const browserTypeName = options.type || project.type || config.browser?.type || 'chromium'
59
417
  const browserType = playwright?.[browserTypeName]
60
418
 
61
419
  if (!browserType?.launch) {
62
- throw new Error(
63
- `Sounding could not find a Playwright browser type named \`${browserTypeName}\`.`
64
- )
420
+ throw createSoundingError({
421
+ code: 'E_SOUNDING_BROWSER_TYPE_UNAVAILABLE',
422
+ message: `Sounding could not find a Playwright browser type named \`${browserTypeName}\`.`,
423
+ details: {
424
+ browserType: browserTypeName,
425
+ },
426
+ })
427
+ }
428
+
429
+ const artifacts = resolveArtifactsConfig(config, options)
430
+ const artifactPaths = resolveArtifactPaths(appPath, artifacts, {
431
+ trialName: options.trialName,
432
+ projectName: project.name,
433
+ })
434
+ const contextOptions = {
435
+ ...project.contextOptions,
436
+ ...(options.contextOptions || {}),
65
437
  }
66
438
 
67
- const projectName =
68
- options.project ||
69
- config.browser?.defaultProject ||
70
- config.browser?.projects?.[0] ||
71
- 'desktop'
439
+ if (recordsArtifact(artifacts.video) && contextOptions.recordVideo === undefined) {
440
+ contextOptions.recordVideo = {
441
+ dir: artifactPaths.directory,
442
+ }
443
+ }
72
444
 
73
445
  const browser = await browserType.launch({
74
446
  headless: true,
75
447
  ...(config.browser?.launchOptions || {}),
448
+ ...project.launchOptions,
76
449
  ...(options.launchOptions || {}),
77
450
  })
78
451
 
79
452
  const context = await browser.newContext({
80
453
  baseURL: resolveBaseUrl({ sails, getConfig }),
81
- ...resolveProjectOptions(projectName, playwright.devices || {}),
82
- ...(options.contextOptions || {}),
454
+ ...contextOptions,
83
455
  })
84
456
 
85
457
  const page = await context.newPage()
458
+ let contextClosed = false
459
+ let traceStarted = false
460
+ let traceStopped = false
461
+ /** @type {SoundingBrowserArtifacts | null} */
462
+ let latestArtifacts = null
463
+
464
+ if (recordsArtifact(artifacts.trace) && context.tracing?.start) {
465
+ await context.tracing.start({
466
+ screenshots: true,
467
+ snapshots: true,
468
+ sources: true,
469
+ })
470
+ traceStarted = true
471
+ }
472
+
473
+ /**
474
+ * @param {SoundingBrowserArtifacts} collected
475
+ * @param {boolean} keepTrace
476
+ */
477
+ async function finalizeTrace(collected, keepTrace) {
478
+ if (!traceStarted || traceStopped || !context.tracing?.stop) {
479
+ return
480
+ }
481
+
482
+ try {
483
+ if (keepTrace) {
484
+ await fs.mkdir(artifactPaths.directory, { recursive: true })
485
+ await context.tracing.stop({ path: artifactPaths.trace })
486
+ collected.trace = artifactPaths.trace
487
+ } else {
488
+ await context.tracing.stop()
489
+ }
490
+
491
+ traceStopped = true
492
+ } catch (error) {
493
+ pushCaptureError(collected.errors, 'trace', error)
494
+ }
495
+ }
496
+
497
+ /**
498
+ * @param {SoundingBrowserArtifacts} collected
499
+ * @param {boolean} keepVideo
500
+ */
501
+ async function finalizeVideo(collected, keepVideo) {
502
+ const video = typeof page.video === 'function' ? page.video() : null
503
+
504
+ if (!video) {
505
+ return
506
+ }
507
+
508
+ try {
509
+ if (keepVideo) {
510
+ await fs.mkdir(artifactPaths.directory, { recursive: true })
511
+
512
+ if (typeof video.saveAs === 'function') {
513
+ await video.saveAs(artifactPaths.video)
514
+ collected.video = artifactPaths.video
515
+ return
516
+ }
517
+
518
+ if (typeof video.path === 'function') {
519
+ collected.video = await video.path()
520
+ }
521
+
522
+ return
523
+ }
524
+
525
+ if (typeof video.delete === 'function') {
526
+ await video.delete()
527
+ return
528
+ }
529
+
530
+ if (typeof video.path === 'function') {
531
+ const videoPath = await video.path()
532
+ await fs.rm(videoPath, { force: true }).catch(() => {})
533
+ }
534
+ } catch (error) {
535
+ pushCaptureError(collected.errors, 'video', error)
536
+ }
537
+ }
538
+
539
+ /**
540
+ * @param {{ failed: boolean, collected: SoundingBrowserArtifacts }} args
541
+ */
542
+ async function closeContext({ failed, collected }) {
543
+ if (contextClosed) {
544
+ return
545
+ }
546
+
547
+ await finalizeTrace(
548
+ collected,
549
+ failed ? capturesFailureArtifact(artifacts.trace) : capturesSuccessArtifact(artifacts.trace)
550
+ )
551
+ await context.close?.()
552
+ contextClosed = true
553
+ await finalizeVideo(
554
+ collected,
555
+ failed ? capturesFailureArtifact(artifacts.video) : capturesSuccessArtifact(artifacts.video)
556
+ )
557
+ }
558
+
559
+ /**
560
+ * @returns {SoundingBrowserArtifacts}
561
+ */
562
+ function createArtifactsMetadata() {
563
+ return {
564
+ outputDir: artifactPaths.outputRoot,
565
+ directory: artifactPaths.directory,
566
+ project: project.name,
567
+ trialName: options.trialName,
568
+ errors: [],
569
+ }
570
+ }
571
+
572
+ async function captureFailureArtifacts() {
573
+ const collected = createArtifactsMetadata()
574
+
575
+ if (artifacts.currentUrl && typeof page.url === 'function') {
576
+ try {
577
+ const currentUrl = page.url()
578
+ if (currentUrl) {
579
+ await fs.mkdir(artifactPaths.directory, { recursive: true })
580
+ await fs.writeFile(artifactPaths.currentUrl, `${currentUrl}\n`)
581
+ collected.currentUrl = currentUrl
582
+ collected.currentUrlPath = artifactPaths.currentUrl
583
+ }
584
+ } catch (error) {
585
+ pushCaptureError(collected.errors, 'currentUrl', error)
586
+ }
587
+ }
588
+
589
+ if (capturesFailureArtifact(artifacts.screenshot) && typeof page.screenshot === 'function') {
590
+ try {
591
+ await fs.mkdir(artifactPaths.directory, { recursive: true })
592
+ await page.screenshot({
593
+ path: artifactPaths.screenshot,
594
+ fullPage: true,
595
+ })
596
+ collected.screenshot = artifactPaths.screenshot
597
+ } catch (error) {
598
+ pushCaptureError(collected.errors, 'screenshot', error)
599
+ }
600
+ }
601
+
602
+ await finalizeTrace(collected, capturesFailureArtifact(artifacts.trace))
603
+
604
+ if (recordsArtifact(artifacts.video)) {
605
+ try {
606
+ await closeContext({ failed: true, collected })
607
+ } catch (error) {
608
+ pushCaptureError(collected.errors, 'video', error)
609
+ }
610
+ }
611
+
612
+ latestArtifacts = collected
613
+ return collected
614
+ }
615
+
616
+ async function closeSessionContext() {
617
+ const collected = latestArtifacts || createArtifactsMetadata()
618
+ await closeContext({ failed: false, collected })
619
+ latestArtifacts = collected
620
+ return collected
621
+ }
86
622
 
87
623
  session = {
88
624
  playwright,
@@ -90,7 +626,13 @@ function createBrowserManager({
90
626
  context,
91
627
  page,
92
628
  expect: playwrightTest?.expect,
93
- project: projectName,
629
+ project: project.name,
630
+ artifacts,
631
+ captureFailureArtifacts,
632
+ closeSessionContext,
633
+ get latestArtifacts() {
634
+ return latestArtifacts
635
+ },
94
636
  }
95
637
 
96
638
  return session
@@ -101,7 +643,7 @@ function createBrowserManager({
101
643
  return
102
644
  }
103
645
 
104
- await session.context?.close?.()
646
+ await session.closeSessionContext?.()
105
647
  await session.browser?.close?.()
106
648
  session = null
107
649
  }
@@ -129,4 +671,9 @@ module.exports = {
129
671
  defaultLoadPlaywright,
130
672
  defaultLoadPlaywrightTest,
131
673
  resolveProjectOptions,
674
+ resolveBrowserProject,
675
+ normalizeBrowserProjects,
676
+ getBrowserProjectNames,
677
+ resolveArtifactsConfig,
678
+ slugifyArtifactSegment,
132
679
  }
@@ -0,0 +1,35 @@
1
+ /** @typedef {import('./types').AnyRecord} AnyRecord */
2
+
3
+ /**
4
+ * Sounding error codes are a lightweight diagnostic contract for tests,
5
+ * reporters, and docs. Keep messages human-readable, and use code/details for
6
+ * stable programmatic handling.
7
+ *
8
+ * @template {AnyRecord} TDetails
9
+ * @param {{
10
+ * code: string,
11
+ * message: string,
12
+ * details?: TDetails,
13
+ * cause?: unknown,
14
+ * name?: string,
15
+ * }} input
16
+ * @returns {Error & { code: string, details: TDetails, [key: string]: any } & TDetails}
17
+ */
18
+ function createSoundingError({ code, message, details, cause, name }) {
19
+ const resolvedDetails = /** @type {TDetails} */ (details || {})
20
+ const error = /** @type {Error & { code: string, details: TDetails, [key: string]: any } & TDetails} */ (
21
+ cause === undefined ? new Error(message) : new Error(message, { cause })
22
+ )
23
+
24
+ Object.assign(error, resolvedDetails)
25
+ error.name = name || 'SoundingError'
26
+ error.message = message
27
+ error.code = code
28
+ error.details = /** @type {TDetails} */ ({ ...resolvedDetails })
29
+
30
+ return error
31
+ }
32
+
33
+ module.exports = {
34
+ createSoundingError,
35
+ }