@exodus/test 1.0.0-rc.32 → 1.0.0-rc.34

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 CHANGED
@@ -37,7 +37,7 @@ Or, run with [`--jest` option](#options) to register jest globals
37
37
  ### Moving from tap/tape
38
38
 
39
39
  ```js
40
- import test from '@exodus/test/tap'
40
+ import test from '@exodus/test/tape'
41
41
  ```
42
42
 
43
43
  ### Running tests asynchronously
@@ -0,0 +1,50 @@
1
+ const { Worker, MessageChannel, isMainThread, parentPort } = require('node:worker_threads')
2
+ const { once } = require('node:events')
3
+ const { availableParallelism } = require('node:os')
4
+
5
+ if (isMainThread) {
6
+ const maxWorkers = availableParallelism() >= 4 ? 2 : 1
7
+ const workers = []
8
+
9
+ const getWorker = () => {
10
+ const idle = workers.find((info) => info.busy === 0)
11
+ if (idle) return idle
12
+
13
+ if (workers.length < maxWorkers) {
14
+ const worker = new Worker(__filename)
15
+ worker.unref()
16
+ // unhandled top-level errors will crash automatically, which is desired behavior, no need to listen to error
17
+ workers.unshift({ worker, busy: 0 })
18
+ } else if (workers.length > 1) {
19
+ workers.sort((a, b) => a.busy - b.busy)
20
+ }
21
+
22
+ return workers[0]
23
+ }
24
+
25
+ const transformAsync = async (code, options) => {
26
+ const info = getWorker()
27
+ info.busy++
28
+ const channel = new MessageChannel()
29
+ info.worker.postMessage({ port: channel.port1, code, options }, [channel.port1])
30
+ const [{ result, error }] = await once(channel.port2, 'message')
31
+ info.busy--
32
+ if (error) throw error
33
+ return result
34
+ }
35
+
36
+ module.exports = { transformAsync }
37
+ } else {
38
+ const babel = require('@babel/core')
39
+ parentPort.on('message', ({ port, code: input, options }) => {
40
+ try {
41
+ const { code, sourcetype, map } = babel.transformSync(input, options) // async here is useless and slower
42
+ // additional properties are deleted as we don't want to transfer e.g. Plugin instances
43
+ port.postMessage({ result: { code, sourcetype, map } })
44
+ } catch (error) {
45
+ port.postMessage({ error })
46
+ }
47
+
48
+ port.close()
49
+ })
50
+ }
package/bin/bundle.js ADDED
@@ -0,0 +1,285 @@
1
+ import assert from 'node:assert/strict'
2
+ import { readFile } from 'node:fs/promises'
3
+ import { existsSync } from 'node:fs'
4
+ import { fileURLToPath, pathToFileURL } from 'node:url'
5
+ import { basename, dirname, resolve, join } from 'node:path'
6
+ import { createRequire } from 'node:module'
7
+ import { randomUUID as uuid, randomBytes } from 'node:crypto'
8
+ import * as esbuild from 'esbuild'
9
+ import glob from 'fast-glob'
10
+
11
+ const require = createRequire(import.meta.url)
12
+ const resolveRequire = (query) => require.resolve(query)
13
+ const resolveImport = import.meta.resolve && ((query) => fileURLToPath(import.meta.resolve(query)))
14
+
15
+ const readSnapshots = async (files) => {
16
+ const snapshots = []
17
+ for (const file of files) {
18
+ for (const resolver of [
19
+ (dir, name) => [dir, `${name}.snapshot`], // node:test
20
+ (dir, name) => [dir, '__snapshots__', `${name}.snap`], // jest
21
+ ]) {
22
+ const snapshotFile = join(...resolver(dirname(file), basename(file)))
23
+ try {
24
+ snapshots.push([snapshotFile, await readFile(snapshotFile, 'utf8')])
25
+ } catch (e) {
26
+ if (e.code !== 'ENOENT') throw e
27
+ }
28
+ }
29
+ }
30
+
31
+ return snapshots
32
+ }
33
+
34
+ // These packages throw on import
35
+ const blockedDeps = ['@pollyjs/adapter-node-http', '@pollyjs/node-server']
36
+ const loadPipeline = [
37
+ function (source, filepath) {
38
+ return source
39
+ .replace(/\bimport\.meta\.url\b/g, JSON.stringify(pathToFileURL(filepath)))
40
+ .replace(/\b(__dirname|import\.meta\.dirname)\b/g, JSON.stringify(dirname(filepath)))
41
+ .replace(/\b(__filename|import\.meta\.filename)\b/g, JSON.stringify(filepath))
42
+ },
43
+ function (source, filepath) {
44
+ // Just a convenience wrapper to show pretty errors instead of generic bundle-apis/empty/module-throw.cjs
45
+ for (const pkg of blockedDeps) {
46
+ const str = `require(${JSON.stringify(pkg)})`
47
+ assert(!str.includes("'"))
48
+ const err = `module unsupported in bundled form: ${pkg}\n loaded from ${filepath}`
49
+ const rep = `((() => { throw new Error(${JSON.stringify(err)}) })())`
50
+ for (const sub of [str, str.replaceAll('"', "'")]) source = source.replace(sub, rep)
51
+ }
52
+
53
+ return source
54
+ },
55
+ ]
56
+
57
+ const options = {}
58
+
59
+ export const init = async ({ platform, jest, target, jestConfig, outdir }) => {
60
+ Object.assign(options, { platform, jest, target, jestConfig, outdir })
61
+ if (options.platform === 'hermes') {
62
+ const babel = await import('./babel-worker.cjs')
63
+ loadPipeline.push(async (source) => {
64
+ const result = await babel.transformAsync(source, {
65
+ compact: false,
66
+ babelrc: false,
67
+ configFile: false,
68
+ plugins: [
69
+ '@babel/plugin-syntax-typescript',
70
+ '@babel/plugin-transform-block-scoping',
71
+ '@babel/plugin-transform-class-properties',
72
+ '@babel/plugin-transform-classes',
73
+ '@babel/plugin-transform-private-methods',
74
+ ],
75
+ })
76
+ return result.code
77
+ })
78
+ }
79
+ }
80
+
81
+ const hermesSupported = {
82
+ arrow: false,
83
+ class: false, // we get a safeguard check this way that it's not used
84
+ 'async-generator': false,
85
+ 'const-and-let': false, // have to explicitly set for esbuild to not emit that in helpers, also to get a safeguard check
86
+ 'for-await': false,
87
+ }
88
+
89
+ const getPackageFiles = async (dir) => {
90
+ // Returns an empty list on errors
91
+ let patterns
92
+ try {
93
+ patterns = JSON.parse(await readFile(resolve(dir, 'package.json'), 'utf8')).files
94
+ } catch {}
95
+
96
+ if (!patterns) {
97
+ const parent = dirname(dir)
98
+ if (parent !== dir) return getPackageFiles(parent)
99
+ return []
100
+ }
101
+
102
+ // Hack for now, TODO: fix this
103
+ const expanded = patterns.flatMap((x) => (x.includes('.') ? [x] : [x, `${x}/**/*`]))
104
+ return glob(expanded, { ignore: ['**/node_modules'], cwd: dir, absolute: true })
105
+ }
106
+
107
+ const loadCache = new Map()
108
+ const loadSourceFile = async (filepath) => {
109
+ if (!loadCache.has(filepath)) {
110
+ const load = async () => {
111
+ let contents = await readFile(filepath, 'utf8')
112
+ for (const transform of loadPipeline) contents = await transform(contents, filepath)
113
+ return contents
114
+ }
115
+
116
+ loadCache.set(filepath, load())
117
+ }
118
+
119
+ return loadCache.get(filepath)
120
+ }
121
+
122
+ export const build = async (...files) => {
123
+ const input = []
124
+ const importSource = async (file) => input.push(await loadSourceFile(resolveRequire(file)))
125
+ const importFile = (...args) => input.push(`await import(${JSON.stringify(resolve(...args))});`)
126
+ const stringify = (x) => ([undefined, null].includes(x) ? `${x}` : JSON.stringify(x))
127
+
128
+ if (!['node'].includes(options.platform)) {
129
+ if (['jsc', 'hermes'].includes(options.platform)) {
130
+ const entropy = randomBytes(5 * 1024).toString('base64')
131
+ input.push(`globalThis.EXODUS_TEST_CRYPTO_ENTROPY = ${stringify(entropy)};`)
132
+ }
133
+
134
+ await importSource('../src/bundle-apis/globals.cjs')
135
+ }
136
+
137
+ if (options.jest) {
138
+ const { jestConfig } = options
139
+ assert(jestConfig.rootDir)
140
+ const preload = [...(jestConfig.setupFiles || []), ...(jestConfig.setupFilesAfterEnv || [])]
141
+ if (jestConfig.testEnvironment && jestConfig.testEnvironment !== 'node') {
142
+ const { specialEnvironments } = await import('../src/jest.environment.js')
143
+ assert(Object.hasOwn(specialEnvironments, jestConfig.testEnvironment))
144
+ preload.push(...(specialEnvironments[jestConfig.testEnvironment].dependencies || []))
145
+ }
146
+
147
+ const local = createRequire(resolve(jestConfig.rootDir, 'package.json'))
148
+ const w = (f) => `[${stringify(f)}, () => require(${stringify(local.resolve(f))})]`
149
+ input.push(`globalThis.EXODUS_TEST_PRELOADED = [${preload.map((f) => w(f)).join(', ')}]`)
150
+ await importSource('./jest.js')
151
+ }
152
+
153
+ for (const file of files) importFile(file)
154
+
155
+ const filename = files.length === 1 ? `${files[0]}-${uuid().slice(0, 8)}` : `bundle-${uuid()}`
156
+ const outfile = `${join(options.outdir, filename)}.js`
157
+ const EXODUS_TEST_SNAPSHOTS = await readSnapshots(files)
158
+ const buildWrap = async (opts) => esbuild.build(opts).catch((err) => err)
159
+ let main = input.join(';\n')
160
+ if (['jsc', 'hermes'].includes(options.platform)) {
161
+ const exit = `EXODUS_TEST_PROCESS.exitCode = 1; EXODUS_TEST_PROCESS._maybeProcessExitCode();`
162
+ main = `try {\n${main}\n} catch (err) { print(err); ${exit} }`
163
+ }
164
+
165
+ const fsfiles = await getPackageFiles(filename ? dirname(resolve(filename)) : process.cwd())
166
+
167
+ const hasBuffer = ['node', 'bun'].includes(options.platform)
168
+ const api = (f) => resolveRequire(join('../src/bundle-apis', f))
169
+ const res = await buildWrap({
170
+ logLevel: 'silent',
171
+ stdin: {
172
+ contents: `(async function () {\n${main}\n})()`,
173
+ resolveDir: dirname(fileURLToPath(import.meta.url)),
174
+ },
175
+ bundle: true,
176
+ outdir: options.outdir,
177
+ entryNames: filename,
178
+ platform: 'neutral',
179
+ mainFields: ['browser', 'module', 'main'],
180
+ define: {
181
+ 'process.env.FORCE_COLOR': stringify('0'),
182
+ 'process.env.NO_COLOR': stringify('1'),
183
+ 'process.env.NODE_ENV': stringify(process.env.NODE_ENV),
184
+ 'process.env.EXODUS_TEST_CONTEXT': stringify('pure'),
185
+ 'process.env.EXODUS_TEST_ENVIRONMENT': stringify('bundle'),
186
+ 'process.env.EXODUS_TEST_PLATFORM': stringify(process.env.EXODUS_TEST_PLATFORM),
187
+ 'process.env.EXODUS_TEST_JEST_CONFIG': stringify(JSON.stringify(options.jestConfig)),
188
+ 'process.env.EXODUS_TEST_EXECARGV': stringify(process.env.EXODUS_TEST_EXECARGV),
189
+ 'process.env.NODE_DEBUG': stringify(),
190
+ 'process.env.DEBUG': stringify(),
191
+ 'process.env.READABLE_STREAM': stringify(),
192
+ 'process.env.CI': stringify(process.env.CI),
193
+ 'process.env.CI_ENABLE_VERBOSE_LOGS': stringify(process.env.CI_ENABLE_VERBOSE_LOGS),
194
+ 'process.browser': stringify(true),
195
+ 'process.emitWarning': 'undefined',
196
+ 'process.stderr': 'undefined',
197
+ 'process.stdout': 'undefined',
198
+ 'process.type': 'undefined',
199
+ 'process.version': stringify('v22.5.1'), // shouldn't depend on currently used Node.js version
200
+ 'process.versions.node': stringify('22.5.1'), // see line above
201
+ EXODUS_TEST_FILES: stringify(files.map((f) => [dirname(f), basename(f)])),
202
+ EXODUS_TEST_SNAPSHOTS: stringify(EXODUS_TEST_SNAPSHOTS),
203
+ EXODUS_TEST_FSFILES: stringify(fsfiles), // TODO: can we safely use relative paths?
204
+ },
205
+ alias: {
206
+ // Jest and tape
207
+ '@jest/globals': resolveImport('../src/jest.js'),
208
+ tape: resolveImport('../src/tape.cjs'),
209
+ 'tape-promise/tape': resolveImport('../src/tape.cjs'),
210
+ // Node browserify
211
+ 'node:assert': dirname(dirname(resolveRequire('assert/'))),
212
+ 'node:assert/strict': api('assert-strict.cjs'),
213
+ 'node:fs': api('fs.cjs'),
214
+ 'node:fs/promises': api('fs-promises.cjs'),
215
+ fs: api('fs.cjs'),
216
+ 'fs/promises': api('fs-promises.cjs'),
217
+ assert: dirname(dirname(resolveRequire('assert/'))),
218
+ buffer: hasBuffer ? api('node-buffer.cjs') : dirname(resolveRequire('buffer/')),
219
+ child_process: api('child_process.cjs'),
220
+ constants: resolveRequire('constants-browserify'),
221
+ crypto: api('crypto.cjs'),
222
+ events: dirname(resolveRequire('events/')),
223
+ http: api('http.cjs'),
224
+ https: api('https.cjs'),
225
+ os: resolveRequire('os-browserify'),
226
+ path: resolveRequire('path-browserify'),
227
+ querystring: resolveRequire('querystring-es3'),
228
+ stream: resolveRequire('stream-browserify'),
229
+ timers: resolveRequire('timers-browserify'),
230
+ url: dirname(resolveRequire('url/')),
231
+ util: dirname(resolveRequire('util/')),
232
+ zlib: resolveRequire('browserify-zlib'),
233
+ // expect-related deps
234
+ 'ansi-styles': api('ansi-styles.cjs'),
235
+ 'jest-util': api('jest-util.js'),
236
+ 'jest-message-util': api('jest-message-util.js'),
237
+ // unwanted deps
238
+ bindings: api('empty/function-throw.cjs'),
239
+ 'node-gyp-build': api('empty/function-throw.cjs'),
240
+ ws: api('ws.cjs'),
241
+ // unsupported deps
242
+ ...Object.fromEntries(blockedDeps.map((n) => [n, api('empty/module-throw.cjs')])),
243
+ },
244
+ sourcemap: ['hermes', 'jsc'].includes(options.platform) ? 'inline' : 'linked', // FIXME?
245
+ sourcesContent: false,
246
+ keepNames: true,
247
+ format: 'iife',
248
+ target: options.target || `node${process.versions.node}`,
249
+ supported: {
250
+ bigint: true,
251
+ ...(options.platform === 'hermes' ? hermesSupported : {}),
252
+ },
253
+ plugins: [
254
+ {
255
+ name: 'exodus-test.bundle',
256
+ setup({ onLoad }) {
257
+ onLoad({ filter: /\.[cm]?[jt]s$/, namespace: 'file' }, async (args) => {
258
+ let filepath = args.path
259
+ // Resolve .native versions
260
+ // TODO: move flag to engine options
261
+ // TODO: maybe follow package.json for this
262
+ if (['jsc', 'hermes'].includes(options.platform)) {
263
+ const maybeNative = filepath.replace(/(\.[cm]?[jt]s)$/u, '.native$1')
264
+ if (existsSync(maybeNative)) filepath = maybeNative
265
+ }
266
+
267
+ const loader = /\.[cm]?ts$/.test(filepath) ? 'ts' : 'js'
268
+ return { contents: await loadSourceFile(filepath), loader }
269
+ })
270
+ },
271
+ },
272
+ ],
273
+ })
274
+ assert.equal(res instanceof Error, res.errors.length > 0)
275
+
276
+ // if (res.errors.length === 0) require('fs').copyFileSync(outfile, 'tempout.cjs') // DEBUG
277
+
278
+ // We treat warnings as errors, so just merge all them
279
+ const errors = []
280
+ const formatOpts = { color: process.stdout.hasColors?.(), terminalWidth: process.stdout.columns }
281
+ const formatMessages = (list, kind) => esbuild.formatMessages(list, { kind, ...formatOpts })
282
+ if (res.warnings.length > 0) errors.push(...(await formatMessages(res.warnings, 'warning')))
283
+ if (res.errors.length > 0) errors.push(...(await formatMessages(res.errors, 'error')))
284
+ return { file: outfile, errors }
285
+ }
package/bin/index.js CHANGED
@@ -1,13 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { spawn } from 'node:child_process'
3
+ import { spawn, execFile as execFileCallback } from 'node:child_process'
4
+ import { promisify, inspect } from 'node:util'
4
5
  import { once } from 'node:events'
5
- import { fileURLToPath, pathToFileURL } from 'node:url'
6
+ import { fileURLToPath } from 'node:url'
6
7
  import { basename, dirname, resolve, join } from 'node:path'
7
8
  import { createRequire } from 'node:module'
8
- import { randomUUID, randomBytes } from 'node:crypto'
9
- import { existsSync } from 'node:fs'
9
+ import { randomUUID } from 'node:crypto'
10
+ import { existsSync, rmSync } from 'node:fs'
11
+ import { unlink } from 'node:fs/promises'
12
+ import { tmpdir, availableParallelism } from 'node:os'
10
13
  import assert from 'node:assert/strict'
14
+ import { Queue } from '@chalker/queue'
11
15
  import glob from 'fast-glob'
12
16
  import { haveModuleMocks, haveSnapshots, haveForceExit } from '../src/version.js'
13
17
 
@@ -25,7 +29,7 @@ const ENGINES = new Map(
25
29
  'bun:pure': { binary: 'bun', pure: true, hasImportLoader: false, ts: 'auto' },
26
30
  'bun:bundle': { binary: 'bun', ...bundleOptions },
27
31
  'deno:bundle': { binary: 'deno', binaryArgs: ['run'], target: 'deno1', ...bundleOptions },
28
- 'jsc:bundle': { binary: 'jsc', ...bundleOptions, target: 'safari11' },
32
+ 'jsc:bundle': { binary: 'jsc', ...bundleOptions, target: 'safari13' },
29
33
  'hermes:bundle': { binary: 'hermes', binaryArgs: ['-Og'], target: 'es2018', ...bundleOptions },
30
34
  })
