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,6 +1,21 @@
1
1
  const nodeTest = require('node:test')
2
2
  const { createAppManager } = require('./create-app-manager')
3
3
  const { createExpect } = require('./create-expect')
4
+ const { createRuntime } = require('./create-runtime')
5
+ const { createSoundingError } = require('./create-error')
6
+ const { runWithTrialContext } = require('./trial-context')
7
+ const { normalizeTestArgs, splitTestOptions } = require('./validate-test-args')
8
+
9
+ /** @typedef {import('./types').SoundingRuntime} SoundingRuntime */
10
+ /** @typedef {import('./types').SoundingExpect} SoundingExpect */
11
+ /** @typedef {import('./types').SoundingBrowserArtifacts} SoundingBrowserArtifacts */
12
+ /** @typedef {import('./types').SoundingBrowserSession} SoundingBrowserSession */
13
+ /** @typedef {import('./types').SoundingTest} SoundingTest */
14
+ /** @typedef {import('./types').SoundingTestOptions} SoundingTestOptions */
15
+ /** @typedef {import('./types').SoundingTrialContext} SoundingTrialContext */
16
+ /** @typedef {import('./types').SoundingTrialHandler} SoundingTrialHandler */
17
+ /** @typedef {import('./types').SoundingTrialRegistrar} SoundingTrialRegistrar */
18
+ /** @typedef {Function & { skip?: Function, todo?: Function, only?: Function }} NodeTestLike */
4
19
 
5
20
  let defaultAppManager = null
6
21
  let defaultCleanupRegistered = false
@@ -25,71 +40,365 @@ function ensureDefaultAppManagerCleanup() {
25
40
  defaultCleanupRegistered = true
26
41
  }
27
42
 
