@yamf/test 0.1.4 → 0.9.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2017 mcbrumagin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  A minimal, zero-dependency testing library with polymorphic async/await and simultaneous assertion reports.
4
4
 
5
- [![Node](https://img.shields.io/badge/node-%3E%3D20.0.0-brightgreen)]()
5
+ [![Node](https://img.shields.io/badge/node-%3E%3D22.0.0-brightgreen)]()
6
6
  [![License](https://img.shields.io/badge/license-MIT-blue)]()
7
7
 
8
8
  ## Features
@@ -46,6 +46,10 @@ runTests({ testAddition, testAsyncFetch })
46
46
 
47
47
  Assert that a value or function result passes all assertion functions.
48
48
 
49
+ **Failure output:** the runner prints the distilled **first argument** (`for target … value = …`) and each failing predicate (`failed -> …`). Put the value under test first and express expectations in the callbacks — avoid `assert(expr, x => x === true)` where `expr` is already a boolean, or failures only show `true`/`false` with no useful predicate text.
50
+
51
+ **Errors:** for `Error` values (e.g. a caught `execSync` failure), use **`assertErr(error, …)`** instead of `assert` — see below.
52
+
49
53
  ```javascript
50
54
  // Direct value
51
55
  assert(5, n => n > 0, n => n < 10)
@@ -248,16 +252,52 @@ import { registryServer, createService } from '@yamf/core'
248
252
 
249
253
  async function testServices() {
250
254
  await terminateAfter(
251
- registryServer(),
252
- createService(function myService(p) { return { ok: true } }),
255
+ () => registryServer(),
256
+ () => createService('my-service', (p) => ({ ok: true })),
253
257
  async (registry, service) => {
254
- const result = await callService('myService', {})
258
+ const result = await callService('my-service', {})
255
259
  assert(result.ok, v => v === true)
256
260
  }
257
261
  )
258
262
  }
259
263
  ```
260
264
 
265
+ **Single-callback form** — when the test only needs a registry (and services register against it), you can pass one async function; teardown uses `terminateActiveRegistryServers()` from `@yamf/core` (no need to return the registry instance):
266
+
267
+ ```javascript
268
+ export async function myTestCaseFunction() {
269
+ await terminateAfter(async () => {
270
+ await registryServer()
271
+ await createService('my-service', (p) => ({ ok: true }))
272
+ const result = await callService('my-service', {})
273
+ assert(result.ok, (v) => v === true)
274
+ })
275
+ }
276
+ ```
277
+
278
+ Prefer **named `async function …`** for that body (and for multi-arg factory thunks) so stack traces read clearly; avoid `() =>` wrappers for routine registry/service setup.
279
+
280
+ ### `withInlineRegistry(...factories, testFn)`
281
+
282
+ Shorthand for `terminateAfter(() => registryServer(), ...)`.
283
+
284
+ ### `pickListenPort()`
285
+
286
+ Returns a free TCP port on `127.0.0.1`. Use with `withEnv({ YAMF_REGISTRY_URL: \`http://127.0.0.1:${port}\` }, …)` when fixed ports from `.env.test` conflict (e.g. parallel runs or drain races).
287
+
288
+ ```javascript
289
+ import { pickListenPort, withEnv, terminateAfter } from '@yamf/test'
290
+ import { registryServer } from '@yamf/core'
291
+
292
+ const port = await pickListenPort()
293
+ await withEnv({ YAMF_REGISTRY_URL: `http://127.0.0.1:${port}` }, async () => {
294
+ await terminateAfter(
295
+ () => registryServer(),
296
+ async () => { /* … */ }
297
+ )
298
+ })
299
+ ```
300
+
261
301
  ### `withEnv(envVars, testFn)`
262
302
 
263
303
  Run a test with temporary environment variables that are restored after.
@@ -277,6 +317,8 @@ async function testWithEnv() {
277
317
  }
278
318
  ```
279
319
 
320
+ **YAMF integration tests:** Prefer defaults from the suite’s `.env.test` and `terminateAfter` for cleanup. Use `withEnv` only when a case must *change* the environment (missing vars, feature flags, per-test secrets, etc.). See [../../docs/TESTING.md](../../docs/TESTING.md) in the YAMF repo.
321
+
280
322
  ## Solo and Mute Flags
281
323
 
282
324
  Focus on specific tests or skip others during development.
@@ -361,7 +403,7 @@ function TODOtestNewFeature() {
361
403
  ✔ testDirectValue (1ms)
362
404
  ✔ testFunctionResult (0ms)
363
405
  ✔ testAsyncFunction (15ms)
364
- ✘ testFailingAssertion
406
+ ✘ testFailingAssertion (12ms)
365
407
 
366
408
  ----- Testing Complete -----
367
409
  ✔ ✔ ✔ Success Report ✔ ✔ ✔
@@ -385,6 +427,8 @@ testFailingAssertion failed with error: AssertionFailure: ...
385
427
  ℹ duration_ms 42
386
428
  ```
387
429
 
430
+ Each `✔` / `✘` line includes wall time for that test. For a **slowest-first table** (e.g. profiling), run `yamf test --timings` or set `YAMF_TEST_TIMINGS=true`, then use `-f` / `-n` to focus one file or test: `yamf test -d packages/cli -f cli-rolling --timings -n testRestart`.
431
+
388
432
  ## API Reference
389
433
 
390
434
  ### Assertions
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yamf/test",
3
- "version": "0.1.4",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "description": "Functional testing module - with polymorphic async/await and simultaneous assertion reports",
6
6
  "main": "src/index.js",
@@ -11,7 +11,7 @@
11
11
  "./utils": "./src/utils.js"
12
12
  },
13
13
  "engines": {
14
- "node": ">=20.0.0"
14
+ "node": ">=22.0.0"
15
15
  },
16
16
  "keywords": [
17
17
  "testing",
@@ -31,9 +31,10 @@
31
31
  "url": "https://github.com/mcbrumagin/yamf"
32
32
  },
33
33
  "peerDependencies": {
34
- "@yamf/core": "0.4.0"
34
+ "@yamf/core": "0.9.0"
35
35
  },
36
36
  "scripts": {
37
- "test": "yamf test -d ."
37
+ "test": "yamf test -d .",
38
+ "test:all": "yamf test -d . --include-e2e"
38
39
  }
39
40
  }
@@ -0,0 +1,29 @@
1
+ import { createServer } from 'node:net'
2
+ import { registryServer } from '@yamf/core'
3
+ import { terminateAfter } from './helpers.js'
4
+
5
+ /**
6
+ * Returns an ephemeral TCP port on 127.0.0.1 for examples/tests that must avoid
7
+ * fixed ports (EADDRINUSE / drain races).
8
+ * @returns {Promise<number>}
9
+ */
10
+ export async function pickListenPort () {
11
+ return new Promise((resolve, reject) => {
12
+ const s = createServer()
13
+ s.once('error', reject)
14
+ s.listen(0, '127.0.0.1', () => {
15
+ const addr = s.address()
16
+ const port = typeof addr === 'object' && addr ? addr.port : null
17
+ s.close((err) => (err ? reject(err) : resolve(port)))
18
+ })
19
+ })
20
+ }
21
+
22
+ /**
23
+ * Run a test body with a local registry and optional service factories (thunks).
24
+ * Same argument order as {@link terminateAfter}: `() => registryServer()`, `() => createX()`, …, `async (reg, x) => {}`.
25
+ * @param {...(function|function[])} serviceFactoriesAndTestFn
26
+ */
27
+ export async function withInlineRegistry (...args) {
28
+ return terminateAfter(() => registryServer(), ...args)
29
+ }
package/src/helpers.js CHANGED
@@ -1,39 +1,107 @@
1
- import { Logger, envConfig } from '@yamf/core'
1
+ import { Logger, envConfig, envTruthy, terminateActiveRegistryServers } from '@yamf/core'
2
2
 
3
3
  const logger = new Logger()
4
4
 
5
5
  export const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
6
6
 
7
- export async function terminateAfter(...args /* ...serverFns, testFn */) {
8
- args.unshift(args.pop()) // rearrange for spread
9
- let [testFn, ...serverFns] = args
10
- if (typeof testFn !== 'function') throw new Error('terminateAfter last argument must be a function')
11
-
12
- let servers = []
7
+ function isThenable (x) {
8
+ return x != null && typeof x.then === 'function'
9
+ }
10
+
11
+ async function terminateAfterSingleFn (fn) {
12
+ let bodyError
13
13
  try {
14
- servers = await Promise.all(serverFns)
15
- for (let server of servers) {
16
- if (server && server.length > 0) {
17
- let index = servers.indexOf(server)
18
- servers.splice(index, 1)
19
- servers.push(...server)
14
+ await fn()
15
+ } catch (e) {
16
+ bodyError = e
17
+ } finally {
18
+ try {
19
+ await terminateActiveRegistryServers()
20
+ } catch (e) {
21
+ if (process.env.YAMF_TEST_VERBOSE_TEARDOWN != null && envTruthy(process.env.YAMF_TEST_VERBOSE_TEARDOWN)) {
22
+ console.warn(`[terminateAfter] registry shutdown failed: ${e.message}`)
20
23
  }
21
24
  }
25
+ }
26
+ if (bodyError) throw bodyError
27
+ }
28
+
29
+ /**
30
+ * Start servers, run the test, then terminate in a safe order (non-registry first, registry last).
31
+ *
32
+ * **Evaluation order (JavaScript):** `terminateAfter(() => registry(), () => create(), testFn)` *starts* all
33
+ * async tasks while building the argument list, before `terminateAfter` runs—so a later `createRoute()`
34
+ * can hit the registry before it is listening. **Pass thunks (zero-arg functions) so work starts
35
+ * in sequence** inside this helper, e.g. `terminateAfter(() => registryServer(), () => createService('x', fn), testFn)`.
36
+ * You can still pass an already-started Promise (a thenable) when you need parallel start
37
+ * outside this helper's sequential `for` loop.
38
+ *
39
+ * - Each server item is either: a **thenable** (awaited as-is), or a **function** (called with no
40
+ * args; the return is awaited, then optional array flattening applies as below).
41
+ * - A single `Promise.all([...])` can be one argument: `() => Promise.all([...])` as a thunk, or
42
+ * pass the Promise if you need parallel start outside the sequential `for` loop.
43
+ *
44
+ * @param {...(Promise<unknown> | (() => unknown) | unknown | unknown[])} serverFns
45
+ * Each item may be a thenable, a no-arg factory, a value, or (after await) a non-empty `Array` of
46
+ * server-like objects.
47
+ * @param {Function} testFn
48
+ */
49
+ export async function terminateAfter (...args) {
50
+ if (args.length === 1 && typeof args[0] === 'function') {
51
+ return await terminateAfterSingleFn(args[0])
52
+ }
53
+
54
+ const testFn = args[args.length - 1]
55
+ const serverInputs = args.slice(0, -1)
56
+ if (typeof testFn !== 'function') {
57
+ throw new Error('terminateAfter last argument must be a function')
58
+ }
22
59
 
23
- let result = await testFn(...servers)
24
- return result
60
+ const flatServers = []
61
+ try {
62
+ for (const item of serverInputs) {
63
+ let toAwait
64
+ if (isThenable(item)) {
65
+ toAwait = item
66
+ } else if (typeof item === 'function') {
67
+ const out = item()
68
+ toAwait = isThenable(out) ? out : Promise.resolve(out)
69
+ } else {
70
+ toAwait = Promise.resolve(item)
71
+ }
72
+ const resolved = await toAwait
73
+ if (Array.isArray(resolved) && resolved.length > 0) {
74
+ for (const s of resolved) {
75
+ flatServers.push(s)
76
+ }
77
+ } else {
78
+ flatServers.push(resolved)
79
+ }
80
+ }
81
+ return await testFn(...flatServers)
25
82
  } finally {
26
- let registryIndex = servers.findIndex(s => s.isRegistry)
83
+ const registryIndex = flatServers.findIndex(s => s && s.isRegistry)
27
84
  if (registryIndex > -1) {
28
- let registryServer = servers[registryIndex]
29
- servers = servers.slice(0, registryIndex).concat(servers.slice(registryIndex + 1))
30
- for (let server of servers) {
31
- await server?.terminate()
32
- logger.info(`terminated server ${server?.name} at port ${server?.port}`)
85
+ const registryServer_ = flatServers[registryIndex]
86
+ const otherServers = flatServers.filter((_, i) => i !== registryIndex)
87
+ for (const server of otherServers) {
88
+ if (!server) {
89
+ continue
90
+ }
91
+ await server.terminate()
92
+ logger.info(`terminated server ${server.name} at port ${server.port}`)
33
93
  }
34
- await registryServer?.terminate()
35
- logger.info(`terminated registry server at port ${registryServer?.port}`)
36
- } else for (let server of servers) await server?.terminate()
94
+ await registryServer_?.terminate()
95
+ logger.info(`terminated registry server at port ${registryServer_?.port}`)
96
+ } else {
97
+ for (const server of flatServers) {
98
+ if (!server) {
99
+ continue
100
+ }
101
+ await server.terminate()
102
+ logger.info(`terminated server ${server.name} at port ${server.port}`)
103
+ }
104
+ }
37
105
  }
38
106
  }
39
107
 
@@ -42,8 +110,9 @@ function setEnv(key, value) {
42
110
  delete process.env[key]
43
111
  envConfig.config.delete(key)
44
112
  } else {
45
- process.env[key] = value
46
- envConfig.set(key, value)
113
+ const str = String(value)
114
+ process.env[key] = str
115
+ envConfig.set(key, envConfig.parseValue(str))
47
116
  }
48
117
  }
49
118
 
package/src/index.js CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  terminateAfter,
21
21
  withEnv
22
22
  } from './helpers.js'
23
+ import { withInlineRegistry, pickListenPort } from './example-helpers.js'
23
24
 
24
25
  import runTests, {
25
26
  mergeAllTestsSafely,
@@ -45,5 +46,7 @@ export {
45
46
  runTests,
46
47
  runTestFnsSequentially,
47
48
  TestRunner,
48
- withEnv
49
+ withEnv,
50
+ withInlineRegistry,
51
+ pickListenPort
49
52
  }
package/src/runner.js CHANGED
@@ -24,7 +24,7 @@
24
24
  */
25
25
 
26
26
  import crypto from 'crypto'
27
- import { Logger } from '@yamf/core'
27
+ import { Logger, envConfig, envTruthy } from '@yamf/core'
28
28
 
29
29
  const logger = new Logger({ includeLogLineNumbers: false })
30
30
 
@@ -91,6 +91,8 @@ export async function runTestFnsSequentially(testFns) {
91
91
  let failedCases = []
92
92
  let todoCases = []
93
93
  let testSuites = {}
94
+ /** @type {{ name: string, durationMs: number, ok: boolean }[]} */
95
+ let testTimings = []
94
96
 
95
97
  // Support arrays or objects
96
98
  testFns = Array.isArray(testFns) ? testFns : Object.values(testFns)
@@ -122,10 +124,24 @@ export async function runTestFnsSequentially(testFns) {
122
124
  if (fn.suite && !testSuites[fn.suite]) {
123
125
  testSuites[fn.suite] = true
124
126
  }
127
+ const startTime = Date.now()
125
128
  try {
126
- let startTime = Date.now()
127
- await fn()
128
- let durationMs = Date.now() - startTime
129
+ const timeoutRaw = process.env.YAMF_TEST_CASE_TIMEOUT_MS
130
+ const timeoutMs = timeoutRaw != null && timeoutRaw !== '' ? Number(timeoutRaw) : NaN
131
+ const useTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0
132
+ if (useTimeout) {
133
+ await new Promise((resolve, reject) => {
134
+ const t = setTimeout(() => reject(new Error(`Test timed out after ${timeoutMs}ms`)), timeoutMs)
135
+ Promise.resolve(fn()).then(
136
+ (v) => { clearTimeout(t); resolve(v) },
137
+ (e) => { clearTimeout(t); reject(e) }
138
+ )
139
+ })
140
+ } else {
141
+ await fn()
142
+ }
143
+ const durationMs = Date.now() - startTime
144
+ testTimings.push({ name: fn.name, durationMs, ok: true })
129
145
  logger.info(logger.writeColor('green', `✔ ${fn.name}`) + logger.writeColor('gray', ` (${durationMs}ms)`))
130
146
  testSuccess++
131
147
  successCases.push(fn.name)
@@ -134,7 +150,9 @@ export async function runTestFnsSequentially(testFns) {
134
150
  todoCases.push(fn.name)
135
151
  return
136
152
  }
137
- logger.error(logger.writeColor('red', `✘ ${fn.name}`))
153
+ const durationMs = Date.now() - startTime
154
+ testTimings.push({ name: fn.name, durationMs, ok: false })
155
+ logger.error(logger.writeColor('red', `✘ ${fn.name}`) + logger.writeColor('gray', ` (${durationMs}ms)`))
138
156
  if (err.message.includes('terminateAfter')) {
139
157
  logger.error(logger.writeColor('magenta', 'Exiting early due to failure in terminateAfter: ', err.stack))
140
158
  process.exit(1)
@@ -165,11 +183,23 @@ export async function runTestFnsSequentially(testFns) {
165
183
  durationMs,
166
184
  isSoloRun,
167
185
  isMuteRun,
168
- testSuitesCount: Object.keys(testSuites).length
186
+ testSuitesCount: Object.keys(testSuites).length,
187
+ testTimings
188
+ }
189
+ }
190
+
191
+ function printTimingTable (testTimings) {
192
+ if (!testTimings || testTimings.length === 0) return
193
+ const sorted = [...testTimings].sort((a, b) => b.durationMs - a.durationMs)
194
+ logger.info('----- Per-test timing (slowest first) -----')
195
+ for (const t of sorted) {
196
+ const tag = t.ok ? '✔' : '✘'
197
+ logger.info(` ${tag} ${t.name} ${t.durationMs}ms`)
169
198
  }
199
+ logger.info('')
170
200
  }
171
201
 
172
- function reportTestResults({
202
+ function reportTestResults ({
173
203
  testSuccess, successCases,
174
204
  testFail, failedCases,
175
205
  testSuitesCount = 0,
@@ -177,13 +207,16 @@ function reportTestResults({
177
207
  todoCases = [],
178
208
  isSoloRun = false,
179
209
  isMuteRun = false,
180
- durationMs
210
+ durationMs,
211
+ testTimings
181
212
  }) {
182
-
183
213
  logger.info('\n')
184
214
  logger.info(`----- Testing Complete -----`)
185
215
 
186
- if (testSuccess > 0 && process.env.MUTE_SUCCESS_CASES !== 'true') {
216
+ if (
217
+ testSuccess > 0 &&
218
+ !envTruthy(envConfig.get('YAMF_TEST_QUIET_PASSES', false))
219
+ ) {
187
220
  logger.info(logger.writeColor('green', '✔ ✔ ✔ Success Report ✔ ✔ ✔'))
188
221
  logger.info(logger.writeColor('green', '\n ' + successCases.join('\n ')))
189
222
  logger.info('')
@@ -207,6 +240,10 @@ function reportTestResults({
207
240
 
208
241
  if (isSoloRun) logger.warn(logger.writeColor('magenta', 'This was a solo test run, remove "solo" flags for a full test run'))
209
242
  if (isMuteRun) logger.warn(logger.writeColor('magenta', 'This was a partially muted test run, remove "mute" flags for a full test run'))
243
+
244
+ if (envTruthy(envConfig.get('YAMF_TEST_TIMINGS', false)) && testTimings && testTimings.length > 0) {
245
+ printTimingTable(testTimings)
246
+ }
210
247
  }
211
248
 
212
249
  // ============================================================================
@@ -261,7 +298,7 @@ export default async function runTests(testSuitesOrFns) {
261
298
  }
262
299
  }
263
300
 
264
- // Export for backwards compatibility
301
+ // Named export mirrors the default export (`runTests`).
265
302
  export { runTests }
266
303
 
267
304
  // ============================================================================
@@ -0,0 +1,64 @@
1
+ /**
2
+ * terminateAfter single-fn overload (in-process registry tracking from @yamf/core).
3
+ */
4
+ import { assert, terminateAfter, withEnv, pickListenPort } from '@yamf/test'
5
+ import { registryServer, createService } from '@yamf/core'
6
+
7
+ export async function testTerminateAfterSingleFnStopsRegistryCascade () {
8
+ const port = await pickListenPort()
9
+ await withEnv({ YAMF_REGISTRY_URL: `http://127.0.0.1:${port}` }, async () => {
10
+ let ran = false
11
+ await terminateAfter(async () => {
12
+ await registryServer()
13
+ await createService('noop', function noop () {
14
+ return {}
15
+ })
16
+ ran = true
17
+ })
18
+ await assert(ran, r => r === true)
19
+ })
20
+ }
21
+
22
+ export async function testTerminateAfterSingleFnNoRegistryNoOp () {
23
+ await terminateAfter(async () => {
24
+ await assert(1 + 1, x => x === 2)
25
+ })
26
+ }
27
+
28
+ export async function testTerminateAfterSingleFnPropagatesBodyError () {
29
+ let saw = false
30
+ try {
31
+ await terminateAfter(async () => {
32
+ throw new Error('body-fail')
33
+ })
34
+ } catch (e) {
35
+ saw = e instanceof Error && e.message === 'body-fail'
36
+ }
37
+ await assert(saw, s => s === true)
38
+ }
39
+
40
+ export async function testTerminateAfterVerboseTeardownEnvSafe () {
41
+ const prev = process.env.YAMF_TEST_VERBOSE_TEARDOWN
42
+ process.env.YAMF_TEST_VERBOSE_TEARDOWN = 'true'
43
+ try {
44
+ await terminateAfter(async () => {})
45
+ } finally {
46
+ if (prev === undefined) delete process.env.YAMF_TEST_VERBOSE_TEARDOWN
47
+ else process.env.YAMF_TEST_VERBOSE_TEARDOWN = prev
48
+ }
49
+ }
50
+
51
+ export async function testTerminateAfterMultiArgUnchanged () {
52
+ const port = await pickListenPort()
53
+ await withEnv({ YAMF_REGISTRY_URL: `http://127.0.0.1:${port}` }, async () => {
54
+ await terminateAfter(
55
+ () => registryServer(),
56
+ () => createService('ex', function ex () {
57
+ return { ok: true }
58
+ }),
59
+ async () => {
60
+ await assert(1, n => n === 1)
61
+ }
62
+ )
63
+ })
64
+ }