@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/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
+ }
@@ -0,0 +1,2 @@
1
+ // TODO implement TAP reporter
2
+ // to integrate with tap-test-runner OR existing suite runner