28
- function normalizeTestArgs(title, optionsOrHandler, maybeHandler) {
29
- if (typeof optionsOrHandler === 'function') {
43
+ /**
44
+ * @param {{ requiresHttp?: boolean, browser?: boolean, socket?: boolean }} [options]
45
+ * @returns {Promise<{ sounding: SoundingRuntime, teardown(): Promise<void> }>}
46
+ */
47
+ async function resolveRuntimeFromGlobals(options = {}) {
48
+ const runtime = globalThis.sounding || globalThis.sails?.sounding || globalThis.sails?.hooks?.sounding
49
+ const requiresHttp = Boolean(options.requiresHttp || options.browser || options.socket)
50
+ const httpServer = globalThis.sails?.hooks?.http?.server
51
+ const hasHttpServer = Boolean(
52
+ httpServer &&
53
+ (httpServer.listening ||
54
+ (typeof httpServer.address === 'function' && httpServer.address()))
55
+ )
56
+
57
+ if (runtime && (!requiresHttp || hasHttpServer)) {
30
58
  return {
31
- title,
32
- options: {},
33
- handler: optionsOrHandler,
59
+ sounding: runtime,
60
+ teardown: async () => runtime.lower(),
34
61
  }
35
62
  }
36
63
 
64
+ const appManager = getDefaultAppManager()
65
+ ensureDefaultAppManagerCleanup()
66
+ const sounding = await appManager.runtime({ app: requiresHttp ? 'lift' : 'load' })
37
67
  return {
38
- title,
39
- options: optionsOrHandler || {},
40
- handler: maybeHandler,
68
+ sounding,
69
+ teardown: async () => sounding.lower(),
41
70
  }
42
71
  }
43
72
 
44
- async function resolveRuntimeFromGlobals(options = {}) {
45
- const runtime = globalThis.sounding || globalThis.sails?.sounding || globalThis.sails?.hooks?.sounding
46
- const requiresHttp = Boolean(options.http || options.browser)
73
+ /**
74
+ * @param {{ requiresHttp?: boolean, browser?: boolean, socket?: boolean }} [options]
75
+ * @returns {Promise<{ sounding: SoundingRuntime, teardown(): Promise<void> }>}
76
+ */
77
+ async function resolveIsolatedRuntimeFromGlobals(options = {}) {
78
+ const requiresHttp = Boolean(options.requiresHttp || options.browser || options.socket)
47
79
  const httpServer = globalThis.sails?.hooks?.http?.server
48
80
  const hasHttpServer = Boolean(
49
81
  httpServer &&
50
82
  (httpServer.listening ||
51
83
  (typeof httpServer.address === 'function' && httpServer.address()))
52
84
  )
85
+ let sails = null
86
+
87
+ if (globalThis.sails && (!requiresHttp || hasHttpServer)) {
88
+ sails = globalThis.sails
89
+ } else {
90
+ const appManager = getDefaultAppManager()
91
+ ensureDefaultAppManagerCleanup()
92
+ sails = requiresHttp ? await appManager.lift() : await appManager.load()
93
+ }
94
+
95
+ const sounding = createRuntime(sails)
96
+
97
+ return {
98
+ sounding,
99
+ teardown: async () => sounding.lower(),
100
+ }
101
+ }
102
+
103
+ /**
104
+ * @param {SoundingRuntime | (() => SoundingRuntime | Promise<SoundingRuntime>) | undefined} runtime
105
+ * @param {SoundingTestOptions} options
106
+ * @returns {Promise<{ sounding: SoundingRuntime, teardown(): Promise<void>, isolated: boolean }>}
107
+ */
108
+ async function resolveTrialRuntime(runtime, options = {}) {
109
+ const requires = {
110
+ requiresHttp: options.transport === 'http',
111
+ browser: Boolean(options.browser),
112
+ socket: Boolean(options.socket),
113
+ }
114
+
115
+ if (typeof runtime === 'function') {
116
+ const sounding = await runtime()
117
+ return {
118
+ sounding,
119
+ teardown: async () => sounding.lower(),
120
+ isolated: Boolean(options.concurrent),
121
+ }
122
+ }
123
+
124
+ if (runtime) {
125
+ if (options.concurrent) {
126
+ throw createSoundingError({
127
+ code: 'E_SOUNDING_CONCURRENT_RUNTIME_SHARED',
128
+ name: 'SoundingConcurrencyError',
129
+ message:
130
+ 'Sounding concurrent trials need isolated runtime state. Pass a runtime factory to createTestApi({ runtime: () => createRuntime(sails) }) or use the default app manager.',
131
+ details: {
132
+ suggestion:
133
+ 'Use `concurrent: true` only when each trial receives its own Sounding runtime.',
134
+ },
135
+ })
136
+ }
53
137
 
54
- if (runtime && (!requiresHttp || hasHttpServer)) {
55
138
  return {
56
139
  sounding: runtime,
57
140
  teardown: async () => runtime.lower(),
141
+ isolated: false,
58
142
  }
59
143
  }
60
144
 
61
- const appManager = getDefaultAppManager()
62
- ensureDefaultAppManagerCleanup()
63
- const sounding = await appManager.runtime({ http: requiresHttp })
145
+ const resolved = options.concurrent
146
+ ? await resolveIsolatedRuntimeFromGlobals(requires)
147
+ : await resolveRuntimeFromGlobals(requires)
148
+
64
149
  return {
150
+ ...resolved,
151
+ isolated: Boolean(options.concurrent),
152
+ }
153
+ }
154
+
155
+ /**
156
+ * @param {Record<string, any>} sails
157
+ * @param {SoundingRuntime} sounding
158
+ * @param {{ isolated?: boolean }} [options]
159
+ * @returns {Record<string, any>}
160
+ */
161
+ function createTrialSails(sails, sounding, options = {}) {
162
+ if (!options.isolated) {
163
+ sails.sounding ||= sounding
164
+ sails.hooks ||= {}
165
+ sails.hooks.sounding ||= sounding
166
+ sails.helpers ||= sounding.helpers
167
+ return sails
168
+ }
169
+
170
+ const hooks = {
171
+ ...(sails.hooks || {}),
65
172
  sounding,
66
- teardown: async () => sounding.lower(),
67
173
  }
174
+
175
+ return new Proxy(sails, {
176
+ get(target, property, receiver) {
177
+ if (property === 'sounding') {
178
+ return sounding
179
+ }
180
+
181
+ if (property === 'hooks') {
182
+ return hooks
183
+ }
184
+
185
+ if (property === 'helpers') {
186
+ return target.helpers || sounding.helpers
187
+ }
188
+
189
+ return Reflect.get(target, property, receiver)
190
+ },
191
+ set(target, property, value, receiver) {
192
+ if (property === 'sounding') {
193
+ return true
194
+ }
195
+
196
+ if (property === 'hooks') {
197
+ Object.assign(hooks, value || {})
198
+ hooks.sounding = sounding
199
+ return true
200
+ }
201
+
202
+ return Reflect.set(target, property, value, receiver)
203
+ },
204
+ })
68
205
  }
69
206
 
207
+ /**
208
+ * @param {import('./types').SoundingRequestClient} request
209
+ * @param {'get' | 'head' | 'post' | 'put' | 'patch' | 'delete'} method
210
+ * @returns {Function | undefined}
211
+ */
70
212
  function bindRequestMethod(request, method) {
71
213
  return typeof request?.[method] === 'function' ? request[method].bind(request) : undefined
72
214
  }
73
215
 
74
- function splitTestOptions(options = {}) {
75
- const {
76
- transport,
77
- browser,
78
- ...nodeOptions
79
- } = options
216
+ /**
217
+ * @param {SoundingTestOptions['world']} worldOption
218
+ * @returns {{ name: string, context: Record<string, any> } | null}
219
+ */
220
+ function normalizeWorldOption(worldOption) {
221
+ if (worldOption === undefined) {
222
+ return null
223
+ }
224
+
225
+ if (typeof worldOption === 'string') {
226
+ return {
227
+ name: worldOption.trim(),
228
+ context: {},
229
+ }
230
+ }
80
231
 
81
232
  return {
82
- nodeOptions: {
83
- concurrency: false,
84
- ...nodeOptions,
85
- },
86
- trialOptions: {
87
- transport,
88
- browser,
89
- },
233
+ name: worldOption.name.trim(),
234
+ context: worldOption.context || {},
90
235
  }
91
236
  }
92
237
 
238
+ /**
239
+ * @param {SoundingTestOptions['browser']} browserOption
240
+ * @param {string | undefined} title
241
+ * @returns {import('./types').SoundingBrowserOpenOptions}
242
+ */
243
+ function normalizeBrowserOpenOptions(browserOption, title) {
244
+ if (browserOption === true) {
245
+ return {
246
+ trialName: title,
247
+ }
248
+ }
249
+
250
+ if (typeof browserOption === 'string') {
251
+ return {
252
+ project: browserOption.trim(),
253
+ trialName: title,
254
+ }
255
+ }
256
+
257
+ return {
258
+ ...(browserOption || {}),
259
+ trialName: title,
260
+ }
261
+ }
262
+
263
+ /**
264
+ * @param {unknown} error
265
+ * @returns {string}
266
+ */
267
+ function formatUnknownError(error) {
268
+ if (error instanceof Error) {
269
+ return error.message
270
+ }
271
+
272
+ return String(error)
273
+ }
274
+
275
+ /**
276
+ * @param {SoundingBrowserArtifacts | null | undefined} artifacts
277
+ * @returns {boolean}
278
+ */
279
+ function hasBrowserArtifacts(artifacts) {
280
+ return Boolean(
281
+ artifacts &&
282
+ (artifacts.currentUrl ||
283
+ artifacts.currentUrlPath ||
284
+ artifacts.screenshot ||
285
+ artifacts.trace ||
286
+ artifacts.video ||
287
+ artifacts.errors?.length)
288
+ )
289
+ }
290
+
291
+ /**
292
+ * @param {SoundingBrowserArtifacts} artifacts
293
+ * @returns {string}
294
+ */
295
+ function formatBrowserArtifacts(artifacts) {
296
+ const lines = ['Sounding browser artifacts:']
297
+
298
+ if (artifacts.currentUrl) {
299
+ lines.push(`- URL: ${artifacts.currentUrl}`)
300
+ }
301
+
302
+ if (artifacts.currentUrlPath) {
303
+ lines.push(`- current URL file: ${artifacts.currentUrlPath}`)
304
+ }
305
+
306
+ if (artifacts.screenshot) {
307
+ lines.push(`- screenshot: ${artifacts.screenshot}`)
308
+ }
309
+
310
+ if (artifacts.trace) {
311
+ lines.push(`- trace: ${artifacts.trace}`)
312
+ }
313
+
314
+ if (artifacts.video) {
315
+ lines.push(`- video: ${artifacts.video}`)
316
+ }
317
+
318
+ for (const captureError of artifacts.errors || []) {
319
+ lines.push(`- ${captureError.artifact} capture failed: ${captureError.message}`)
320
+ }
321
+
322
+ return lines.join('\n')
323
+ }
324
+
325
+ /**
326
+ * @param {SoundingBrowserSession | null} browserSession
327
+ * @returns {Promise<SoundingBrowserArtifacts | null>}
328
+ */
329
+ async function captureBrowserFailureArtifacts(browserSession) {
330
+ if (typeof browserSession?.captureFailureArtifacts !== 'function') {
331
+ return null
332
+ }
333
+
334
+ try {
335
+ return await browserSession.captureFailureArtifacts()
336
+ } catch (captureError) {
337
+ return {
338
+ outputDir: '',
339
+ directory: '',
340
+ project: browserSession.project,
341
+ errors: [
342
+ {
343
+ artifact: 'browser',
344
+ message: formatUnknownError(captureError),
345
+ },
346
+ ],
347
+ }
348
+ }
349
+ }
350
+
351
+ /**
352
+ * @param {unknown} error
353
+ * @param {{ world?: { name: string, context: Record<string, any> }, browserArtifacts?: SoundingBrowserArtifacts | null }} metadata
354
+ * @returns {unknown}
355
+ */
356
+ function decorateTrialError(error, metadata) {
357
+ const browserArtifacts = hasBrowserArtifacts(metadata.browserArtifacts)
358
+ ? metadata.browserArtifacts
359
+ : null
360
+
361
+ if ((!metadata.world && !browserArtifacts) || !error || typeof error !== 'object') {
362
+ return error
363
+ }
364
+
365
+ const target = /** @type {Record<string, any>} */ (error)
366
+ const existingSounding =
367
+ target.sounding && typeof target.sounding === 'object' ? target.sounding : {}
368
+ const existingDetails =
369
+ target.details && typeof target.details === 'object' ? target.details : null
370
+
371
+ target.sounding = {
372
+ ...existingSounding,
373
+ ...(metadata.world ? { world: metadata.world } : {}),
374
+ ...(browserArtifacts ? { browserArtifacts } : {}),
375
+ }
376
+
377
+ if (existingDetails) {
378
+ target.details = {
379
+ ...existingDetails,
380
+ ...(metadata.world
381
+ ? {
382
+ world: metadata.world.name,
383
+ worldContext: metadata.world.context,
384
+ }
385
+ : {}),
386
+ ...(browserArtifacts ? { browserArtifacts } : {}),
387
+ }
388
+ }
389
+
390
+ if (browserArtifacts && typeof target.message === 'string') {
391
+ target.message = `${target.message}\n\n${formatBrowserArtifacts(browserArtifacts)}`
392
+ }
393
+
394
+ return error
395
+ }
396
+
397
+ /**
398
+ * @template T
399
+ * @param {() => Promise<T>} handler
400
+ * @returns {Promise<T>}
401
+ */
93
402
  async function runInTrialQueue(handler) {
94
403
  const previous = trialQueue
95
404
  let release = () => {}
@@ -107,77 +416,148 @@ async function runInTrialQueue(handler) {
107
416
  }
108
417
  }
109
418
 
110
- async function runTrial({ runtime, mode, nodeContext, handler, options = {} }) {
111
- const activeRuntime = typeof runtime === 'function' ? await runtime() : runtime
112
- const resolved = activeRuntime
113
- ? {
114
- sounding: activeRuntime,
115
- teardown: async () => activeRuntime.lower(),
116
- }
117
- : await resolveRuntimeFromGlobals({
118
- http: options.transport === 'http',
119
- browser: Boolean(options.browser),
120
- })
419
+ /**
420
+ * @param {{
421
+ * runtime?: SoundingRuntime | (() => SoundingRuntime | Promise<SoundingRuntime>),
422
+ * mode: string,
423
+ * title?: string,
424
+ * nodeContext: Record<string, any>,
425
+ * handler: SoundingTrialHandler,
426
+ * options?: SoundingTestOptions,
427
+ * }} args
428
+ * @returns {Promise<any>}
429
+ */
430
+ async function runTrial({ runtime, mode, title, nodeContext, handler, options = {} }) {
431
+ const resolved = await resolveTrialRuntime(runtime, options)
121
432
  const sounding = resolved.sounding
122
- const booted = await sounding.boot({ mode })
123
- const sails = booted.sails || {}
124
-
125
- sails.sounding ||= sounding
126
- sails.hooks ||= {}
127
- sails.hooks.sounding ||= sounding
128
- sails.helpers ||= sounding.helpers
129
-
130
- const request = options.transport ? sounding.request.using(options.transport) : sounding.request
131
- const visit = options.transport ? sounding.visit.using(options.transport) : sounding.visit
132
-
133
- let browserSession = null
134
- if (options.browser) {
135
- browserSession = await sounding.browser.open(options.browser === true ? {} : options.browser)
136
- }
137
-
138
- const expect = browserSession?.expect
139
- ? createExpect.withFallback(browserSession.expect)
140
- : createExpect
141
-
142
- const context = {
143
- ...nodeContext,
144
- t: nodeContext,
145
- expect,
146
- sails,
147
- request,
148
- visit,
149
- auth: sounding.auth,
150
- login: sounding.auth?.login,
151
- world: sounding.world,
152
- mailbox: sounding.mailbox,
153
- browser: browserSession?.browser,
154
- browserContext: browserSession?.context,
155
- page: browserSession?.page,
156
- get: bindRequestMethod(request, 'get'),
157
- head: bindRequestMethod(request, 'head'),
158
- post: bindRequestMethod(request, 'post'),
159
- put: bindRequestMethod(request, 'put'),
160
- patch: bindRequestMethod(request, 'patch'),
161
- del: bindRequestMethod(request, 'delete'),
162
- }
163
433
 
164
- try {
165
- return await handler(context)
166
- } finally {
167
- await resolved.teardown()
168
- }
434
+ return runWithTrialContext(
435
+ {
436
+ runtime: sounding,
437
+ mailbox: sounding.mailbox,
438
+ getConfig: () => sounding.config,
439
+ },
440
+ async () => {
441
+ const booted = await sounding.boot({ mode })
442
+ const sails = createTrialSails(booted.sails || {}, sounding, {
443
+ isolated: resolved.isolated,
444
+ })
445
+ const worldOption = normalizeWorldOption(options.world)
446
+ const trialMetadata = {
447
+ ...(worldOption ? { world: worldOption } : {}),
448
+ }
449
+ let browserSession = null
450
+
451
+ try {
452
+ if (worldOption) {
453
+ await sounding.world.use(worldOption.name, worldOption.context)
454
+ }
455
+
456
+ const request = options.transport ? sounding.request.using(options.transport) : sounding.request
457
+ const visit = options.transport ? sounding.visit.using(options.transport) : sounding.visit
458
+ const socketOptions =
459
+ options.socket && typeof options.socket === 'object' ? options.socket : {}
460
+ const sockets =
461
+ options.socket && typeof options.socket === 'object'
462
+ ? {
463
+ connect(connectOptions = {}) {
464
+ return sounding.sockets.connect({
465
+ ...socketOptions,
466
+ ...connectOptions,
467
+ })
468
+ },
469
+ as(actor) {
470
+ return {
471
+ connect(connectOptions = {}) {
472
+ return sounding.sockets.as(actor).connect({
473
+ ...socketOptions,
474
+ ...connectOptions,
475
+ })
476
+ },
477
+ }
478
+ },
479
+ closeAll() {
480
+ return sounding.sockets.closeAll()
481
+ },
482
+ }
483
+ : sounding.sockets
484
+
485
+ if (options.browser) {
486
+ browserSession = await sounding.browser.open(
487
+ normalizeBrowserOpenOptions(options.browser, title)
488
+ )
489
+ }
490
+
491
+ /** @type {SoundingExpect} */
492
+ const expect = browserSession?.expect
493
+ ? createExpect.withFallback(browserSession.expect)
494
+ : /** @type {SoundingExpect} */ (createExpect)
495
+
496
+ /** @type {SoundingTrialContext} */
497
+ const context = {
498
+ ...nodeContext,
499
+ t: nodeContext,
500
+ expect,
501
+ sails,
502
+ request,
503
+ visit,
504
+ sockets,
505
+ auth: sounding.auth,
506
+ login: sounding.auth?.login,
507
+ world: sounding.world,
508
+ mailbox: sounding.mailbox,
509
+ browser: browserSession?.browser,
510
+ browserContext: browserSession?.context,
511
+ page: browserSession?.page,
512
+ get: /** @type {any} */ (bindRequestMethod(request, 'get')),
513
+ head: /** @type {any} */ (bindRequestMethod(request, 'head')),
514
+ post: /** @type {any} */ (bindRequestMethod(request, 'post')),
515
+ put: /** @type {any} */ (bindRequestMethod(request, 'put')),
516
+ patch: /** @type {any} */ (bindRequestMethod(request, 'patch')),
517
+ del: /** @type {any} */ (bindRequestMethod(request, 'delete')),
518
+ }
519
+
520
+ return await handler(context)
521
+ } catch (error) {
522
+ const browserArtifacts = await captureBrowserFailureArtifacts(browserSession)
523
+
524
+ throw decorateTrialError(error, {
525
+ ...trialMetadata,
526
+ browserArtifacts,
527
+ })
528
+ } finally {
529
+ await resolved.teardown()
530
+ }
531
+ }
532
+ )
169
533
  }
170
534
 
171
- function createTrialMethod(baseTest, runtime, mode) {
172
- return function registerTrial(title, optionsOrHandler, maybeHandler) {
173
- const { options, handler } = normalizeTestArgs(title, optionsOrHandler, maybeHandler)
174
- const { nodeOptions, trialOptions } = splitTestOptions(options)
535
+ /**
536
+ * @param {NodeTestLike} baseTest
537
+ * @param {SoundingRuntime | (() => SoundingRuntime | Promise<SoundingRuntime>) | undefined} runtime
538
+ * @param {string} mode
539
+ * @param {boolean} [forceConcurrent]
540
+ * @returns {SoundingTrialRegistrar}
541
+ */
542
+ function createTrialMethod(baseTest, runtime, mode, apiName = 'test', forceConcurrent = false) {
543
+ const registerTrial = function registerTrial(title, optionsOrHandler, maybeHandler) {
544
+ const { options, handler } = normalizeTestArgs(
545
+ title,
546
+ optionsOrHandler,
547
+ maybeHandler,
548
+ apiName
549
+ )
550
+ const nextOptions = forceConcurrent ? { ...options, concurrent: true } : options
551
+ const { nodeOptions, trialOptions } = splitTestOptions(nextOptions, apiName)
175
552
 
176
553
  return baseTest(title, nodeOptions, async (nodeContext) => {
177
- return runInTrialQueue(async () => {
554
+ const run = trialOptions.concurrent ? (action) => action() : runInTrialQueue
555
+
556
+ return run(async () => {
178
557
  return runTrial({
179
558
  runtime,
180
559
  mode,
560
+ title,
181
561
  nodeContext,
182
562
  handler,
183
563
  options: trialOptions,
@@ -185,18 +565,29 @@ function createTrialMethod(baseTest, runtime, mode) {
185
565
  })
186
566
  })
187
567
  }
568
+
569
+ return /** @type {SoundingTrialRegistrar} */ (registerTrial)
188
570
  }
189
571
 
572
+ /**
573
+ * Create Sounding's `test()` API.
574
+ *
575
+ * @param {{ baseTest?: NodeTestLike, runtime?: SoundingRuntime | (() => SoundingRuntime | Promise<SoundingRuntime>) }} [options]
576
+ * @returns {SoundingTest}
577
+ */
190
578
  function createTestApi({ baseTest = nodeTest, runtime } = {}) {
191
579
  function soundingTest(title, optionsOrHandler, maybeHandler) {
192
- const { options, handler } = normalizeTestArgs(title, optionsOrHandler, maybeHandler)
193
- const { nodeOptions, trialOptions } = splitTestOptions(options)
580
+ const { options, handler } = normalizeTestArgs(title, optionsOrHandler, maybeHandler, 'test')
581
+ const { nodeOptions, trialOptions } = splitTestOptions(options, 'test')
194
582
 
195
583
  return baseTest(title, nodeOptions, async (nodeContext) => {
196
- return runInTrialQueue(async () => {
584
+ const run = trialOptions.concurrent ? (action) => action() : runInTrialQueue
585
+
586
+ return run(async () => {
197
587
  return runTrial({
198
588
  runtime,
199
589
  mode: 'trial',
590
+ title,
200
591
  nodeContext,
201
592
  handler,
202
593
  options: trialOptions,
@@ -205,21 +596,19 @@ function createTestApi({ baseTest = nodeTest, runtime } = {}) {
205
596
  })
206
597
  }
207
598
 
208
- soundingTest.skip = (...args) => baseTest.skip(...args)
209
- soundingTest.todo = (...args) => baseTest.todo(...args)
599
+ soundingTest.skip = (...args) => baseTest.skip?.(...args)
600
+ soundingTest.todo = (...args) => baseTest.todo?.(...args)
210
601
  if (typeof baseTest.only === 'function') {
211
- soundingTest.only = (...args) => baseTest.only(...args)
602
+ soundingTest.only = createTrialMethod(baseTest.only.bind(baseTest), runtime, 'trial', 'test.only')
212
603
  }
213
-
214
- // Temporary compatibility aliases while the public docs move fully to `test()`.
215
- soundingTest.helper = createTrialMethod(baseTest, runtime, 'helper')
216
- soundingTest.endpoint = createTrialMethod(baseTest, runtime, 'endpoint')
604
+ soundingTest.concurrent = createTrialMethod(baseTest, runtime, 'trial', 'test.concurrent', true)
217
605
 
218
606
  return soundingTest
219
607
  }
220
608
 
221
609
  module.exports = {
222
610
  createTestApi,
611
+ normalizeBrowserOpenOptions,
223
612
  normalizeTestArgs,
224
613
  resolveRuntimeFromGlobals,
225
614
  runInTrialQueue,