@exodus/test 1.0.0-rc.69 → 1.0.0-rc.70

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/bin/browsers.js CHANGED
@@ -29,7 +29,7 @@ export const close = () => Promise.all(Object.values(launched).map((p) => p.then
29
29
 
30
30
  async function newPage(runner, browser, { binary, dropNetwork }) {
31
31
  const context = await (browser.newContext ? browser.newContext() : browser.createBrowserContext())
32
- if (dropNetwork && context.setOffline) await context.setOffline(true)
32
+ if (dropNetwork && context.setOffline && binary !== 'webkit') await context.setOffline(true) // WebKit crashes if this is done prior to navigation to /dev/null
33
33
  let page
34
34
  try {
35
35
  page = await context.newPage()
@@ -40,6 +40,9 @@ async function newPage(runner, browser, { binary, dropNetwork }) {
40
40
  return newPage(runner, browser, { binary, dropNetwork })
41
41
  }
42
42
 
43
+ await page.goto('file:///dev/null') // Need to load a secure origin for e.g. crypto.subtle to be available
44
+
45
+ if (dropNetwork && context.setOffline) await context.setOffline(true)
43
46
  if (dropNetwork && page.setOfflineMode) await page.setOfflineMode(true)
44
47
  assert(!dropNetwork || context.setOffline || page.setOfflineMode)
45
48
  return { context, page }
@@ -55,7 +58,6 @@ export async function run(runner, args, { binary, devtools, dropNetwork, timeout
55
58
  assert(Object.hasOwn(launchers, runner), 'Unexpected runner')
56
59
  if (!launched[runner]) launched[runner] = launchers[runner]({ binary, devtools })
57
60
  const { page, context } = await newPage(runner, await launched[runner], { binary, dropNetwork })
58
- await page.goto('file:///dev/null') // Need to load a secure origin for e.g. crypto.subtle to be available
59
61
 
60
62
  page.on('console', (message) => {
61
63
  const type = message.type()
package/bin/index.js CHANGED
@@ -21,28 +21,29 @@ import * as browsers from './browsers.js'
21
21
  const bindir = dirname(fileURLToPath(import.meta.url))
22
22
  const DEFAULT_PATTERNS = [`**/?(*.)+(spec|test).?([cm])[jt]s?(x)`] // do not trust magic dirs by default
23
23
 
24
- const bundleOptions = { pure: true, bundle: true, esbuild: true, ts: 'auto' }
24
+ const bundleOpts = { pure: true, bundle: true, esbuild: true, ts: 'auto' }
25
+ const bareboneOpts = { ...bundleOpts, barebone: true }
25
26
  const hermesAv = ['-Og', '-Xmicrotask-queue']
26
27
  const ENGINES = new Map(
27
28
  Object.entries({
28
29
  'node:test': { binary: 'node', pure: false, hasImportLoader: true, ts: 'flag', haveIsOk: true },
29
30
  'node:pure': { binary: 'node', pure: true, hasImportLoader: true, ts: 'flag', haveIsOk: true },
30
- 'node:bundle': { binary: 'node', ...bundleOptions },
31
+ 'node:bundle': { binary: 'node', ...bundleOpts },
31
32
  'bun:pure': { binary: 'bun', pure: true, hasImportLoader: false, ts: 'auto' },
32
- 'bun:bundle': { binary: 'bun', ...bundleOptions },
33
+ 'bun:bundle': { binary: 'bun', ...bundleOpts },
33
34
  'electron-as-node:test': { binary: 'electron', pure: false, hasImportLoader: true, ts: 'flag' },
34
35
  'electron-as-node:pure': { binary: 'electron', pure: true, hasImportLoader: true, ts: 'flag' },
35
- 'electron-as-node:bundle': { binary: 'electron', ...bundleOptions },
36
- 'deno:bundle': { binary: 'deno', binaryArgs: ['run'], target: 'deno1', ...bundleOptions },
37
- 'd8:bundle': { binary: 'd8', ...bundleOptions },
38
- 'jsc:bundle': { binary: 'jsc', ...bundleOptions, target: 'safari13' },
39
- 'hermes:bundle': { binary: 'hermes', binaryArgs: hermesAv, target: 'es2018', ...bundleOptions },
36
+ 'electron-as-node:bundle': { binary: 'electron', ...bundleOpts },
37
+ 'deno:bundle': { binary: 'deno', binaryArgs: ['run'], target: 'deno1', ...bundleOpts },
38
+ 'd8:bundle': { binary: 'd8', ...bareboneOpts },
39
+ 'jsc:bundle': { binary: 'jsc', target: 'safari13', ...bareboneOpts },
40
+ 'hermes:bundle': { binary: 'hermes', binaryArgs: hermesAv, target: 'es2018', ...bareboneOpts },
40
41
  // Browser engines
41
- 'chrome:puppeteer': { binary: 'chrome', browsers: 'puppeteer', ...bundleOptions },
42
- 'chromium:playwright': { binary: 'chromium', browsers: 'playwright', ...bundleOptions },
43
- 'firefox:puppeteer': { binary: 'firefox', browsers: 'puppeteer', ...bundleOptions },
44
- 'firefox:playwright': { binary: 'firefox', browsers: 'playwright', ...bundleOptions },
45
- 'webkit:playwright': { binary: 'webkit', browsers: 'playwright', ...bundleOptions },
42
+ 'chrome:puppeteer': { binary: 'chrome', browsers: 'puppeteer', ...bundleOpts },
43
+ 'chromium:playwright': { binary: 'chromium', browsers: 'playwright', ...bundleOpts },
44
+ 'firefox:puppeteer': { binary: 'firefox', browsers: 'puppeteer', ...bundleOpts },
45
+ 'firefox:playwright': { binary: 'firefox', browsers: 'playwright', ...bundleOpts },
46
+ 'webkit:playwright': { binary: 'webkit', browsers: 'playwright', ...bundleOpts },
46
47
  })
47
48
  )
48
49
 
@@ -239,6 +240,8 @@ setEnv('EXODUS_TEST_ENGINE', options.engine) // e.g. 'hermes:bundle', 'node:bund
239
240
  setEnv('EXODUS_TEST_PLATFORM', options.binary) // e.g. 'hermes', 'node'
240
241
  setEnv('EXODUS_TEST_TIMEOUT', options.testTimeout)
241
242
  setEnv('EXODUS_TEST_IS_BROWSER', options.browsers ? '1' : '')
243
+ setEnv('EXODUS_TEST_IS_BAREBONE', options.barebone ? '1' : '')
244
+ setEnv('EXODUS_TEST_ENVIRONMENT', options.bundle ? 'bundle' : '') // perhaps switch to _IS_BUNDLED?
242
245
 
243
246
  assert(!options.devtools || options.browsers, '--devtools can be only used with browser engines')
244
247
 
@@ -312,7 +315,9 @@ if (options.jest) {
312
315
  const { loadJestConfig } = await import('../src/jest.config.js')
313
316
  const config = await loadJestConfig(process.cwd())
314
317
  jestConfig = config
315
- if (!options.bundle) {
318
+ if (options.bundle) {
319
+ setEnv('EXODUS_TEST_JEST_CONFIG', JSON.stringify(jestConfig))
320
+ } else {
316
321
  args.push(options.hasImportLoader ? '--import' : '-r', resolve(bindir, 'jest.js'))
317
322
  }
318
323
 
package/bin/reporter.js CHANGED
@@ -27,6 +27,7 @@ export const format = (chunk) => {
27
27
  return chunk
28
28
  .replaceAll(/^✔ PASS /gmu, color('✔ PASS ', 'green'))
29
29
  .replaceAll(/^⏭ SKIP /gmu, color('⏭ SKIP ', dim))
30
+ .replaceAll(/^ℹ DIAGNOSTIC /gmu, color('ℹ DIAGNOSTIC ', 'blue'))
30
31
  .replaceAll(/^✖ FAIL /gmu, color('✖ FAIL ', 'red'))
31
32
  .replaceAll(/^⚠ WARN /gmu, color('⚠ WARN ', 'blue'))
32
33
  .replaceAll(/^‼ FATAL /gmu, `${color('‼', 'red')} ${color(' FATAL ', 'bgRed')} `)
package/bundler/bundle.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import assert from 'node:assert/strict'
2
- import { readFile } from 'node:fs/promises'
2
+ import { readFile, readdir } from 'node:fs/promises'
3
3
  import { existsSync } from 'node:fs'
4
4
  import { fileURLToPath, pathToFileURL } from 'node:url'
5
- import { basename, dirname, extname, resolve, join } from 'node:path'
5
+ import { basename, dirname, extname, resolve, join, relative } from 'node:path'
6
6
  import { createRequire } from 'node:module'
7
7
  import { randomUUID as uuid, randomBytes } from 'node:crypto'
8
8
  import * as esbuild from 'esbuild'
@@ -68,7 +68,6 @@ export const init = async ({ platform, jest, flow, target, jestConfig, outdir, e
68
68
  }
69
69
  }
70
70
 
71
- const barebone = new Set(['hermes', 'jsc', 'd8'])
72
71
  const hermesSupported = {
73
72
  arrow: false,
74
73
  class: false, // we get a safeguard check this way that it's not used
@@ -111,7 +110,11 @@ const loadSourceFileBase = async (filepath) => {
111
110
  }
112
111
 
113
112
  export const build = async (...files) => {
114
- const specificLoadPipeline = []
113
+ const envOverride = { FORCE_COLOR: '0', NO_COLOR: '1' }
114
+ const getEnv = (key) => (Object.hasOwn(envOverride, key) ? envOverride[key] : process.env[key]) // We know key is safe as it comes from regex below
115
+ const specificLoadPipeline = [
116
+ (src) => src.replace(/\b(?:process\.env\.([A-Z0-9_]+))\b/gu, (_, x) => stringify(getEnv(x))),
117
+ ]
115
118
  const loadSourceFile = async (filepath) => {
116
119
  let contents = await loadSourceFileBase(filepath)
117
120
  for (const transform of specificLoadPipeline) contents = await transform(contents, filepath)
@@ -124,7 +127,7 @@ export const build = async (...files) => {
124
127
  const stringify = (x) => ([undefined, null].includes(x) ? `${x}` : JSON.stringify(x))
125
128
 
126
129
  if (!['node', 'electron'].includes(options.platform)) {
127
- if (barebone.has(options.platform)) {
130
+ if (process.env.EXODUS_TEST_IS_BAREBONE) {
128
131
  const entropy = randomBytes(options.entropySize ?? 5 * 1024).toString('base64')
129
132
  input.push(`globalThis.EXODUS_TEST_CRYPTO_ENTROPY = ${stringify(entropy)};`)
130
133
  }
@@ -168,7 +171,7 @@ export const build = async (...files) => {
168
171
  const buildWrap = async (opts) => esbuild.build(opts).catch((err) => err)
169
172
  let main = input.join(';\n')
170
173
  const exit = `EXODUS_TEST_PROCESS.exitCode = 1; EXODUS_TEST_PROCESS._maybeProcessExitCode();`
171
- if (barebone.has(options.platform)) {
174
+ if (process.env.EXODUS_TEST_IS_BAREBONE) {
172
175
  main = `try {\n${main}\n} catch (err) { print(err); ${exit} }`
173
176
  } else if (process.env.EXODUS_TEST_IS_BROWSER) {
174
177
  main = `try {\n${main}\n} catch (err) { console.error(err); ${exit} }`
@@ -176,23 +179,67 @@ export const build = async (...files) => {
176
179
 
177
180
  const fsfiles = await getPackageFiles(filename ? dirname(resolve(filename)) : process.cwd())
178
181
  const fsFilesContents = new Map()
179
- specificLoadPipeline.push(async (source) => {
180
- const cwd = process.cwd()
181
- for (const re of [/readFileSync\('([^'\\]+)'[),]/gu, /readFileSync\("([^"\\]+)"[),]/gu]) {
182
- for (const match of source.matchAll(re)) {
183
- let file = match[1]
184
- if (file && /^[a-z0-9@_./-]+$/iu.test(file)) {
185
- file = resolve(file)
186
- if (!file.startsWith(`${cwd}/`)) continue
187
- const data = await readFile(file, 'base64')
188
- if (fsFilesContents.has(file)) {
189
- assert(fsFilesContents.get(file) === data)
190
- } else {
191
- fsFilesContents.set(file, data)
192
- }
193
- }
182
+ const fsFilesDirs = new Map()
183
+ const cwd = process.cwd()
184
+ const fixturesRegex = /(fixtures|samples)/u
185
+ const aggressiveExtensions = /\.(json|txt|hex)$/u // These are bundled when just used in path.join and by wildcard from fixtures/
186
+ const fileAllowed = (f) =>
187
+ f && f.startsWith(`${cwd}/`) && resolve(f) === f && /^[a-z0-9@_./-]+$/iu.test(relative(cwd, f))
188
+
189
+ const fsFilesAdd = async (file) => {
190
+ if (!fileAllowed(file)) return
191
+ try {
192
+ const data = await readFile(file, 'base64')
193
+ if (fsFilesContents.has(file)) {
194
+ assert(fsFilesContents.get(file) === data)
195
+ } else {
196
+ fsFilesContents.set(file, data)
194
197
  }
198
+ } catch (e) {
199
+ if (e.code !== 'ENOENT') throw e
195
200
  }
201
+ }
202
+
203
+ const fixturesSeen = { fs: false, fixtures: false, bundled: false }
204
+ const fsFilesBundleFixtures = async (reason) => {
205
+ if (fixturesSeen.bundled || !filename) return
206
+ if (reason === 'fs' || reason === 'fixtures') fixturesSeen[reason] = true
207
+ if (!fixturesSeen.fs || !fixturesSeen.fixtures) return
208
+ fixturesSeen.bundled = true
209
+ const dir = dirname(resolve(filename))
210
+ for (const name of await readdir(dir, { recursive: true })) {
211
+ const parent = dirname(name)
212
+ if (!fixturesRegex.test(parent)) continue // relative dir path should look like a fixtures dir
213
+
214
+ // Save as directory entry into parent dir
215
+ const subdir = resolve(dir, parent)
216
+ if (fileAllowed(subdir)) {
217
+ if (!fsFilesDirs.has(subdir)) fsFilesDirs.set(subdir, [])
218
+ fsFilesDirs.get(subdir).push(basename(name))
219
+ }
220
+
221
+ // Save to files
222
+ const file = resolve(dir, name)
223
+ if (aggressiveExtensions.test(file)) await fsFilesAdd(file)
224
+ }
225
+ }
226
+
227
+ specificLoadPipeline.push(async (source, filepath) => {
228
+ for (const m of source.matchAll(/readFileSync\(\s*(?:"([^"\\]+)"|'([^'\\]+)')[),]/gu)) {
229
+ await fsFilesAdd(resolve(m[1] || m[2])) // resolves from cwd
230
+ }
231
+
232
+ // E.g. path.join(import.meta.dirname, './fixtures/data.json'), dirname is inlined by loadPipeline already
233
+ const dir = dirname(filepath)
234
+ for (const m of source.matchAll(/join\(\s*("[^"\\]+"),\s*(?:"([^"\\]+)"|'([^'\\]+)')\s*\)/gu)) {
235
+ if (m[1] !== JSON.stringify(dir)) continue // only allow files relative to dirname, from loadPipeline
236
+ const file = resolve(dir, m[2] || m[3])
237
+ if (aggressiveExtensions.test(file)) await fsFilesAdd(file) // only bundle path.join for specific extensions used as test fixtures
238
+ }
239
+
240
+ // Both conditions should happen for deep fixtures inclusion
241
+ if (/(readdir|readFile|exists)Sync/u.test(source)) await fsFilesBundleFixtures('fs')
242
+ if (fixturesRegex.test(source)) await fsFilesBundleFixtures('fixtures')
196
243
 
197
244
  return source
198
245
  })
@@ -219,11 +266,12 @@ export const build = async (...files) => {
219
266
  'fs/promises': api('fs-promises.cjs'),
220
267
  http: api('http.cjs'),
221
268
  https: api('https.cjs'),
222
- os: resolveRequire('os-browserify'),
269
+ os: resolveRequire('os-browserify/browser.js'), // 'main' entry point is noop, we want browser entry
223
270
  path: resolveRequire('path-browserify'),
224
271
  querystring: resolveRequire('querystring-es3'),
225
272
  stream: resolveRequire('stream-browserify'),
226
273
  timers: resolveRequire('timers-browserify'),
274
+ tty: api('tty.cjs'),
227
275
  url: api('url.cjs'),
228
276
  util: dirname(resolveRequire('util/')),
229
277
  zlib: resolveRequire('browserify-zlib'),
@@ -241,23 +289,6 @@ export const build = async (...files) => {
241
289
  platform: 'neutral',
242
290
  mainFields: ['browser', 'module', 'main'],
243
291
  define: {
244
- 'process.env.FORCE_COLOR': stringify('0'),
245
- 'process.env.NO_COLOR': stringify('1'),
246
- 'process.env.NODE_ENV': stringify(process.env.NODE_ENV),
247
- 'process.env.EXODUS_TEST_CONTEXT': stringify('pure'),
248
- 'process.env.EXODUS_TEST_ENVIRONMENT': stringify('bundle'), // always 'bundle'
249
- 'process.env.EXODUS_TEST_PLATFORM': stringify(process.env.EXODUS_TEST_PLATFORM), // e.g. 'hermes', 'node'
250
- 'process.env.EXODUS_TEST_ENGINE': stringify(process.env.EXODUS_TEST_ENGINE), // e.g. 'hermes:bundle', 'node:bundle'
251
- 'process.env.EXODUS_TEST_IS_BROWSER': stringify(process.env.EXODUS_TEST_IS_BROWSER), // '1' or ''
252
- 'process.env.EXODUS_TEST_JEST_CONFIG': stringify(JSON.stringify(options.jestConfig)),
253
- 'process.env.EXODUS_TEST_EXECARGV': stringify(process.env.EXODUS_TEST_EXECARGV),
254
- 'process.env.EXODUS_TEST_ONLY': stringify(process.env.EXODUS_TEST_ONLY),
255
- 'process.env.EXODUS_TEST_TIMEOUT': stringify(process.env.EXODUS_TEST_TIMEOUT),
256
- 'process.env.NODE_DEBUG': stringify(),
257
- 'process.env.DEBUG': stringify(),
258
- 'process.env.READABLE_STREAM': stringify(),
259
- 'process.env.CI': stringify(process.env.CI),
260
- 'process.env.CI_ENABLE_VERBOSE_LOGS': stringify(process.env.CI_ENABLE_VERBOSE_LOGS),
261
292
  'process.browser': stringify(true),
262
293
  'process.emitWarning': 'undefined',
263
294
  'process.stderr': 'undefined',
@@ -271,6 +302,7 @@ export const build = async (...files) => {
271
302
  EXODUS_TEST_RECORDINGS: stringify(EXODUS_TEST_RECORDINGS),
272
303
  EXODUS_TEST_FSFILES: stringify(fsfiles), // TODO: can we safely use relative paths?
273
304
  EXODUS_TEST_FSFILES_CONTENTS: stringify([...fsFilesContents.entries()]),
305
+ EXODUS_TEST_FSDIRS: stringify([...fsFilesDirs.entries()]),
274
306
  },
275
307
  alias: {
276
308
  // Jest, tape and node:test
@@ -295,7 +327,7 @@ export const build = async (...files) => {
295
327
  'node-gyp-build': api('empty/function-throw.cjs'),
296
328
  ws: api('ws.cjs'),
297
329
  },
298
- sourcemap: barebone.has(options.platform) ? 'inline' : 'linked', // FIXME?
330
+ sourcemap: process.env.EXODUS_TEST_IS_BAREBONE ? 'inline' : 'linked', // FIXME?
299
331
  sourcesContent: false,
300
332
  keepNames: true,
301
333
  format: 'iife',
@@ -311,9 +343,8 @@ export const build = async (...files) => {
311
343
  onLoad({ filter: /\.[cm]?[jt]sx?$/, namespace: 'file' }, async (args) => {
312
344
  let filepath = args.path
313
345
  // Resolve .native versions
314
- // TODO: move flag to engine options
315
346
  // TODO: maybe follow package.json for this
316
- if (barebone.has(options.platform)) {
347
+ if (process.env.EXODUS_TEST_IS_BAREBONE) {
317
348
  const maybeNative = filepath.replace(/(\.[cm]?[jt]sx?)$/u, '.native$1')
318
349
  if (existsSync(maybeNative)) filepath = maybeNative
319
350
  }
@@ -340,9 +371,10 @@ export const build = async (...files) => {
340
371
  let res = await buildWrap(config)
341
372
  assert.equal(res instanceof Error, res.errors.length > 0)
342
373
 
343
- if (fsFilesContents.size > 0) {
374
+ if (fsFilesContents.size > 0 || fsFilesDirs.size > 0) {
344
375
  // re-run as we detected that tests depend on fsReadFileSync contents
345
376
  config.define.EXODUS_TEST_FSFILES_CONTENTS = stringify([...fsFilesContents.entries()])
377
+ config.define.EXODUS_TEST_FSDIRS = stringify([...fsFilesDirs.entries()])
346
378
  res = await buildWrap(config)
347
379
  assert.equal(res instanceof Error, res.errors.length > 0)
348
380
  }
@@ -75,38 +75,49 @@ const stubs = Object.fromEntries(mainKeys.map((key) => [key, () => err(key)]))
75
75
  const stubsPromises = Object.fromEntries(promisesKeys.map((key) => [key, async () => err(key)]))
76
76
  const promises = { ...stubsPromises, constants }
77
77
 
78
- // eslint-disable-next-line no-undef
79
- const fsFiles = typeof EXODUS_TEST_FSFILES === 'undefined' ? null : new Set(EXODUS_TEST_FSFILES)
80
- const existsSync = (file) => {
81
- if (fsFiles?.has(file)) return true
82
- err('existsSync', file)
78
+ const decode = (source, sourceEncoding, encoding) => {
79
+ if (encoding && sourceEncoding === encoding) return source
80
+ const data = Buffer.from(source, sourceEncoding)
81
+ return encoding === undefined ? data : data.toString(encoding)
82
+ }
83
+
84
+ const getOptions = (arg, options) => {
85
+ if (typeof arg !== 'string') throw new Error('first argument should be string')
86
+ const file = resolve(process.cwd(), arg)
87
+ if (typeof options === 'string') return { file, encoding: options, rest: {} }
88
+ if (options === undefined) return { file, rest: {} }
89
+ if (typeof options !== 'object') throw new Error('Unexpected options')
90
+ const { encoding: enc, ...rest } = options
91
+ if (enc !== undefined && typeof enc !== 'string') throw new Error('encoding should be a string')
92
+ return { file, encoding: enc, rest }
83
93
  }
84
94
 
85
95
  const fsFilesContents =
86
96
  // eslint-disable-next-line no-undef
87
97
  typeof EXODUS_TEST_FSFILES_CONTENTS === 'undefined' ? null : new Map(EXODUS_TEST_FSFILES_CONTENTS)
88
- const readFileSync = (file, options) => {
89
- let encoding
90
- if (typeof options === 'string') {
91
- encoding = options
92
- } else if (options !== undefined) {
93
- if (typeof options !== 'object') throw new Error('Unexpected readFileSync options')
94
- const { encoding: enc, ...rest } = options
95
- if (enc !== undefined && typeof enc !== 'string') throw new Error('encoding should be a string')
96
- encoding = enc
97
- if (Object.keys(rest).length > 0) throw new Error('Unsupported readFileSync options')
98
- }
98
+ const readFileSync = (arg, options) => {
99
+ const { file, encoding, rest } = getOptions(arg, options)
100
+ if (Object.keys(rest).length > 0) throw new Error('Unsupported readFileSync options')
101
+ if (fsFilesContents?.has(file)) return decode(fsFilesContents.get(file), 'base64', encoding)
102
+ err('readFileSync', file)
103
+ }
99
104
 
100
- if (typeof file !== 'string') throw new Error('file argument should be string')
101
- file = resolve(process.cwd(), file)
102
- if (fsFilesContents?.has(file)) {
103
- const data = Buffer.from(fsFilesContents.get(file), 'base64')
104
- if (encoding?.toLowerCase().replace('-', '') === 'utf8') return data.toString('utf8')
105
- if (encoding === undefined) return data
106
- throw new Error('Unsupported encoding')
107
- }
105
+ // eslint-disable-next-line no-undef
106
+ const fsDir = typeof EXODUS_TEST_FSDIRS === 'undefined' ? null : new Map(EXODUS_TEST_FSDIRS)
107
+ const readdirSync = (arg, options) => {
108
+ const { file: dir, encoding, rest } = getOptions(arg, options)
109
+ if (Object.keys(rest).length > 0) throw new Error('Unsupported readdirSync options')
110
+ const enc = encoding === 'buffer' ? undefined : encoding || 'utf8'
111
+ if (fsDir?.has(dir)) return fsDir.get(dir).map((name) => decode(name, 'utf8', enc))
112
+ err('readdirSync', dir)
113
+ }
108
114
 
109
- err('readFileSync', file)
115
+ // eslint-disable-next-line no-undef
116
+ const fsFiles = typeof EXODUS_TEST_FSFILES === 'undefined' ? null : new Set(EXODUS_TEST_FSFILES)
117
+ const existsSync = (file) => {
118
+ if (fsFiles?.has(file) || fsFilesContents?.has(file) || fsDir?.has(file)) return true
119
+ err('existsSync', file)
110
120
  }
111
121
 
112
- module.exports = { ...stubs, existsSync, readFileSync, promises, constants, F_OK, R_OK, W_OK, X_OK }
122
+ const implemented = { existsSync, readFileSync, readdirSync }
123
+ module.exports = { ...stubs, ...implemented, promises, constants, F_OK, R_OK, W_OK, X_OK }
@@ -3,22 +3,39 @@
3
3
 
4
4
  if (!globalThis.global) globalThis.global = globalThis
5
5
  if (!globalThis.Buffer) globalThis.Buffer = require('buffer').Buffer
6
- if (!globalThis.console) {
7
- // eslint-disable-next-line no-undef
8
- globalThis.console = { log: print, error: print, warn: print, info: print, debug: print }
9
- }
10
6
 
11
- // Otherwise e.g. errors (and some other objects) are hard to unwrap via the API
7
+ const consoleKeys = ['log', 'error', 'warn', 'info', 'debug', 'trace']
8
+ if (!globalThis.console) globalThis.console = Object.fromEntries(consoleKeys.map((k) => [k, print])) // eslint-disable-line no-undef
9
+
10
+ // In browsers e.g. errors (and some other objects) are hard to unwrap via the API
12
11
  // So we just stringify everything instead on the sender side
13
- if (process.env.EXODUS_TEST_IS_BROWSER) {
12
+ // In barebone, we don't want console.log({x:10}) to print "[Object object]"", we want "{ x: 10 }"
13
+ if (process.env.EXODUS_TEST_IS_BROWSER || process.env.EXODUS_TEST_IS_BAREBONE) {
14
14
  const utilFormat = require('exodus-test:util-format')
15
- for (const type of ['log', 'error', 'warn', 'info', 'debug']) {
15
+ for (const type of consoleKeys) {
16
16
  if (!Object.hasOwn(console, type)) continue
17
17
  const orig = console[type].bind(console)
18
18
  console[type] = (...args) => orig(utilFormat(...args))
19
19
  }
20
20
  }
21
21
 
22
+ if (!console.time || !console.timeEnd) {
23
+ const start = new Map()
24
+ const now = globalThis.performance?.now ? performance.now.bind(performance) : Date.now.bind(Date) // d8 and jsc have performance.now()
25
+ const warn = (text) => console.error(`Warning: ${text}`)
26
+ console.time = (key = 'default') => {
27
+ if (start.has(key)) return warn(`Label '${key}' already exists for console.time()`) // Does not reset
28
+ start.set(key, now()) // Start late
29
+ }
30
+
31
+ console.timeEnd = (key = 'default') => {
32
+ const ms = now() // End early
33
+ if (!start.has(key)) return warn(`No such label '${key}' for console.timeEnd()`)
34
+ console.log(`${key}: ${ms - start.get(key)}ms`)
35
+ start.delete(key)
36
+ }
37
+ }
38
+
22
39
  if (!globalThis.fetch) {
23
40
  globalThis.fetch = () => {
24
41
  throw new Error('Fetch not supported')
@@ -116,40 +133,53 @@ This activity created errors and would have caused tests to fail, but instead tr
116
133
 
117
134
  if (
118
135
  process.env.EXODUS_TEST_PLATFORM === 'hermes' ||
119
- (process.env.EXODUS_TEST_PLATFORM === 'jsc' && !globalThis.clearTimeout) ||
120
- (process.env.EXODUS_TEST_PLATFORM === 'd8' && !globalThis.clearTimeout)
136
+ (process.env.EXODUS_TEST_IS_BAREBONE && !globalThis.clearTimeout)
121
137
  ) {
122
138
  // Ok, we have broken timers, let's hack them around
123
139
  let i = 0
124
140
  const timers = new Map()
125
- const { setTimeout, clearTimeout } = globalThis
126
- const dateNow = Date.now
127
- const precision = clearTimeout ? Infinity : 10 // have to tick this fast for clearTimeout to work
141
+ const repeating = new Set()
142
+ const { setTimeout: setTimeoutOriginal, clearTimeout: clearTimeoutOriginal } = globalThis
143
+ const dateNow = Date.now.bind(Date)
144
+ const precision = clearTimeoutOriginal ? Infinity : 10 // have to tick this fast for clearTimeout to work
128
145
 
129
- globalThis.setTimeout = (fn, time, ...args) => {
146
+ const setTimeout = (fn, time, ...args) => {
130
147
  const id = `ht${i++}`
131
- const now = dateNow()
148
+ let started = dateNow()
132
149
  const tick = () => {
133
150
  if (!timers.has(id)) return
134
- const remaining = now + time - dateNow()
151
+ const remaining = started + time - dateNow()
135
152
  if (remaining < 0) {
136
- timers.delete(id)
153
+ if (repeating.has(id)) {
154
+ started = dateNow()
155
+ timers.set(id, setTimeoutOriginal(tick, Math.min(precision, time)))
156
+ } else {
157
+ timers.delete(id)
158
+ }
159
+
137
160
  fn(...args)
138
161
  } else {
139
- timers.set(id, setTimeout(tick, Math.min(precision, remaining)))
162
+ timers.set(id, setTimeoutOriginal(tick, Math.min(precision, remaining)))
140
163
  }
141
164
  }
142
165
 
143
- timers.set(id, setTimeout(tick, Math.min(precision, time)))
166
+ timers.set(id, setTimeoutOriginal(tick, Math.min(precision, time)))
167
+ return id
168
+ }
169
+
170
+ globalThis.setTimeout = setTimeout
171
+ globalThis.setInterval = (fn, time, ...args) => {
172
+ const id = setTimeout(fn, time, ...args)
173
+ repeating.add(id)
144
174
  return id
145
175
  }
146
176
 
147
- globalThis.clearTimeout = (id) => {
177
+ globalThis.clearTimeout = globalThis.clearInterval = (id) => {
148
178
  if (!timers.has(id)) return
149
- clearTimeout?.(timers.get(id))
179
+ clearTimeoutOriginal?.(timers.get(id))
150
180
  timers.delete(id)
181
+ repeating.delete(id)
151
182
  }
152
- // TODO: setInterval, clearInterval
153
183
  }
154
184
 
155
185
  if (!globalThis.crypto?.getRandomValues && globalThis.EXODUS_TEST_CRYPTO_ENTROPY) {
@@ -193,11 +223,7 @@ if (globalThis.crypto?.getRandomValues && !globalThis.crypto?.randomUUID) {
193
223
 
194
224
  if (!globalThis.crypto.subtle) globalThis.crypto.subtle = {} // For getRandomValues detection
195
225
 
196
- if (
197
- process.env.EXODUS_TEST_PLATFORM === 'hermes' ||
198
- process.env.EXODUS_TEST_PLATFORM === 'jsc' ||
199
- process.env.EXODUS_TEST_PLATFORM === 'd8'
200
- ) {
226
+ if (process.env.EXODUS_TEST_IS_BAREBONE) {
201
227
  if (!globalThis.URLSearchParams) globalThis.URLSearchParams = require('@ungap/url-search-params')
202
228
  if (!globalThis.TextEncoder || !globalThis.TextDecoder) {
203
229
  const { TextEncoder, TextDecoder } = require('exodus-test:text-encoding-utf')
@@ -56,8 +56,7 @@ function TextDecoder(encoding = UTF8, options = {}) {
56
56
  encoding = normalizeEncoding(encoding)
57
57
  assertUTF8orUTF16LE(encoding)
58
58
 
59
- const { fatal = true, ignoreBOM = false, stream = false } = options
60
- if (fatal !== true) throw new Error('disabling "fatal" mode is not supported')
59
+ const { fatal = false, ignoreBOM = false, stream = false } = options
61
60
  if (ignoreBOM !== false) throw new Error('option "ignoreBOM" is not supported')
62
61
  if (stream !== false) throw new Error('option "stream" is not supported')
63
62
 
@@ -67,11 +66,25 @@ function TextDecoder(encoding = UTF8, options = {}) {
67
66
  defineFinal(this, 'ignoreBOM', ignoreBOM)
68
67
  }
69
68
 
69
+ // Note: https://npmjs.com/package/buffer has a bug
70
+ // Buffer.from([0xf0, 0x90, 0x80]).toString().length should be 1, but it is 3 in https://npmjs.com/package/buffer
71
+ // Buffer.from([0xf0, 0x80, 0x80]).toString().length should be 3, see https://github.com/nodejs/node/issues/16894
70
72
  TextDecoder.prototype.decode = function (buf) {
71
73
  if (buf === undefined) return ''
72
74
  assertBufferSource(buf)
73
75
  if (!Buffer.isBuffer(buf)) buf = Buffer.from(buf)
74
- return buf.toString(this.encoding)
76
+ const res = buf.toString(this.encoding)
77
+ if (this.fatal && res.includes('\uFFFD')) {
78
+ // We have a replacement symbol, recheck if output matches input
79
+ const reconstructed = Buffer.from(res, this.encoding)
80
+ if (Buffer.compare(buf, reconstructed) !== 0) {
81
+ const err = new TypeError('The encoded data was not valid for encoding utf-8')
82
+ err.code = 'ERR_ENCODING_INVALID_ENCODED_DATA'
83
+ throw err
84
+ }
85
+ }
86
+
87
+ return res
75
88
  }
76
89
 
77
90
  module.exports = { TextEncoder, TextDecoder }
@@ -0,0 +1,10 @@
1
+ module.exports = {
2
+ isatty: () => false,
3
+ // Not arrows as those are classes and can be called with new
4
+ ReadStream() {
5
+ throw new Error('tty.ReadStream unsupported in bundled mode')
6
+ },
7
+ WriteStream() {
8
+ throw new Error('tty.WriteStream unsupported in bundled mode')
9
+ },
10
+ }
@@ -1,7 +1,12 @@
1
1
  const { inspect: inspectOrig, isString, isNull, isObject } = require('util/') // dep
2
2
 
3
3
  // Print errors without square brackets
4
- const inspect = (obj, opts) => (obj instanceof Error ? String(obj) : inspectOrig(obj, opts))
4
+ const trim = (x) => x.trim()
5
+ const validLine = (x) => x && x !== '@'
6
+ const padLine = (line) => ` ${line}`
7
+ const pad = (stack) => stack.split('\n').map(trim).filter(validLine).map(padLine).join('\n')
8
+ const errorStr = (e) => (e.stack.startsWith(`${e}\n`) ? e.stack : `${e}\n${pad(e.stack)}`.trimEnd())
9
+ const inspect = (obj, opts) => (obj instanceof Error ? errorStr(obj) : inspectOrig(obj, opts))
5
10
 
6
11
  // Patched impl from require('util'), added %i
7
12
  const formatRegExp = /%[%dijs]/g
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/test",
3
- "version": "1.0.0-rc.69",
3
+ "version": "1.0.0-rc.70",
4
4
  "author": "Exodus Movement, Inc.",
5
5
  "description": "A test suite runner",
6
6
  "homepage": "https://github.com/ExodusMovement/test",
@@ -60,6 +60,7 @@
60
60
  "bundler/modules/jest-util.js",
61
61
  "bundler/modules/node-buffer.cjs",
62
62
  "bundler/modules/text-encoding-utf.cjs",
63
+ "bundler/modules/tty.cjs",
63
64
  "bundler/modules/url.cjs",
64
65
  "bundler/modules/util-format.cjs",
65
66
  "bundler/modules/ws.cjs",
@@ -130,7 +131,7 @@
130
131
  "@babel/plugin-transform-private-methods": "^7.0.0",
131
132
  "@babel/register": "^7.0.0",
132
133
  "@chalker/queue": "^1.0.0",
133
- "@exodus/replay": "^1.0.0-rc.4",
134
+ "@exodus/replay": "^1.0.0-rc.6",
134
135
  "@ungap/url-search-params": "^0.2.2",
135
136
  "amaro": "^0.0.5",
136
137
  "assert": "^2.1.0",
@@ -148,7 +149,7 @@
148
149
  "jsdom": "^24.1.0",
149
150
  "os-browserify": "^0.3.0",
150
151
  "path-browserify": "^1.0.1",
151
- "playwright-core": "^1.51.1",
152
+ "playwright-core": "^1.52.0",
152
153
  "pretty-format": "^29.7.0",
153
154
  "puppeteer-core": "^24.6.0",
154
155
  "querystring-es3": "^0.2.1",
@@ -84,6 +84,10 @@ class Context {
84
84
  if (!this.#hooks?.[type]) return
85
85
  for (const hook of this.#hooks[type]) await runFunction(hook, context)
86
86
  }
87
+
88
+ diagnostic(message) {
89
+ console.log(`ℹ DIAGNOSTIC ${message}`)
90
+ }
87
91
  }
88
92
 
89
93
  function enterContext(name, options) {
package/src/jest.js CHANGED
@@ -231,7 +231,9 @@ export const jest = {
231
231
  fetchRecord,
232
232
  fetchReplay,
233
233
  fetchNoop: () => {
234
- globalThis.fetch = jest.fn()
234
+ // We can't use pure noop, it will break chained fetch().then(), so let's reject
235
+ const fetch = () => Promise.reject(new Error('fetch is disabled by mock.fetchNoop()'))
236
+ globalThis.fetch = jest.fn(fetch)
235
237
  return globalThis.fetch
236
238
  },
237
239
  websocketRecord,