31
35
  )
@@ -147,6 +151,7 @@ const { options, patterns } = parseOptions()
147
151
  const engineOptions = ENGINES.get(options.engine)
148
152
  assert(engineOptions, `Unknown engine: ${options.engine}`)
149
153
  Object.assign(options, engineOptions)
154
+ options.platform = options.binary // binary can be overriden by c8
150
155
 
151
156
  const require = createRequire(import.meta.url)
152
157
  const resolveRequire = (query) => require.resolve(query)
@@ -348,258 +353,27 @@ if (options.coverage) {
348
353
  args.unshift(options.binary)
349
354
  options.binary = c8
350
355
  // perhaps use text-summary ?
351
- args.unshift('-r', 'text', '-r', 'html')
356
+ args.unshift('-r', 'text', '-r', 'html', '-r', 'lcov', '-r', 'json-summary')
352
357
  } else {
353
358
  throw new Error(`Unknown coverage engine: ${JSON.stringify(options.coverageEngine)}`)
354
359
  }
355
360
  }
356
361
 
357
362
  process.env.EXODUS_TEST_EXECARGV = JSON.stringify(args)
358
- const inputs = files.map((file) => ({ source: file, file }))
363
+ let buildFile
359
364
 
360
365
  if (options.bundle) {
361
- const esbuild = await import('esbuild')
362
- const { readFile, writeFile } = await import('node:fs/promises')
363
- const { rmSync } = await import('node:fs')
364
- const os = await import('node:os')
365
- const outdir = join(os.tmpdir(), `exodus-test-${randomUUID().slice(0, 8)}`)
366
- process.on('beforeExit', async () => rmSync(outdir, { recursive: true, force: true }))
366
+ const outdir = join(tmpdir(), `exodus-test-${randomUUID().slice(0, 8)}`)
367
+ process.on('exit', () => rmSync(outdir, { recursive: true, force: true }))
367
368
  assert.deepEqual(args, [])
368
- if (options.binary === 'node') args.unshift('--enable-source-maps') // FIXME
369
-
370
- const readSnapshots = async (ifiles) => {
371
- const snapshots = []
372
- for (const file of ifiles) {
373
- for (const resolver of [
374
- (dir, name) => [dir, `${name}.snapshot`], // node:test
375
- (dir, name) => [dir, '__snapshots__', `${name}.snap`], // jest
376
- ]) {
377
- const snapshotFile = join(...resolver(dirname(file), basename(file)))
378
- try {
379
- snapshots.push([snapshotFile, await readFile(snapshotFile, 'utf8')])
380
- } catch (e) {
381
- if (e.code !== 'ENOENT') throw e
382
- }
383
- }
384
- }
385
-
386
- return snapshots
387
- }
388
-
389
- // These packages throw on import
390
- const blockedDeps = ['@pollyjs/adapter-node-http', '@pollyjs/node-server']
391
- const loadPipeline = [
392
- function (source, args) {
393
- return source
394
- .replace(/\bimport\.meta\.url\b/g, JSON.stringify(pathToFileURL(args.path)))
395
- .replace(/\b(__dirname|import\.meta\.dirname)\b/g, JSON.stringify(dirname(args.path)))
396
- .replace(/\b(__filename|import\.meta\.filename)\b/g, JSON.stringify(args.path))
397
- },
398
- function (source, args) {
399
- // Just a convenience wrapper to show pretty errors instead of generic bundle-apis/empty/module-throw.cjs
400
- for (const pkg of blockedDeps) {
401
- const str = `require(${JSON.stringify(pkg)})`
402
- assert(!str.includes("'"))
403
- const err = `module unsupported in bundled form: ${pkg}\n loaded from ${args.path}`
404
- const rep = `((() => { throw new Error(${JSON.stringify(err)}) })())`
405
- for (const sub of [str, str.replaceAll('"', "'")]) source = source.replace(sub, rep)
406
- }
407
-
408
- return source
409
- },
410
- ]
411
-
412
- const writePipeline = []
413
- if (options.binary === 'hermes') {
414
- const babel = await import('@babel/core')
415
- writePipeline.push((source) => {
416
- const result = babel.transformSync(source, {
417
- compact: false,
418
- plugins: [
419
- '@babel/plugin-transform-arrow-functions',
420
- '@babel/plugin-transform-async-generator-functions',
421
- '@babel/plugin-transform-class-properties',
422
- '@babel/plugin-transform-classes',
423
- '@babel/plugin-transform-block-scoping',
424
- ],
425
- })
426
- return result.code
427
- })
428
- }
429
-
430
- const getPackageFiles = async () => {
431
- // Returns an empty list on errors
432
- let patterns
433
- try {
434
- patterns = JSON.parse(await readFile('package.json', 'utf8')).files
435
- } catch {}
436
-
437
- if (!patterns) return []
438
- // Hack for now, TODO: fix this
439
- const expanded = patterns.flatMap((x) => (x.includes('.') ? [x] : [x, `${x}/**/*`]))
440
- return glob(expanded, { ignore: ['**/node_modules'] })
441
- }
442
-
443
- const buildOne = async (...ifiles) => {
444
- const input = []
445
- const importSource = async (file) => input.push(await readFile(resolveRequire(file), 'utf8'))
446
- const importFile = (...args) => input.push(`await import(${JSON.stringify(resolve(...args))});`)
447
- const stringify = (x) => ([undefined, null].includes(x) ? `${x}` : JSON.stringify(x))
448
-
449
- if (!['node', c8].includes(options.binary)) {
450
- if (['jsc', 'hermes'].includes(options.binary)) {
451
- const entropy = randomBytes(5 * 1024).toString('base64')
452
- input.push(`globalThis.EXODUS_TEST_CRYPTO_ENTROPY = ${stringify(entropy)};`)
453
- }
454
-
455
- await importSource('../src/bundle-apis/globals.cjs')
456
- }
457
-
458
- if (options.jest) {
459
- assert(jestConfig.rootDir)
460
- const preload = [...(jestConfig.setupFiles || []), ...(jestConfig.setupFilesAfterEnv || [])]
461
- if (jestConfig.testEnvironment && jestConfig.testEnvironment !== 'node') {
462
- const { specialEnvironments } = await import('../src/jest.environment.js')
463
- assert(Object.hasOwn(specialEnvironments, jestConfig.testEnvironment))
464
- preload.push(...(specialEnvironments[jestConfig.testEnvironment].dependencies || []))
465
- }
466
-
467
- const local = createRequire(resolve(jestConfig.rootDir, 'package.json'))
468
- const w = (f) => `[${stringify(f)}, () => require(${stringify(local.resolve(f))})]`
469
- input.push(`globalThis.EXODUS_TEST_PRELOADED = [${preload.map((f) => w(f)).join(', ')}]`)
470
- await importSource('./jest.js')
471
- }
472
-
473
- for (const file of ifiles) importFile(file)
474
-
475
- const filename =
476
- ifiles.length === 1 ? `${ifiles[0]}-${randomUUID().slice(0, 8)}` : `bundle-${randomUUID()}`
477
- const outfile = `${join(outdir, filename)}.js`
478
- const EXODUS_TEST_SNAPSHOTS = await readSnapshots(ifiles)
479
- const build = async (opts) => esbuild.build(opts).catch((err) => ({ errors: [err] }))
480
- let main = input.join('\n')
481
- if (['jsc', 'hermes'].includes(options.binary)) {
482
- const exit = `EXODUS_TEST_PROCESS.exitCode = 1; EXODUS_TEST_PROCESS._maybeProcessExitCode();`
483
- main = `try {\n${main}\n} catch (err) { print(err); ${exit} }`
484
- }
485
-
486
- const fsfiles = await getPackageFiles()
487
-
488
- const res = await build({
489
- stdin: {
490
- contents: `(async function () {\n${main}\n})()`,
491
- resolveDir: bindir,
492
- },
493
- bundle: true,
494
- outdir,
495
- entryNames: filename,
496
- platform: 'neutral',
497
- mainFields: ['browser', 'module', 'main'],
498
- define: {
499
- 'process.env.FORCE_COLOR': stringify('0'),
500
- 'process.env.NO_COLOR': stringify('1'),
501
- 'process.env.NODE_ENV': stringify(process.env.NODE_ENV),
502
- 'process.env.EXODUS_TEST_CONTEXT': stringify('pure'),
503
- 'process.env.EXODUS_TEST_ENVIRONMENT': stringify('bundle'),
504
- 'process.env.EXODUS_TEST_PLATFORM': stringify(process.env.EXODUS_TEST_PLATFORM),
505
- 'process.env.EXODUS_TEST_JEST_CONFIG': stringify(JSON.stringify(jestConfig)),
506
- 'process.env.EXODUS_TEST_EXECARGV': stringify(process.env.EXODUS_TEST_EXECARGV),
507
- 'process.env.NODE_DEBUG': stringify(),
508
- 'process.env.DEBUG': stringify(),
509
- 'process.env.READABLE_STREAM': stringify(),
510
- 'process.env.CI': stringify(process.env.CI),
511
- 'process.env.CI_ENABLE_VERBOSE_LOGS': stringify(process.env.CI_ENABLE_VERBOSE_LOGS),
512
- 'process.browser': stringify(true),
513
- 'process.emitWarning': 'undefined',
514
- 'process.stderr': 'undefined',
515
- 'process.stdout': 'undefined',
516
- 'process.type': 'undefined',
517
- 'process.version': stringify('v22.5.1'), // shouldn't depend on currently used Node.js version
518
- 'process.versions.node': stringify('22.5.1'), // see line above
519
- EXODUS_TEST_FILES: stringify(ifiles.map((f) => [dirname(f), basename(f)])),
520
- EXODUS_TEST_SNAPSHOTS: stringify(EXODUS_TEST_SNAPSHOTS),
521
- EXODUS_TEST_FSFILES: stringify(fsfiles.map((file) => resolve(file))), // TODO: can we safely use relative paths?
522
- },
523
- alias: {
524
- // Node browserify
525
- 'node:assert': resolveRequire('assert'),
526
- 'node:assert/strict': resolveRequire('../src/bundle-apis/assert-strict.cjs'),
527
- 'node:fs': resolveRequire('../src/bundle-apis/fs.cjs'),
528
- 'node:fs/promises': resolveRequire('../src/bundle-apis/fs-promises.cjs'),
529
- fs: resolveRequire('../src/bundle-apis/fs.cjs'),
530
- 'fs/promises': resolveRequire('../src/bundle-apis/fs-promises.cjs'),
531
- assert: resolveRequire('assert'),
532
- buffer: resolveRequire('buffer'),
533
- child_process: resolveRequire('../src/bundle-apis/child_process.cjs'),
534
- constants: resolveRequire('constants-browserify'),
535
- crypto: resolveRequire('../src/bundle-apis/crypto.cjs'),
536
- events: resolveRequire('events'),
537
- http: resolveRequire('../src/bundle-apis/http.cjs'),
538
- https: resolveRequire('../src/bundle-apis/https.cjs'),
539
- os: resolveRequire('os-browserify'),
540
- path: resolveRequire('path-browserify'),
541
- querystring: resolveRequire('querystring-es3'),
542
- stream: resolveRequire('stream-browserify'),
543
- url: resolveRequire('url'),
544
- util: resolveRequire('util'),
545
- // expect-related deps
546
- 'ansi-styles': resolveRequire('../src/bundle-apis/ansi-styles.cjs'),
547
- 'jest-util': resolveRequire('../src/bundle-apis/jest-util.js'),
548
- 'jest-message-util': resolveRequire('../src/bundle-apis/jest-message-util.js'),
549
- // unwanted deps
550
- bindings: resolveRequire('../src/bundle-apis/empty/function-throw.cjs'),
551
- 'node-gyp-build': resolveRequire('../src/bundle-apis/empty/function-throw.cjs'),
552
- ws: resolveRequire('../src/bundle-apis/ws.cjs'),
553
- // unsupported deps
554
- ...Object.fromEntries(
555
- blockedDeps.map((n) => [n, resolveRequire('../src/bundle-apis/empty/module-throw.cjs')])
556
- ),
557
- },
558
- sourcemap: writePipeline.length > 0 ? 'inline' : 'linked',
559
- sourcesContent: false,
560
- keepNames: true,
561
- format: 'iife',
562
- target: options.target || `node${process.versions.node}`,
563
- supported: {
564
- bigint: true,
565
- },
566
- plugins: [
567
- {
568
- name: 'exodus-test.bundle',
569
- setup({ onLoad }) {
570
- onLoad({ filter: /\.m?js$/, namespace: 'file' }, async (args) => {
571
- let filepath = args.path
572
- // Resolve .native versions
573
- // TODO: move flag to engine options
574
- // TODO: maybe follow package.json for this
575
- if (['jsc', 'hermes'].includes(options.binary)) {
576
- const maybeNative = filepath.replace(/(\.[cm]?js)$/u, '.native$1')
577
- if (existsSync(maybeNative)) filepath = maybeNative
578
- }
579
-
580
- let contents = await readFile(filepath, 'utf8')
581
- for (const transform of loadPipeline) contents = await transform(contents, args)
582
- return { contents }
583
- })
584
- },
585
- },
586
- ],
587
- })
588
369
 
589
- if (writePipeline.length > 0 && res.errors.length === 0) {
590
- let contents = await readFile(outfile, 'utf8')
591
- for (const transform of writePipeline) contents = await transform(contents)
592
- await writeFile(outfile, contents)
593
- }
594
-
595
- // require('fs').copyFileSync(outfile, 'tempout.cjs') // DEBUG
596
- return { file: outfile, errors: res.errors, warnings: res.warnings }
597
- }
370
+ if (options.binary === 'node') args.unshift('--enable-source-maps') // FIXME
598
371
 
599
- for (const input of inputs) Object.assign(input, await buildOne(input.file)) // TODO: queued concurrency
372
+ const bundle = await import('./bundle.js')
373
+ await bundle.init({ ...options, outdir, jestConfig })
374
+ buildFile = (file) => bundle.build(file)
600
375
  }
