@yamf/test 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 +428 -0
- package/package.json +39 -0
- package/src/assert.js +504 -0
- package/src/assertion-errors.js +114 -0
- package/src/cli.js +49 -0
- package/src/helpers.js +69 -0
- package/src/index.js +49 -0
- package/src/runner.js +329 -0
- package/src/tap-reporter.js +2 -0
package/src/helpers.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// TODO @yamf/core
|
|
2
|
+
import { Logger, envConfig } from '../../core/src/index.js'
|
|
3
|
+
|
|
4
|
+
const logger = new Logger()
|
|
5
|
+
|
|
6
|
+
export const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
|
|
7
|
+
|
|
8
|
+
export async function terminateAfter(...args /* ...serverFns, testFn */) {
|
|
9
|
+
args.unshift(args.pop()) // rearrange for spread
|
|
10
|
+
let [testFn, ...serverFns] = args
|
|
11
|
+
if (typeof testFn !== 'function') throw new Error('terminateAfter last argument must be a function')
|
|
12
|
+
|
|
13
|
+
let servers = []
|
|
14
|
+
try {
|
|
15
|
+
servers = await Promise.all(serverFns)
|
|
16
|
+
for (let server of servers) {
|
|
17
|
+
if (server && server.length > 0) {
|
|
18
|
+
let index = servers.indexOf(server)
|
|
19
|
+
servers.splice(index, 1)
|
|
20
|
+
servers.push(...server)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let result = await testFn(...servers)
|
|
25
|
+
return result
|
|
26
|
+
} finally {
|
|
27
|
+
let registryIndex = servers.findIndex(s => s.isRegistry)
|
|
28
|
+
if (registryIndex > -1) {
|
|
29
|
+
let registryServer = servers[registryIndex]
|
|
30
|
+
servers = servers.slice(0, registryIndex).concat(servers.slice(registryIndex + 1))
|
|
31
|
+
for (let server of servers) {
|
|
32
|
+
await server?.terminate()
|
|
33
|
+
logger.info(`terminated server ${server?.name} at port ${server?.port}`)
|
|
34
|
+
}
|
|
35
|
+
await registryServer?.terminate()
|
|
36
|
+
logger.info(`terminated registry server at port ${registryServer?.port}`)
|
|
37
|
+
} else for (let server of servers) await server?.terminate()
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function setEnv(key, value) {
|
|
42
|
+
if (value === undefined) {
|
|
43
|
+
delete process.env[key]
|
|
44
|
+
envConfig.config.delete(key)
|
|
45
|
+
} else {
|
|
46
|
+
process.env[key] = value
|
|
47
|
+
envConfig.set(key, value)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function withEnv(envVars, fn) {
|
|
52
|
+
const saved = {}
|
|
53
|
+
for (const key in envVars) {
|
|
54
|
+
saved[key] = process.env[key]
|
|
55
|
+
setEnv(key, envVars[key])
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
return await fn()
|
|
60
|
+
} finally {
|
|
61
|
+
for (const key in saved) {
|
|
62
|
+
if (saved[key] === undefined) {
|
|
63
|
+
setEnv(key, undefined)
|
|
64
|
+
} else {
|
|
65
|
+
setEnv(key, saved[key])
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assert,
|
|
3
|
+
assertErr,
|
|
4
|
+
assertEach,
|
|
5
|
+
assertSequence,
|
|
6
|
+
assertErrEach,
|
|
7
|
+
assertErrSequence,
|
|
8
|
+
// MultiAssertError,
|
|
9
|
+
// AssertError
|
|
10
|
+
} from './assert.js'
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
AssertionFailure,
|
|
14
|
+
AssertionFailureDetail,
|
|
15
|
+
MultiAssertionFailure
|
|
16
|
+
} from './assertion-errors.js'
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
sleep,
|
|
20
|
+
terminateAfter,
|
|
21
|
+
withEnv
|
|
22
|
+
} from './helpers.js'
|
|
23
|
+
|
|
24
|
+
import runTests, {
|
|
25
|
+
mergeAllTestsSafely,
|
|
26
|
+
TestRunner,
|
|
27
|
+
runTestFnsSequentially
|
|
28
|
+
} from './runner.js'
|
|
29
|
+
|
|
30
|
+
export {
|
|
31
|
+
assert,
|
|
32
|
+
assertErr,
|
|
33
|
+
assertEach,
|
|
34
|
+
assertSequence,
|
|
35
|
+
assertErrEach,
|
|
36
|
+
assertErrSequence,
|
|
37
|
+
|
|
38
|
+
AssertionFailure,
|
|
39
|
+
AssertionFailureDetail,
|
|
40
|
+
MultiAssertionFailure,
|
|
41
|
+
|
|
42
|
+
sleep,
|
|
43
|
+
terminateAfter,
|
|
44
|
+
mergeAllTestsSafely,
|
|
45
|
+
runTests,
|
|
46
|
+
runTestFnsSequentially,
|
|
47
|
+
TestRunner,
|
|
48
|
+
withEnv
|
|
49
|
+
}
|
package/src/runner.js
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test Runner with Suite Support
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Run tests individually or organized in suites
|
|
6
|
+
* - Solo/mute flags for focused testing
|
|
7
|
+
* - Automatic async detection
|
|
8
|
+
* - Comprehensive error reporting
|
|
9
|
+
*
|
|
10
|
+
* Basic Usage:
|
|
11
|
+
* import { runTests } from './core/runner.js'
|
|
12
|
+
* runTests({ test1, test2, test3 })
|
|
13
|
+
*
|
|
14
|
+
* Suite Usage:
|
|
15
|
+
* import { TestRunner } from './core/runner.js'
|
|
16
|
+
* const runner = new TestRunner()
|
|
17
|
+
* runner.addSuite('my-suite', { test1, test2 })
|
|
18
|
+
*
|
|
19
|
+
* import { suite1, suite2 } from './my-suites.js'
|
|
20
|
+
* runner.addSuites({ suite1, suite2 })
|
|
21
|
+
* runner.run()
|
|
22
|
+
*
|
|
23
|
+
*
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import crypto from 'crypto'
|
|
27
|
+
// TODO @yamf/core
|
|
28
|
+
import { Logger } from '../../core/src/index.js'
|
|
29
|
+
|
|
30
|
+
const logger = new Logger({ includeLogLineNumbers: false })
|
|
31
|
+
|
|
32
|
+
// ============================================================================
|
|
33
|
+
// Helper Functions
|
|
34
|
+
// ============================================================================
|
|
35
|
+
|
|
36
|
+
function formatErrorDetails(failedCases) {
|
|
37
|
+
return failedCases.map(({name, err}) => {
|
|
38
|
+
return `\n${name} failed with error: ${err.assertMessage || err.stack}`
|
|
39
|
+
}).join('\n')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function orchestrateForSoloOrMutedTests(testFns) {
|
|
43
|
+
let isSoloRun = false
|
|
44
|
+
let isMuteRun = false
|
|
45
|
+
let skippedCases = []
|
|
46
|
+
let runCases = []
|
|
47
|
+
|
|
48
|
+
if (testFns.some(fn => fn.solo)) {
|
|
49
|
+
logger.warn(logger.writeColor('magenta', `Solo tests: ${testFns.filter(fn => fn.solo).map(fn => fn.name).join(', ')}`))
|
|
50
|
+
isSoloRun = true
|
|
51
|
+
skippedCases = testFns.filter(fn => !fn.solo || fn.mute)
|
|
52
|
+
runCases = testFns.filter(fn => fn.solo)
|
|
53
|
+
} else if (testFns.some(fn => fn.mute)) {
|
|
54
|
+
logger.warn(logger.writeColor('magenta', `Muted tests: ${testFns.filter(fn => fn.mute).map(fn => fn.name).join(', ')}`))
|
|
55
|
+
isMuteRun = true
|
|
56
|
+
skippedCases = testFns.filter(fn => fn.mute)
|
|
57
|
+
runCases = testFns.filter(fn => !fn.mute)
|
|
58
|
+
} else {
|
|
59
|
+
// No solo or mute flags - run all tests
|
|
60
|
+
runCases = testFns
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { isSoloRun, isMuteRun, skippedCases, runCases }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function appendMetadataToTestSuiteFn(suiteName, testFn) {
|
|
67
|
+
Object.defineProperty(testFn, 'name', { value: `${suiteName}.${testFn.name}` })
|
|
68
|
+
Object.defineProperty(testFn, 'suite', { value: suiteName })
|
|
69
|
+
return testFn
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ============================================================================
|
|
73
|
+
// Test Execution
|
|
74
|
+
// ============================================================================
|
|
75
|
+
|
|
76
|
+
export async function runTestFnsSequentially(testFns) {
|
|
77
|
+
|
|
78
|
+
process.on('unhandledRejection', async (reason, promise) => {
|
|
79
|
+
// TODO do something with the rejected promise?
|
|
80
|
+
logger.error(logger.writeColor('magenta', 'Exiting early due to Unhandled Promise Rejection'))
|
|
81
|
+
logger.error(reason.stack)
|
|
82
|
+
if (reason.stack.includes('AssertError: Assert Error')) logger.warn(logger.removeExtraWhitespace(
|
|
83
|
+
`This likely means your assert function is being called without await.
|
|
84
|
+
Either add await before every assert/assertErr, or make sure its promise is returned by the test function.`
|
|
85
|
+
))
|
|
86
|
+
process.exit(1)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
let testSuccess = 0
|
|
90
|
+
let successCases = []
|
|
91
|
+
let testFail = 0
|
|
92
|
+
let failedCases = []
|
|
93
|
+
let todoCases = []
|
|
94
|
+
let testSuites = {}
|
|
95
|
+
|
|
96
|
+
// Support arrays or objects
|
|
97
|
+
testFns = Array.isArray(testFns) ? testFns : Object.values(testFns)
|
|
98
|
+
|
|
99
|
+
let { isSoloRun, isMuteRun, skippedCases, runCases } = orchestrateForSoloOrMutedTests(testFns)
|
|
100
|
+
|
|
101
|
+
runCases = runCases.map(fn => {
|
|
102
|
+
// Wrap non-async functions
|
|
103
|
+
if (fn.constructor.name !== 'AsyncFunction') {
|
|
104
|
+
let originalFn = fn
|
|
105
|
+
fn = async () => originalFn()
|
|
106
|
+
|
|
107
|
+
// Preserve the name and flags of the original function
|
|
108
|
+
Object.defineProperty(fn, 'name', { value: originalFn.name })
|
|
109
|
+
Object.defineProperty(fn, 'mute', { value: originalFn.mute })
|
|
110
|
+
Object.defineProperty(fn, 'solo', { value: originalFn.solo })
|
|
111
|
+
Object.defineProperty(fn, 'suite', { value: originalFn.suite })
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return async () => {
|
|
115
|
+
if (fn.name.includes('TODO')) {
|
|
116
|
+
todoCases.push(fn.name)
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
if (fn.mute) {
|
|
120
|
+
skippedCases.push(fn.name)
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
if (fn.suite && !testSuites[fn.suite]) {
|
|
124
|
+
testSuites[fn.suite] = true
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
let startTime = Date.now()
|
|
128
|
+
await fn()
|
|
129
|
+
let durationMs = Date.now() - startTime
|
|
130
|
+
logger.info(logger.writeColor('green', `✔ ${fn.name}`) + logger.writeColor('gray', ` (${durationMs}ms)`))
|
|
131
|
+
testSuccess++
|
|
132
|
+
successCases.push(fn.name)
|
|
133
|
+
} catch (err) {
|
|
134
|
+
if (err.message.includes('TODO')) {
|
|
135
|
+
todoCases.push(fn.name)
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
logger.error(logger.writeColor('red', `✘ ${fn.name}`))
|
|
139
|
+
if (err.message.includes('terminateAfter')) {
|
|
140
|
+
logger.error(logger.writeColor('magenta', 'Exiting early due to failure in terminateAfter: ', err.stack))
|
|
141
|
+
process.exit(1)
|
|
142
|
+
}
|
|
143
|
+
testFail++
|
|
144
|
+
failedCases.push({name: fn.name, err})
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
let startTime = Date.now()
|
|
150
|
+
for (let testFn of runCases) {
|
|
151
|
+
if (typeof testFn !== 'function') {
|
|
152
|
+
logger.error(logger.writeColor('red', `✘ not a function? ${testFn.toString().slice(0, 100)}...`))
|
|
153
|
+
} else {
|
|
154
|
+
await testFn()
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
let durationMs = Date.now() - startTime
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
testSuccess,
|
|
161
|
+
successCases,
|
|
162
|
+
testFail,
|
|
163
|
+
failedCases,
|
|
164
|
+
skippedCases,
|
|
165
|
+
todoCases,
|
|
166
|
+
durationMs,
|
|
167
|
+
isSoloRun,
|
|
168
|
+
isMuteRun,
|
|
169
|
+
testSuitesCount: Object.keys(testSuites).length
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function reportTestResults({
|
|
174
|
+
testSuccess, successCases,
|
|
175
|
+
testFail, failedCases,
|
|
176
|
+
testSuitesCount = 0,
|
|
177
|
+
skippedCases = [],
|
|
178
|
+
todoCases = [],
|
|
179
|
+
isSoloRun = false,
|
|
180
|
+
isMuteRun = false,
|
|
181
|
+
durationMs
|
|
182
|
+
}) {
|
|
183
|
+
|
|
184
|
+
logger.info('\n')
|
|
185
|
+
logger.info(`----- Testing Complete -----`)
|
|
186
|
+
|
|
187
|
+
if (testSuccess > 0 && process.env.MUTE_SUCCESS_CASES !== 'true') {
|
|
188
|
+
logger.info(logger.writeColor('green', '✔ ✔ ✔ Success Report ✔ ✔ ✔'))
|
|
189
|
+
logger.info(logger.writeColor('green', '\n ' + successCases.join('\n ')))
|
|
190
|
+
logger.info('')
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (testFail) {
|
|
194
|
+
logger.info(logger.writeColor('red', '✘ ✘ ✘ Failure Report ✘ ✘ ✘'))
|
|
195
|
+
logger.info(logger.writeColor('red', '\n ' + failedCases.map(f => f.name).join('\n ')))
|
|
196
|
+
logger.info(logger.writeColor('red', '\n' + formatErrorDetails(failedCases)))
|
|
197
|
+
} else logger.info(logger.writeColor('green', '✔ ✔ ✔ All Tests Passed! ✔ ✔ ✔'))
|
|
198
|
+
|
|
199
|
+
logger.info('\n----- Test Overview -----')
|
|
200
|
+
logger.info(`ℹ tests ${testSuccess + testFail}`)
|
|
201
|
+
if (testSuitesCount > 0) logger.info(`ℹ suites ${testSuitesCount}`)
|
|
202
|
+
logger.info(`ℹ pass ${testSuccess}`)
|
|
203
|
+
logger.info(`ℹ fail ${testFail}`)
|
|
204
|
+
logger.info(`ℹ skipped ${skippedCases.length}`)
|
|
205
|
+
logger.info(`ℹ todo ${todoCases.length}`)
|
|
206
|
+
logger.info(`ℹ duration_ms ${durationMs}`)
|
|
207
|
+
logger.info('')
|
|
208
|
+
|
|
209
|
+
if (isSoloRun) logger.warn(logger.writeColor('magenta', 'This was a solo test run, remove "solo" flags for a full test run'))
|
|
210
|
+
if (isMuteRun) logger.warn(logger.writeColor('magenta', 'This was a partially muted test run, remove "mute" flags for a full test run'))
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ============================================================================
|
|
214
|
+
// Public API
|
|
215
|
+
// ============================================================================
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Merge test functions/objects into a single flat object
|
|
219
|
+
* Detects duplicates and throws an error
|
|
220
|
+
*/
|
|
221
|
+
export function mergeAllTestsSafely(...testFnObjects) {
|
|
222
|
+
let finalTestFns = {}
|
|
223
|
+
let duplicateNames = []
|
|
224
|
+
|
|
225
|
+
for (let testFns of testFnObjects) {
|
|
226
|
+
if (typeof testFns === 'function') {
|
|
227
|
+
if (finalTestFns[testFns.name]) duplicateNames.push(testFns.name)
|
|
228
|
+
finalTestFns[testFns.name] = testFns
|
|
229
|
+
} else if (Array.isArray(testFns)) {
|
|
230
|
+
for (let fn of testFns) {
|
|
231
|
+
if (finalTestFns[fn.name]) duplicateNames.push(fn.name)
|
|
232
|
+
finalTestFns[fn.name] = fn
|
|
233
|
+
}
|
|
234
|
+
} else {
|
|
235
|
+
let testNames = Object.keys(testFns)
|
|
236
|
+
for (let name of testNames) {
|
|
237
|
+
if (finalTestFns[name]) duplicateNames.push(name)
|
|
238
|
+
finalTestFns[name] = testFns[name]
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (duplicateNames.length > 0) throw new Error(`Duplicate test names: [${duplicateNames.join(', ')}]`)
|
|
244
|
+
return finalTestFns
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Run tests directly (without test suites)
|
|
249
|
+
* @param {Object|Array|Function} testSuitesOrFns - Tests to run
|
|
250
|
+
* @returns {Promise<void>}
|
|
251
|
+
*/
|
|
252
|
+
export default async function runTests(testSuitesOrFns) {
|
|
253
|
+
let testFns = mergeAllTestsSafely(testSuitesOrFns)
|
|
254
|
+
|
|
255
|
+
let testResults = await runTestFnsSequentially(testFns)
|
|
256
|
+
reportTestResults(testResults)
|
|
257
|
+
|
|
258
|
+
if (testResults.testFail > 0) {
|
|
259
|
+
let err = new Error(`${testResults.testFail} test(s) failed`)
|
|
260
|
+
err.code = testResults.testFail
|
|
261
|
+
throw err
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Export for backwards compatibility
|
|
266
|
+
export { runTests }
|
|
267
|
+
|
|
268
|
+
// ============================================================================
|
|
269
|
+
// TestRunner Class (for organized test suites)
|
|
270
|
+
// ============================================================================
|
|
271
|
+
|
|
272
|
+
export class TestRunner {
|
|
273
|
+
constructor(testFns) {
|
|
274
|
+
this.testFns = testFns
|
|
275
|
+
this.suites = {}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
addSuite(suiteName, testFns) {
|
|
279
|
+
if (!testFns) {
|
|
280
|
+
testFns = suiteName
|
|
281
|
+
suiteName = suiteName.name
|
|
282
|
+
|| suiteName.constructor?.name
|
|
283
|
+
|| suiteName[0]?.name
|
|
284
|
+
|| suiteName[Object.keys(suiteName)[0]]
|
|
285
|
+
|| `Suite ${crypto.randomBytes(4).toString('hex')}`
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (Array.isArray(testFns)) {
|
|
289
|
+
testFns = testFns.map(fn => appendMetadataToTestSuiteFn(suiteName, fn))
|
|
290
|
+
} else if (!testFns.length && typeof testFns !== 'function' && typeof testFns === 'object') {
|
|
291
|
+
let testFnArray = []
|
|
292
|
+
// Don't modify the original object (it may be read-only Module namespace)
|
|
293
|
+
for (let fnName in testFns) {
|
|
294
|
+
if (fnName === 'default') continue // Skip default export
|
|
295
|
+
if (typeof testFns[fnName] === 'function') {
|
|
296
|
+
const wrappedFn = appendMetadataToTestSuiteFn(suiteName, testFns[fnName])
|
|
297
|
+
testFnArray.push(wrappedFn)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
testFns = testFnArray
|
|
301
|
+
} else throw new Error(`Invalid test suite: "${testFns}"; should be an array or object`)
|
|
302
|
+
|
|
303
|
+
if (this.suites[suiteName]) throw new Error(`Test suite "${suiteName}" already exists`)
|
|
304
|
+
this.suites[suiteName] = testFns
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
addSuites(suites) {
|
|
308
|
+
for (let suite in suites) {
|
|
309
|
+
this.addSuite(suite, suites[suite])
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async run(additionalTestFns) {
|
|
314
|
+
if (additionalTestFns) {
|
|
315
|
+
// additionalTestFns is an array or object of standalone test functions
|
|
316
|
+
this.addSuite('standalone', additionalTestFns)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Merge all suites into a flat structure
|
|
320
|
+
let allTests = []
|
|
321
|
+
for (let suiteName in this.suites) {
|
|
322
|
+
if (this.suites[suiteName] && Array.isArray(this.suites[suiteName])) {
|
|
323
|
+
allTests.push(...this.suites[suiteName])
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
await runTests(allTests)
|
|
328
|
+
}
|
|
329
|
+
}
|