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.
@@ -0,0 +1,145 @@
1
+ const path = require('node:path')
2
+
3
+ const { createSoundingError } = require('./create-error')
4
+
5
+ /** @typedef {import('./types').AnyRecord} AnyRecord */
6
+
7
+ /**
8
+ * @param {unknown} error
9
+ * @returns {error is Error & { code: string }}
10
+ */
11
+ function isMissingModuleError(error) {
12
+ return Boolean(
13
+ error &&
14
+ typeof error === 'object' &&
15
+ 'code' in error &&
16
+ error.code === 'MODULE_NOT_FOUND'
17
+ )
18
+ }
19
+
20
+ /**
21
+ * @param {{
22
+ * moduleId: string,
23
+ * dependency?: string,
24
+ * purpose?: string,
25
+ * install?: string,
26
+ * suggestion?: string,
27
+ * appPath?: string,
28
+ * cause?: unknown,
29
+ * }} input
30
+ * @returns {Error}
31
+ */
32
+ function createMissingDependencyError({
33
+ moduleId,
34
+ dependency = moduleId,
35
+ purpose,
36
+ install,
37
+ suggestion,
38
+ appPath,
39
+ cause,
40
+ }) {
41
+ const reason = purpose ? ` Sounding needs it to ${purpose}.` : ''
42
+ const installHint = install ? ` Install it with: \`${install}\`.` : ''
43
+ const suggestionHint = suggestion ? ` ${suggestion}` : ''
44
+
45
+ return createSoundingError({
46
+ code: 'E_SOUNDING_DEPENDENCY_MISSING',
47
+ name: 'SoundingDependencyError',
48
+ message: `Sounding could not find dependency \`${dependency}\`.${reason}${installHint}${suggestionHint}`,
49
+ details: {
50
+ moduleId,
51
+ dependency,
52
+ purpose,
53
+ install,
54
+ suggestion,
55
+ appPath,
56
+ },
57
+ cause,
58
+ })
59
+ }
60
+
61
+ /**
62
+ * @param {{
63
+ * appPath?: string,
64
+ * moduleId: string,
65
+ * dependency?: string,
66
+ * purpose?: string,
67
+ * install?: string,
68
+ * suggestion?: string,
69
+ * optional?: boolean,
70
+ * resolveImplementation?: (moduleId: string, options?: { paths?: string[] }) => string,
71
+ * paths?: string[],
72
+ * }} input
73
+ * @returns {string | null}
74
+ */
75
+ function resolveDependencyFromApp({
76
+ appPath = process.cwd(),
77
+ moduleId,
78
+ dependency = moduleId,
79
+ purpose,
80
+ install,
81
+ suggestion,
82
+ optional = false,
83
+ resolveImplementation = require.resolve,
84
+ paths,
85
+ }) {
86
+ const resolvedAppPath = path.resolve(appPath)
87
+ const searchPaths = paths || [resolvedAppPath, process.cwd(), __dirname]
88
+
89
+ try {
90
+ return resolveImplementation(moduleId, { paths: searchPaths })
91
+ } catch (error) {
92
+ if (optional && isMissingModuleError(error)) {
93
+ return null
94
+ }
95
+
96
+ if (isMissingModuleError(error)) {
97
+ throw createMissingDependencyError({
98
+ moduleId,
99
+ dependency,
100
+ purpose,
101
+ install,
102
+ suggestion,
103
+ appPath: resolvedAppPath,
104
+ cause: error,
105
+ })
106
+ }
107
+
108
+ throw error
109
+ }
110
+ }
111
+
112
+ /**
113
+ * @param {{
114
+ * appPath?: string,
115
+ * moduleId: string,
116
+ * dependency?: string,
117
+ * purpose?: string,
118
+ * install?: string,
119
+ * suggestion?: string,
120
+ * optional?: boolean,
121
+ * resolveImplementation?: (moduleId: string, options?: { paths?: string[] }) => string,
122
+ * requireImplementation?: (resolvedPath: string) => any,
123
+ * paths?: string[],
124
+ * }} input
125
+ * @returns {any}
126
+ */
127
+ function loadDependencyFromApp({
128
+ requireImplementation = require,
129
+ ...options
130
+ }) {
131
+ const resolvedPath = resolveDependencyFromApp(options)
132
+
133
+ if (!resolvedPath) {
134
+ return null
135
+ }
136
+
137
+ return requireImplementation(resolvedPath)
138
+ }
139
+
140
+ module.exports = {
141
+ createMissingDependencyError,
142
+ isMissingModuleError,
143
+ loadDependencyFromApp,
144
+ resolveDependencyFromApp,
145
+ }
@@ -0,0 +1,427 @@
1
+ const fs = require('node:fs')
2
+ const path = require('node:path')
3
+ const { execFileSync, spawn } = require('node:child_process')
4
+
5
+ const DEFAULT_TEST_DIRECTORIES = ['tests', 'test']
6
+ const DEFAULT_JUNIT_DESTINATION = 'reports/sounding-junit.xml'
7
+ const NODE_VALUE_FLAGS = new Set([
8
+ '--test-name-pattern',
9
+ '--test-reporter',
10
+ '--test-reporter-destination',
11
+ '--test-shard',
12
+ '--test-timeout',
13
+ ])
14
+
15
+ /**
16
+ * @typedef {{
17
+ * appPath?: string,
18
+ * argv?: string[],
19
+ * nodeExecutable?: string,
20
+ * }} BuildTestCommandOptions
21
+ */
22
+
23
+ /**
24
+ * @typedef {{
25
+ * command: string,
26
+ * args: string[],
27
+ * cwd: string,
28
+ * files: string[],
29
+ * dryRun: boolean,
30
+ * }} SoundingTestCommand
31
+ */
32
+
33
+ /**
34
+ * @param {string} filePath
35
+ * @returns {boolean}
36
+ */
37
+ function isTestFile(filePath) {
38
+ return /\.test\.(js|cjs|mjs)$/.test(filePath)
39
+ }
40
+
41
+ /**
42
+ * @param {string} directory
43
+ * @returns {string[]}
44
+ */
45
+ function listTestFiles(directory) {
46
+ if (!fs.existsSync(directory)) {
47
+ return []
48
+ }
49
+
50
+ const files = []
51
+
52
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
53
+ const nextPath = path.join(directory, entry.name)
54
+
55
+ if (entry.isDirectory()) {
56
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) {
57
+ continue
58
+ }
59
+
60
+ files.push(...listTestFiles(nextPath))
61
+ continue
62
+ }
63
+
64
+ if (entry.isFile() && isTestFile(nextPath)) {
65
+ files.push(nextPath)
66
+ }
67
+ }
68
+
69
+ return files.sort()
70
+ }
71
+
72
+ /**
73
+ * @param {string} value
74
+ * @returns {boolean}
75
+ */
76
+ function hasGlob(value) {
77
+ return /[*?[\]{}]/.test(value)
78
+ }
79
+
80
+ /**
81
+ * @param {string} value
82
+ * @returns {string}
83
+ */
84
+ function escapeRegExp(value) {
85
+ return value.replace(/[|\\{}()[\]^$+?.]/g, '\\$&')
86
+ }
87
+
88
+ /**
89
+ * @param {string} pattern
90
+ * @returns {RegExp}
91
+ */
92
+ function globToRegExp(pattern) {
93
+ const normalized = pattern.split(path.sep).join('/')
94
+ let output = '^'
95
+
96
+ for (let index = 0; index < normalized.length; index += 1) {
97
+ const char = normalized[index]
98
+ const nextChar = normalized[index + 1]
99
+ const followingChar = normalized[index + 2]
100
+
101
+ if (char === '*' && nextChar === '*') {
102
+ if (followingChar === '/') {
103
+ output += '(?:.*/)?'
104
+ index += 2
105
+ continue
106
+ }
107
+
108
+ output += '.*'
109
+ index += 1
110
+ continue
111
+ }
112
+
113
+ if (char === '*') {
114
+ output += '[^/]*'
115
+ continue
116
+ }
117
+
118
+ if (char === '?') {
119
+ output += '[^/]'
120
+ continue
121
+ }
122
+
123
+ output += escapeRegExp(char)
124
+ }
125
+
126
+ output += '$'
127
+ return new RegExp(output)
128
+ }
129
+
130
+ /**
131
+ * @param {string} pattern
132
+ * @returns {string}
133
+ */
134
+ function resolveGlobBase(pattern) {
135
+ const segments = pattern.split(/[\\/]/)
136
+ const baseSegments = []
137
+
138
+ for (const segment of segments) {
139
+ if (hasGlob(segment)) {
140
+ break
141
+ }
142
+
143
+ baseSegments.push(segment)
144
+ }
145
+
146
+ return baseSegments.length > 0 ? baseSegments.join(path.sep) : '.'
147
+ }
148
+
149
+ /**
150
+ * @param {string} appPath
151
+ * @param {string} target
152
+ * @returns {string[]}
153
+ */
154
+ function resolveTargetFiles(appPath, target) {
155
+ const absoluteTarget = path.resolve(appPath, target)
156
+
157
+ if (hasGlob(target)) {
158
+ const relativeBase = resolveGlobBase(target)
159
+ const absoluteBase = path.resolve(appPath, relativeBase)
160
+ const matcher = globToRegExp(target.split(path.sep).join('/'))
161
+
162
+ return listTestFiles(absoluteBase).filter((filePath) => {
163
+ const relativePath = path.relative(appPath, filePath).split(path.sep).join('/')
164
+ return matcher.test(relativePath)
165
+ })
166
+ }
167
+
168
+ if (!fs.existsSync(absoluteTarget)) {
169
+ return []
170
+ }
171
+
172
+ const stat = fs.statSync(absoluteTarget)
173
+
174
+ if (stat.isDirectory()) {
175
+ return listTestFiles(absoluteTarget)
176
+ }
177
+
178
+ return isTestFile(absoluteTarget) ? [absoluteTarget] : []
179
+ }
180
+
181
+ /**
182
+ * @param {string} appPath
183
+ * @returns {string[]}
184
+ */
185
+ function getChangedTestFiles(appPath) {
186
+ try {
187
+ const output = execFileSync('git', ['-C', appPath, 'diff', '--name-only', 'HEAD'], {
188
+ encoding: 'utf8',
189
+ stdio: ['ignore', 'pipe', 'ignore'],
190
+ })
191
+
192
+ return output
193
+ .split(/\r?\n/)
194
+ .filter(Boolean)
195
+ .filter(isTestFile)
196
+ } catch (_error) {
197
+ return []
198
+ }
199
+ }
200
+
201
+ /**
202
+ * @param {string} appPath
203
+ * @param {string[]} targets
204
+ * @returns {string[]}
205
+ */
206
+ function resolveTestFiles(appPath, targets) {
207
+ const nextTargets =
208
+ targets.length > 0
209
+ ? targets
210
+ : DEFAULT_TEST_DIRECTORIES.filter((directory) => fs.existsSync(path.join(appPath, directory)))
211
+
212
+ const files = new Set()
213
+
214
+ for (const target of nextTargets) {
215
+ for (const filePath of resolveTargetFiles(appPath, target)) {
216
+ files.add(path.relative(appPath, filePath))
217
+ }
218
+ }
219
+
220
+ return Array.from(files).sort()
221
+ }
222
+
223
+ /**
224
+ * @param {string[]} argv
225
+ * @param {string} appPath
226
+ * @returns {{
227
+ * appPath: string,
228
+ * dryRun: boolean,
229
+ * targets: string[],
230
+ * nodeArgs: string[],
231
+ * }}
232
+ */
233
+ function parseTestArgs(argv = [], appPath = process.cwd()) {
234
+ const args = [...argv]
235
+ const targets = []
236
+ const nodeArgs = []
237
+ let dryRun = false
238
+ let useChanged = false
239
+ let resolvedAppPath = path.resolve(appPath)
240
+
241
+ function readValue(flag) {
242
+ const value = args.shift()
243
+
244
+ if (!value || value.startsWith('--')) {
245
+ throw new Error(`Sounding test option \`${flag}\` requires a value.`)
246
+ }
247
+
248
+ return value
249
+ }
250
+
251
+ while (args.length > 0) {
252
+ const arg = args.shift()
253
+
254
+ if (arg === '--dry-run') {
255
+ dryRun = true
256
+ continue
257
+ }
258
+
259
+ if (arg === '--app') {
260
+ resolvedAppPath = path.resolve(readValue(arg))
261
+ continue
262
+ }
263
+
264
+ if (arg === '--grep') {
265
+ nodeArgs.push('--test-name-pattern', readValue(arg))
266
+ continue
267
+ }
268
+
269
+ if (arg === '--file') {
270
+ targets.push(readValue(arg))
271
+ continue
272
+ }
273
+
274
+ if (arg === '--lane') {
275
+ const lane = readValue(arg)
276
+ targets.push(`tests/${lane}`)
277
+ targets.push(`test/${lane}`)
278
+ continue
279
+ }
280
+
281
+ if (arg === '--changed') {
282
+ useChanged = true
283
+ continue
284
+ }
285
+
286
+ if (arg === '--reporter') {
287
+ nodeArgs.push('--test-reporter', readValue(arg))
288
+ continue
289
+ }
290
+
291
+ if (arg === '--reporter-destination') {
292
+ nodeArgs.push('--test-reporter-destination', readValue(arg))
293
+ continue
294
+ }
295
+
296
+ if (arg === '--junit') {
297
+ nodeArgs.push('--test-reporter', 'junit')
298
+
299
+ if (args[0] && !args[0].startsWith('-')) {
300
+ nodeArgs.push('--test-reporter-destination', args.shift())
301
+ } else {
302
+ nodeArgs.push('--test-reporter-destination', DEFAULT_JUNIT_DESTINATION)
303
+ }
304
+
305
+ continue
306
+ }
307
+
308
+ if (arg === '--json') {
309
+ nodeArgs.push('--test-reporter', 'json')
310
+ continue
311
+ }
312
+
313
+ if (arg === '--coverage') {
314
+ nodeArgs.push('--experimental-test-coverage')
315
+ continue
316
+ }
317
+
318
+ if (arg === '--watch') {
319
+ nodeArgs.push('--watch')
320
+ continue
321
+ }
322
+
323
+ if (arg === '--') {
324
+ nodeArgs.push(...args)
325
+ break
326
+ }
327
+
328
+ if (NODE_VALUE_FLAGS.has(arg)) {
329
+ nodeArgs.push(arg, readValue(arg))
330
+ continue
331
+ }
332
+
333
+ if (arg.startsWith('--')) {
334
+ nodeArgs.push(arg)
335
+ continue
336
+ }
337
+
338
+ targets.push(arg)
339
+ }
340
+
341
+ if (useChanged) {
342
+ targets.push(...getChangedTestFiles(resolvedAppPath))
343
+ }
344
+
345
+ return {
346
+ appPath: resolvedAppPath,
347
+ dryRun,
348
+ targets,
349
+ nodeArgs,
350
+ }
351
+ }
352
+
353
+ /**
354
+ * @param {BuildTestCommandOptions} [options]
355
+ * @returns {SoundingTestCommand}
356
+ */
357
+ function buildTestCommand(options = {}) {
358
+ const parsed = parseTestArgs(options.argv || [], options.appPath)
359
+ const files = resolveTestFiles(parsed.appPath, parsed.targets)
360
+
361
+ return {
362
+ command: options.nodeExecutable || process.execPath,
363
+ args: ['--test', ...parsed.nodeArgs, ...files],
364
+ cwd: parsed.appPath,
365
+ files,
366
+ dryRun: parsed.dryRun,
367
+ }
368
+ }
369
+
370
+ /**
371
+ * @param {string} value
372
+ * @returns {string}
373
+ */
374
+ function quoteShell(value) {
375
+ if (/^[A-Za-z0-9_./:=@-]+$/.test(value)) {
376
+ return value
377
+ }
378
+
379
+ return JSON.stringify(value)
380
+ }
381
+
382
+ /**
383
+ * @param {SoundingTestCommand} command
384
+ * @returns {string}
385
+ */
386
+ function formatTestCommand(command) {
387
+ return [command.command, ...command.args].map(quoteShell).join(' ')
388
+ }
389
+
390
+ /**
391
+ * @param {BuildTestCommandOptions & { stdio?: 'inherit' | 'pipe' }} [options]
392
+ * @returns {Promise<{ status: number, command: SoundingTestCommand }>}
393
+ */
394
+ function runTests(options = {}) {
395
+ const command = buildTestCommand(options)
396
+
397
+ if (command.dryRun) {
398
+ return Promise.resolve({
399
+ status: 0,
400
+ command,
401
+ })
402
+ }
403
+
404
+ return new Promise((resolve) => {
405
+ const child = spawn(command.command, command.args, {
406
+ cwd: command.cwd,
407
+ stdio: options.stdio || 'inherit',
408
+ })
409
+
410
+ child.on('exit', (code, signal) => {
411
+ resolve({
412
+ status: code ?? (signal ? 1 : 0),
413
+ command,
414
+ })
415
+ })
416
+ })
417
+ }
418
+
419
+ module.exports = {
420
+ DEFAULT_JUNIT_DESTINATION,
421
+ DEFAULT_TEST_DIRECTORIES,
422
+ buildTestCommand,
423
+ formatTestCommand,
424
+ parseTestArgs,
425
+ resolveTestFiles,
426
+ runTests,
427
+ }
@@ -0,0 +1,29 @@
1
+ const { AsyncLocalStorage } = require('node:async_hooks')
2
+
3
+ /** @typedef {import('./types').SoundingMailbox} SoundingMailbox */
4
+ /** @typedef {import('./types').SoundingRuntime} SoundingRuntime */
5
+
6
+ /** @type {AsyncLocalStorage<{ runtime?: SoundingRuntime, mailbox?: SoundingMailbox, getConfig?: () => any }>} */
7
+ const trialContextStorage = new AsyncLocalStorage()
8
+
9
+ /**
10
+ * @template T
11
+ * @param {{ runtime?: SoundingRuntime, mailbox?: SoundingMailbox, getConfig?: () => any }} context
12
+ * @param {() => T | Promise<T>} handler
13
+ * @returns {T | Promise<T>}
14
+ */
15
+ function runWithTrialContext(context, handler) {
16
+ return trialContextStorage.run(context, handler)
17
+ }
18
+
19
+ /**
20
+ * @returns {{ runtime?: SoundingRuntime, mailbox?: SoundingMailbox, getConfig?: () => any } | null}
21
+ */
22
+ function getTrialContext() {
23
+ return trialContextStorage.getStore() || null
24
+ }
25
+
26
+ module.exports = {
27
+ getTrialContext,
28
+ runWithTrialContext,
29
+ }