601
376
 
602
- assert.equal(inputs.length, files.length)
603
377
  assert(options.binary && ['node', 'bun', 'deno', 'jsc', 'hermes', c8].includes(options.binary))
604
378
 
605
379
  if (options.pure) {
@@ -630,30 +404,95 @@ if (options.pure) {
630
404
 
631
405
  process.env.EXODUS_TEST_CONTEXT = 'pure'
632
406
  console.warn(`\n${options.engine} engine is experimental and may not work an expected\n`)
633
- const failures = []
634
- for (const input of inputs) {
635
- if (input.errors?.length > 0 || input.warnings?.length > 0) {
636
- failures.push(input.source)
637
- continue
638
- }
407
+
408
+ const execFile = promisify(execFileCallback)
409
+
410
+ const runOne = async (inputFile) => {
411
+ const bundled = buildFile ? await buildFile(inputFile) : undefined
412
+ if (buildFile) assert(bundled.file)
413
+ const file = buildFile ? bundled.file : inputFile
414
+ if (bundled?.errors.length > 0) return { ok: false, output: bundled.errors }
639
415
 
640
416
  const { binaryArgs = [] } = options
641
- console.log(`# ${input.source}`)
642
- const node = spawn(options.binary, [...binaryArgs, ...args, input.file], { stdio: 'inherit' })
643
- const [code] = await once(node, 'close')
644
- if (code !== 0) failures.push(input.source)
417
+ // 5 MiB just in case, timeout is fallback if timeout in script hangs, 50x as it can be adjusted per-script inside them
418
+ // Do we want to extract timeouts from script code instead? Also, hermes might be slower, so makes sense to increase
419
+ const execOpts = { maxBuffer: 5 * 1024 * 1024, timeout: (jestConfig?.testTimeout || 5000) * 50 }
420
+ try {
421
+ const fullArgs = [...binaryArgs, ...args, file]
422
+ const { code = 0, stdout, stderr } = await execFile(options.binary, fullArgs, execOpts)
423
+ return { ok: code === 0, output: [stdout, stderr] }
424
+ } catch (err) {
425
+ const { code, stdout = '', stderr = '', signal, killed } = err
426
+ if (code === null) {
427
+ assert(signal)
428
+ const message = ` ${signal}${killed ? ' (killed)' : ''}`
429
+ const comment = killed && signal === 'SIGTERM' ? ' Most likely due to timeout reached' : ''
430
+ return { ok: false, output: [stdout, stderr, message, comment] }
431
+ }
432
+
433
+ assert(Number.isInteger(code) && code > 0)
434
+ return { ok: false, output: [stdout, stderr] }
435
+ } finally {
436
+ if (bundled) await unlink(bundled.file)
437
+ }
438
+ }
439
+
440
+ const queue = new Queue(availableParallelism() - 1)
441
+ const runConcurrent = async (file) => {
442
+ await queue.claim()
443
+ try {
444
+ // need to await here
445
+ return await runOne(file)
446
+ } finally {
447
+ queue.release()
448
+ }
449
+ }
450
+
451
+ const haveColors = process.stdout.hasColors?.()
452
+ const colors = new Map(Object.entries(inspect.colors))
453
+ const color = (text, color) => {
454
+ if (!haveColors || text === '') return text
455
+ if (!colors.has(color)) throw new Error(`Unknown color: ${color}`)
456
+ const [start, end] = colors.get(color)
457
+ return `\x1B[${start}m${text}\x1B[${end}m`
458
+ }
459
+
460
+ const format = (chunk) => {
461
+ if (!haveColors) return chunk
462
+ return chunk
463
+ .replaceAll(/^✔ PASS /gmu, color('✔ PASS ', 'green'))
464
+ .replaceAll(/^⏭ SKIP /gmu, color('⏭ SKIP ', 'dim'))
465
+ .replaceAll(/^✖ FAIL /gmu, color('✖ FAIL ', 'red'))
466
+ .replaceAll(/^‼ FATAL /gmu, `${color('‼', 'red')} ${color(' FATAL ', 'bgRed')} `)
467
+ }
468
+
469
+ const failures = []
470
+ const tasks = files.map((file) => ({ file, task: runConcurrent(file) }))
471
+ const timeString = color('Total time', 'dim')
472
+ console.time(timeString)
473
+ for (const { file, task } of tasks) {
474
+ console.log(color(`# ${file}`, 'bold'))
475
+ const { ok, output } = await task
476
+ for (const chunk of output.map((x) => x.trimEnd()).filter(Boolean)) console.log(format(chunk))
477
+ if (!ok) failures.push(file)
645
478
  }
646
479
 
647
480
  if (failures.length > 0) {
648
481
  process.exitCode = 1
649
482
  const [total, passed, failed] = [files.length, files.length - failures.length, failures.length]
650
- console.log(`Test suites failed: ${failed} / ${total} (passed: ${passed} / ${total})`)
651
- console.log('Failed test suites:')
483
+ const failLine = color(`${failed} / ${total}`, 'red')
484
+ const passLine = color(`${passed} / ${total}`, 'green')
485
+ const suffix = passed > 0 ? color(` (passed: ${passLine})`, 'dim') : ''
486
+ console.log(`${color('Test suites failed:', 'bold')} ${failLine}${suffix}`)
487
+ console.log(color('Failed test suites:', 'red'))
652
488
  for (const file of failures) console.log(` ${file}`) // joining with \n can get truncated, too big
653
489
  } else {
654
- console.log(`All ${files.length} test suites passed`)
490
+ console.log(color(`All ${files.length} test suites passed`, 'green'))
655
491
  }
492
+
493
+ console.timeEnd(timeString)
656
494
  } else {
495
+ assert(!buildFile)
657
496
  assert(['node', c8].includes(options.binary), `Unexpected native engine: ${options.binary}`)
658
497
  assert(['node:test'].includes(options.engine))
659
498
  process.env.EXODUS_TEST_CONTEXT = options.engine
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/test",
3
- "version": "1.0.0-rc.32",
3
+ "version": "1.0.0-rc.34",
4
4
  "author": "Exodus Movement, Inc.",
5
5
  "description": "A test suite runner",
6
6
  "homepage": "https://github.com/ExodusMovement/test",
@@ -36,6 +36,8 @@
36
36
  "prettier": "@exodus/prettier",
37
37
  "files": [
38
38
  "bin/babel.cjs",
39
+ "bin/babel-worker.cjs",
40
+ "bin/bundle.js",
39
41
  "bin/jest.js",
40
42
  "bin/typescript.js",
41
43
  "bin/typescript.loader.js",
@@ -52,6 +54,7 @@
52
54
  "src/bundle-apis/globals.cjs",
53
55
  "src/bundle-apis/jest-message-util.js",
54
56
  "src/bundle-apis/jest-util.js",
57
+ "src/bundle-apis/node-buffer.cjs",
55
58
  "src/bundle-apis/util-format.cjs",
56
59
  "src/bundle-apis/ws.cjs",
57
60
  "src/dark.cjs",
@@ -69,6 +72,7 @@
69
72
  "src/jest.timers.js",
70
73
  "src/node.js",
71
74
  "src/tape.js",
75
+ "src/tape.cjs",
72
76
  "src/version.js",
73
77
  "!__tests__",
74
78
  "CHANGELOG.md"
@@ -88,15 +92,17 @@
88
92
  },
89
93
  "dependencies": {
90
94
  "@babel/core": "^7.0.0",
91
- "@babel/plugin-transform-arrow-functions": "^7.0.0",
92
- "@babel/plugin-transform-async-generator-functions": "^7.0.0",
95
+ "@babel/plugin-syntax-typescript": "^7.0.0",
93
96
  "@babel/plugin-transform-block-scoping": "^7.0.0",
94
97
  "@babel/plugin-transform-class-properties": "^7.0.0",
95
98
  "@babel/plugin-transform-classes": "^7.0.0",
99
+ "@babel/plugin-transform-private-methods": "^7.0.0",
96
100
  "@babel/register": "^7.0.0",
101
+ "@chalker/queue": "^1.0.0",
97
102
  "@ungap/url-search-params": "^0.2.2",
98
103
  "amaro": "^0.0.4",
99
104
  "assert": "^2.1.0",
105
+ "browserify-zlib": "^0.2.0",
100
106
  "buffer": "^6.0.3",
101
107
  "c8": "^9.1.0",
102
108
  "constants-browserify": "^1.0.0",
@@ -113,6 +119,7 @@
113
119
  "querystring-es3": "^0.2.1",
114
120
  "stream-browserify": "^3.0.0",
115
121
  "text-encoding": "^0.7.0",
122
+ "timers-browserify": "^2.0.12",
116
123
  "tsx": "^4.16.2",
117
124
  "url": "^0.11.0",
118
125
  "util": "^0.12.5"
@@ -2,4 +2,4 @@ const cb = require('crypto-browserify')
2
2
  const webcrypto = globalThis.crypto
3
3
  const randomUUID = () => webcrypto.randomUUID()
4
4
  const getRandomValues = (array) => webcrypto.getRandomValues(array)
5
- module.exports = { ...cb, webcrypto, subtle: webcrypto.subtle, randomUUID, getRandomValues }
5
+ module.exports = { ...cb, webcrypto, subtle: webcrypto?.subtle, randomUUID, getRandomValues }
@@ -81,4 +81,8 @@ const existsSync = (file) => {
81
81
  err('existsSync', file)
82
82
  }
83
83
 
84
- module.exports = { ...stubs, existsSync, promises, constants, F_OK, R_OK, W_OK, X_OK }
84
+ const readFileSync = (file /*, options */) => {
85
+ err('readFileSync', file)
86
+ }
87
+
88
+ module.exports = { ...stubs, existsSync, readFileSync, promises, constants, F_OK, R_OK, W_OK, X_OK }
@@ -1,3 +1,6 @@
1
+ // We expect bundler to optimize out EXODUS_TEST_PLATFORM blocks
2
+ /* eslint-disable sonarjs/no-collapsible-if, unicorn/no-lonely-if */
3
+
1
4
  if (!globalThis.global) globalThis.global = globalThis
2
5
  if (!globalThis.Buffer) globalThis.Buffer = require('buffer').Buffer
3
6
  if (!globalThis.console) {
@@ -24,6 +27,19 @@ if (!Array.prototype.at) {
24
27
  }
25
28
  }
26
29
 
30
+ if (process.env.EXODUS_TEST_PLATFORM === 'hermes') {
31
+ // Fixed after 0.12, not present in 0.12
32
+ // Refs: https://github.com/facebook/hermes/commit/e8fa81328dd630e39975e6d16ac3e6f47f4cba06
33
+ if (!Promise.allSettled) {
34
+ const wrap = (element) =>
35
+ Promise.resolve(element).then(
36
+ (value) => ({ status: 'fulfilled', value }),
37
+ (reason) => ({ status: 'rejected', reason })
38
+ )
39
+ Promise.allSettled = (iterable) => Promise.all([...iterable].map((element) => wrap(element)))
40
+ }
41
+ }
42
+
27
43
  if (globalThis.describe) delete globalThis.describe
28
44
 
29
45
  if (typeof process === 'undefined') {
@@ -48,7 +64,9 @@ if (typeof process === 'undefined') {
48
64
  if (globalThis.Deno) return // has native exitCode support
49
65
  if (process._exitCode !== 0) {
50
66
  setTimeout(() => {
51
- throw new Error('Test failed')
67
+ const err = new Error('Test failed')
68
+ err.stack = ''
69
+ throw err
52
70
  }, 0)
53
71
  }
54
72
  },
@@ -57,6 +75,24 @@ if (typeof process === 'undefined') {
57
75
  globalThis.EXODUS_TEST_PROCESS = process
58
76
  }
59
77
 
78
+ if (process.env.EXODUS_TEST_PLATFORM === 'hermes') {
79
+ let headerLogged = false
80
+ globalThis.HermesInternal?.enablePromiseRejectionTracker({
81
+ allRejections: true,
82
+ onUnhandled: (i, err) => {
83
+ globalThis.EXODUS_TEST_PROCESS.exitCode = 1
84
+ if (!headerLogged) {
85
+ console.log(`‼ FATAL Tests generated asynchronous activity after they ended.
86
+ This activity created errors and would have caused tests to fail, but instead triggered unhandledRejection events`)
87
+ headerLogged = true
88
+ }
89
+
90
+ console.log(`Uncaught error #${i}: ${err}`)
91
+ globalThis.EXODUS_TEST_PROCESS._maybeProcessExitCode()
92
+ },
93
+ })
94
+ }
95
+
60
96
  if (
61
97
  process.env.EXODUS_TEST_PLATFORM === 'hermes' ||
62
98
  (process.env.EXODUS_TEST_PLATFORM === 'jsc' && !globalThis.clearTimeout)
@@ -0,0 +1,3 @@
1
+ // For node.js Buffers to have the same ref / be fast
2
+ const { Buffer, Blob, File } = globalThis
3
+ module.exports = { Buffer, Blob, File, INSPECT_MAX_BYTES: 50, kMaxLength: 2 ** 31 - 1 }
@@ -1,4 +1,4 @@
1
- const { inspect, isString, isNull, isObject } = require('util/util.js') // dep
1
+ const { inspect, isString, isNull, isObject } = require('util/') // dep
2
2
 
3
3
  // Patched impl from require('util'), added %i
4
4
 
@@ -69,21 +69,21 @@ async function runContext(context) {
69
69
 
70
70
  // TODO: try/catch for hooks?
71
71
  for (const c of stack) for (const hook of c.hooks.beforeEach) await runFunction(hook, context)
72
+ const guard = { id: null, failed: false }
73
+ guard.promise = new Promise((resolve) => {
74
+ guard.id = setTimeout(() => {
75
+ guard.failed = true
76
+ resolve()
77
+ }, options.timeout || 5000)
78
+ })
72
79
  try {
73
- const guard = { id: null, failed: false }
74
- guard.promise = new Promise((resolve) => {
75
- guard.id = setTimeout(() => {
76
- guard.failed = true
77
- resolve()
78
- }, options.timeout || 5000)
79
- })
80
80
  await Promise.race([guard.promise, runFunction(fn, context)])
81
- clearTimeout(guard.id)
82
81
  if (guard.failed) throw new Error('timeout reached')
83
82
  } catch (e) {
84
83
  error = e ?? 'Unknown error'
85
84
  }
86
85
 
86
+ clearTimeout(guard.id)
87
87
  stack.reverse()
88
88
  for (const c of stack) for (const hook of c.hooks.afterEach) await runFunction(hook, context)
89
89
 
@@ -109,17 +109,29 @@ async function run() {
109
109
  assert(context === context.root)
110
110
  await runContext(context).catch((error) => {
111
111
  // Should not throw under regular circumstances
112
- console.log('Fatal: ', error)
112
+ console.log(' FATAL', error)
113
113
  abstractProcess.exitCode = 1
114
114
  })
115
115
  abstractProcess._maybeProcessExitCode?.()
116
116
  }
117
117
 
118
- function describe(...args) {
118
+ async function describe(...args) {
119
119
  const { name, options, fn } = parseArgs(args)
120
120
  enterContext(name, options)
121
121
  context.options = options
122
- if (!options.skip) fn(context) // todo: callback
122
+ // todo: callback support?
123
+ if (!options.skip) {
124
+ try {
125
+ const res = fn(context)
126
+ // we don't need to be async if fn is sync
127
+ if (isPromise(res)) await res
128
+ } catch (error) {
129
+ console.log('✖ FAIL', context.fullName)
130
+ console.log(' describe() body threw an error:', error)
131
+ abstractProcess.exitCode = 1
132
+ }
133
+ }
134
+
123
135
  exitContext()
124
136
  }
125
137
 
@@ -216,7 +228,7 @@ class MockTimers {
216
228
  }
217
229
 
218
230
  runAll() {
219
- this.tick(Math.max(0, ...this.#queue.map((x) => x.at)))
231
+ this.tick(Math.max(0, ...this.#queue.map((x) => x.at - this.#elapsed)))
220
232
  }
221
233
 
222
234
  setTime(milliseconds) {
@@ -224,15 +236,21 @@ class MockTimers {
224
236
  }
225
237
 
226
238
  #setTimeout(fn, delay, ...args) {
227
- this.#queue.push({ type: 'timeout', fn, at: delay + this.#elapsed, args })
239
+ const id = { type: 'timeout', fn, at: delay + this.#elapsed, args }
240
+ this.#queue.push(id)
241
+ return id
228
242
  }
229
243
 
230
244
  #setInterval(fn, delay, ...args) {
231
- this.#queue.push({ type: 'interval', fn, at: delay + this.#elapsed, interval: delay, args })
245
+ const id = { type: 'interval', fn, at: delay + this.#elapsed, interval: delay, args }
246
+ this.#queue.push(id)
247
+ return id
232
248
  }
233
249
 
234
250
  #setImmediate(fn, ...args) {
235
- this.#queue.push({ type: 'immediate', fn, args })
251
+ const id = { type: 'immediate', fn, args }
252
+ this.#queue.push(id)
253
+ return id
236
254
  }
237
255
 
238
256
  #clearTimeout(id) {
@@ -248,50 +266,12 @@ class MockTimers {
248
266
  }
249
267
  }
250
268
 
251
- class MockedFunction extends Function {
252
- get mock() {
253
- return this._mock
254
- }
255
- }
256
-
257
269
  const mock = {
258
270
  module: undefined,
259
271
  timers: new MockTimers(),
260
272
  fn: (original = () => {}, implementation = original) => {
261
273
  let impl = implementation
262
- const mocked = function (...args) {
263
- const call = {
264
- arguments: args,
265
- // eslint-disable-next-line unicorn/error-message
266
- stack: new Error(), // todo: recheck location
267
- target: undefined,
268
- this: this,
269
- }
270
-
271
- mocked.mock.calls.push(call)
272
-
273
- try {
274
- // todo: what's if it a promise
275
- if (this instanceof mocked) {
276
- impl.apply(this, args)
277
- call.result = call.target = this
278
- } else {
279
- call.result = impl.apply(this, args)
280
- }
281
-
282
- call.error = undefined
283
- } catch (err) {
284
- call.result = undefined
285
- call.error = err
286
- throw err
287
- }
288
-
289
- return call.result
290
- }
291
-
292
- Object.setPrototypeOf(mocked, MockedFunction.prototype)
293
-
294
- mocked._mock = {
274
+ const _mock = {
295
275
  calls: [],
296
276
  get callCount() {
297
277
  return this.calls.length
@@ -307,14 +287,64 @@ const mock = {
307
287
  }
308
288
  },
309
289
  resetCalls: () => {
310
- mocked._mock.calls.length = 0
290
+ _mock.calls.length = 0
311
291
  },
312
292
  restore: () => {
313
293
  impl = original
314
294
  },
315
295
  }
316
296
 
317
- return mocked
297
+ return new Proxy(function () {}, {
298
+ __proto__: null,
299
+ apply(fn, _this, args) {
300
+ // eslint-disable-next-line unicorn/error-message
301
+ const call = { arguments: args, stack: new Error(), target: undefined, this: _this } // todo: recheck .stack location
302
+
303
+ try {
304
+ call.result = impl.apply(_this, args)
305
+ call.error = undefined
306
+ } catch (err) {
307
+ call.result = undefined
308
+ call.error = err
309
+ throw err
310
+ } finally {
311
+ _mock.calls.push(call)
312
+ }
313
+
314
+ return call.result
315
+ },
316
+ construct(target, args) {
317
+ // eslint-disable-next-line unicorn/error-message
318
+ const call = { arguments: args, stack: new Error(), target } // todo: recheck .stack location
319
+
320
+ try {
321
+ call.result = call.this = new impl(...args) // eslint-disable-line new-cap
322
+ call.error = undefined
323
+ } catch (err) {
324
+ call.result = undefined
325
+ call.error = err
326
+ throw err
327
+ } finally {
328
+ _mock.calls.push(call)
329
+ }
330
+
331
+ return call.result
332
+ },
333
+ get: (fn, key) => {
334
+ if (key === 'mock') return _mock
335
+ const target = key !== 'prototype' && key in fn ? fn : impl
336
+ return target[key]
337
+ },
338
+ set: (fn, key, value) => {
339
+ const target = key !== 'prototype' && key in fn ? fn : impl
340
+ target[key] = value
341
+ return true
342
+ },
343
+ getOwnPropertyDescriptor(fn, key) {
344
+ const target = key !== 'prototype' && key in fn ? fn : impl
345
+ return Object.getOwnPropertyDescriptor(target, key)
346
+ },
347
+ })
318
348
  },
319
349
  }
320
350
 
@@ -64,11 +64,11 @@ export const jestConfig = () => {
64
64
 
65
65
  export async function loadJestConfig(...args) {
66
66
  let rawConfig
67
- if (process.env.EXODUS_TEST_JEST_CONFIG) {
68
- rawConfig = JSON.parse(process.env.EXODUS_TEST_JEST_CONFIG)
69
- } else {
67
+ if (process.env.EXODUS_TEST_JEST_CONFIG === undefined) {
70
68
  const { readJestConfig } = await import('./jest.config.fs.js')
71
69
  rawConfig = await readJestConfig(...args)
70
+ } else {
71
+ rawConfig = JSON.parse(process.env.EXODUS_TEST_JEST_CONFIG)
72
72
  }
73
73
 
74
74
  config = normalizeJestConfig(rawConfig)
@@ -32,12 +32,16 @@ export const specialEnvironments = {
32
32
  dependencies: ['@pollyjs/core', 'setup-polly-jest', 'setup-polly-jest/lib/common'],
33
33
  setup: async (require, engine) => {
34
34
  const { getTestNamePath } = await import('./dark.cjs')
35
+ // polly has bad defer impl in case if it finds MessageChannel but not process.* (e.g. on deno), forever blocking
36
+ const { MessageChannel } = globalThis
37
+ if (MessageChannel) globalThis.MessageChannel = undefined
35
38
  const { Polly } = require('@pollyjs/core')
36
39
  const pollyJest = require('setup-polly-jest')
37
40
  const {
38
41
  JestPollyGlobals,
39
42
  createPollyContextAccessor,
40
43
  } = require('setup-polly-jest/lib/common')
44
+ if (MessageChannel) globalThis.MessageChannel = MessageChannel
41
45
  const pollyGlobals = new JestPollyGlobals(globalThis)
42
46
  pollyGlobals.isJestPollyEnvironment = true
43
47
  pollyJest.setupPolly = (options) => {
package/src/jest.js CHANGED
@@ -94,7 +94,7 @@ const describe = (...args) => {
94
94
  inDescribe.push(fn)
95
95
  const optionsConcurrent = args?.at(-1)?.concurrency > 1
96
96
  if (optionsConcurrent) inConcurrent.push(fn)
97
- const result = node.describe(...args, async () => {
97
+ const result = node.describe(...args, () => {
98
98
  const res = fn()
99
99
 
100
100
  // We do only block-level concurrency, not file-level
@@ -96,6 +96,20 @@ const snapInline = (obj, inline) => {
96
96
  getAssert().strictEqual(serialize(obj).trim(), inline.trim())
97
97
  }
98
98
 
99
+ const deepMerge = (obj, matcher) => {
100
+ if (!obj || !matcher) return matcher
101
+ const proto = Object.getPrototypeOf(obj)
102
+ if (![Object.prototype, null].includes(proto)) return matcher
103
+ const protoM = Object.getPrototypeOf(matcher)
104
+ if (![Object.prototype, null].includes(protoM)) return matcher
105
+ // all matcher keys should be already in obj, as verified by toMatchObject prior to this
106
+ const map = new Map(Object.entries(matcher))
107
+ const merge = (key, value) => [key, map.has(key) ? deepMerge(value, map.get(key)) : value]
108
+ const res = Object.fromEntries(Object.entries(obj).map(([key, value]) => merge(key, value)))
109
+ Object.setPrototypeOf(res, proto)
110
+ return res
111
+ }
112
+
99
113
  const snapOnDisk = (orig, matcher) => {
100
114
  if (matcher) {
101
115
  expect(orig).toMatchObject(matcher)
@@ -105,7 +119,7 @@ const snapOnDisk = (orig, matcher) => {
105
119
  state.numPassingAsserts--
106
120
  }
107
121
 
108
- const obj = matcher ? { ...orig, ...matcher } : orig
122
+ const obj = matcher ? deepMerge(orig, matcher) : orig
109
123
  const escape = (str) => str.replaceAll(/([\\`])/gu, '\\$1')
110
124
 
111
125
  maybeSetupJestSnapshots()
package/src/tape.cjs ADDED
@@ -0,0 +1,15 @@
1
+ let lib
2
+
3
+ const loadLib = async () => {
4
+ if (!lib) lib = await import('./tape.js')
5
+ return lib
6
+ }
7
+
8
+ /* eslint-disable unicorn/no-await-expression-member */
9
+ const test = async (...args) => (await loadLib()).test(...args)
10
+ test.skip = async (...args) => (await loadLib()).test.skip(...args)
11
+ test.only = async (...args) => (await loadLib()).test.only(...args)
12
+ test.test = test
13
+ /* eslint-enable unicorn/no-await-expression-member */
14
+
15
+ module.exports = test
package/src/tape.js CHANGED
@@ -1,4 +1,4 @@
1
- import { assert, assertLoose, test } from './engine.js'
1
+ import { assert, assertLoose, test as nodeTest } from './engine.js'
2
2
  import { createCallerLocationHook } from './dark.cjs'
3
3
  import './version.js'
4
4
 
@@ -63,7 +63,7 @@ const aliases = {
63
63
  doesNotThrow: ['doesNotThrow'],
64
64
  fail: ['fail'],
65
65
  rejects: ['rejects'],
66
- doesNotReject: ['resolves'],
66
+ doesNotReject: ['doesNotReject', 'resolves'],
67
67
 
68
68
  // specially handled ones as do not exist in t.assert / assert
69
69
  notOk: ['notOk', 'false', 'notok'],
@@ -156,5 +156,5 @@ function tapeWrap(test) {
156
156
  return tap
157
157
  }
158
158
 
159
- export const tape = tapeWrap(test)
160
- export default tape
159
+ export const test = tapeWrap(nodeTest)
160
+ export default test