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 +336 -1
- package/bin/sounding.js +156 -0
- package/index.js +23 -0
- package/lib/create-app-manager.js +380 -26
- package/lib/create-auth-helpers.js +168 -21
- package/lib/create-browser-manager.js +578 -31
- package/lib/create-error.js +35 -0
- package/lib/create-expect.js +1070 -27
- package/lib/create-helper-runner.js +38 -2
- package/lib/create-mail-capture.js +174 -25
- package/lib/create-mailbox.js +20 -0
- package/lib/create-request-client.js +635 -57
- package/lib/create-runtime.js +222 -21
- package/lib/create-socket-manager.js +706 -0
- package/lib/create-test-api.js +491 -102
- package/lib/create-visit-client.js +40 -2
- package/lib/create-world-engine.js +106 -7
- package/lib/create-world-loader.js +150 -8
- package/lib/default-config.js +26 -0
- package/lib/define-world.js +27 -2
- package/lib/init-project.js +403 -0
- package/lib/merge-config.js +11 -0
- package/lib/normalize-config.js +16 -19
- package/lib/resolve-auth-config.js +36 -0
- package/lib/resolve-datastore.js +50 -7
- package/lib/resolve-dependency.js +145 -0
- package/lib/test-runner.js +427 -0
- package/lib/trial-context.js +29 -0
- package/lib/types.js +675 -0
- package/lib/validate-config.js +633 -0
- package/lib/validate-test-args.js +480 -0
- package/package.json +16 -2
|
@@ -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
|
-
|
|
4
|
-
|
|
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
|
-
|
|
8
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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
|
|
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(() =>
|
|
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
|
|
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
|
|
63
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
...
|
|
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:
|
|
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.
|
|
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
|
+
}
|