@exodus/test-bundler
Version:
Test bundler for @exodus/test for barebone and browser engines
384 lines (331 loc) • 14 kB
JavaScript
// We expect bundler to optimize out EXODUS_TEST_PLATFORM blocks
/* eslint-disable sonarjs/no-collapsible-if, unicorn/no-lonely-if */
if (!globalThis.global) globalThis.global = globalThis
if (!globalThis.Buffer) globalThis.Buffer = require('buffer').Buffer
const consoleKeys = ['log', 'error', 'warn', 'info', 'debug', 'trace']
const print = globalThis.print ?? globalThis.console.log.bind(globalThis.console)
if (process.env.EXODUS_TEST_IS_BAREBONE && !globalThis.print) globalThis.print = print // bundler expects barebones to have print()
if (process.env.EXODUS_TEST_PLATFORM === 'engine262') delete globalThis.console // prints [object Object] on everything
if (!globalThis.console) globalThis.console = Object.fromEntries(consoleKeys.map((k) => [k, print])) // eslint-disable-line no-undef
for (const k of consoleKeys) if (!console[k]) console[k] = console.log // SpiderMonkey has console but no console.error
// In browsers e.g. errors (and some other objects) are hard to unwrap via the API
// So we just stringify everything instead on the sender side
// In barebone, we don't want console.log({x:10}) to print "[Object object]"", we want "{ x: 10 }"
if (process.env.EXODUS_TEST_IS_BROWSER || process.env.EXODUS_TEST_IS_BAREBONE) {
const utilFormat = require('exodus-test:util-format')
if (globalThis.print) globalThis.print = (...args) => print(utilFormat(...args))
for (const type of consoleKeys) {
if (!Object.hasOwn(console, type)) continue
const orig = console[type].bind(console)
console[type] = (...args) => {
try {
orig(utilFormat(...args))
} catch {
orig(...args) // fallback if format fails
}
}
}
}
if (!console.time || !console.timeEnd) {
const start = new Map()
const now = globalThis.performance?.now ? performance.now.bind(performance) : Date.now.bind(Date) // d8 and jsc have performance.now()
const warn = (text) => console.error(`Warning: ${text}`)
console.time = (key = 'default') => {
if (start.has(key)) return warn(`Label '${key}' already exists for console.time()`) // Does not reset
start.set(key, now()) // Start late
}
console.timeEnd = (key = 'default') => {
const ms = now() // End early
if (!start.has(key)) return warn(`No such label '${key}' for console.timeEnd()`)
console.log(`${key}: ${ms - start.get(key)}ms`)
start.delete(key)
}
}
if (!globalThis.fetch) {
globalThis.fetch = () => {
throw new Error('Fetch not supported')
}
}
if (!globalThis.WebSocket) {
globalThis.WebSocket = () => {
throw new Error('WebSocket not supported')
}
}
if (!Array.prototype.at) {
const at = function (i) {
return this[i < 0 ? this.length + i : i]
}
// eslint-disable-next-line no-extend-native
Object.defineProperty(Array.prototype, 'at', { configurable: true, writable: true, value: at })
}
if (process.env.EXODUS_TEST_IS_BAREBONE) {
// Refuse to run if block scoped vars are fake
const r = []
for (let i = 0; i < 2; i++) r.push(() => i)
if (r[0]() === r[1]()) {
print('‼ FATAL Fake block-scoped vars support detected')
throw new Error('Refusing to run')
}
}
if (process.env.EXODUS_TEST_PLATFORM === 'hermes') {
// Fixed after 0.12, not present in 0.12
// Refs: https://github.com/facebook/hermes/commit/e8fa81328dd630e39975e6d16ac3e6f47f4cba06
if (!Promise.allSettled) {
const wrap = (element) =>
Promise.resolve(element).then(
(value) => ({ status: 'fulfilled', value }),
(reason) => ({ status: 'rejected', reason })
)
Promise.allSettled = (iterable) => Promise.all([...iterable].map((element) => wrap(element)))
}
// Refs: https://github.com/facebook/hermes/commit/e97db61b49bd0c065a3ce7da46f074bc39b80c6a
if (!Promise.any) {
const AggregateError =
globalThis.AggregateError ||
class AggregateError extends Error {
constructor(errors, message) {
super(message)
this.name = 'AggregateError'
this.errors = errors
}
}
const errmsg = 'All promises were rejected'
Promise.any = function (values) {
const promises = [...values]
const errors = []
if (promises.length === 0) return Promise.reject(new AggregateError(errors, errmsg))
let resolved = false
return new Promise((resolve, reject) => {
const oneResolve = (value) => {
if (resolved) return
resolved = true
errors.length = 0
resolve(value)
}
const oneReject = (error) => {
if (resolved) return
errors.push(error)
if (errors.length === promises.length) reject(new AggregateError(errors, errmsg))
}
promises.forEach((promise) => Promise.resolve(promise).then(oneResolve, oneReject))
})
}
}
}
if (process.env.EXODUS_TEST_PLATFORM === 'quickjs' && globalThis.os) {
const { setTimeout, setInterval, clearTimeout, clearInterval } = globalThis.os
Object.assign(globalThis, { setTimeout, setInterval, clearTimeout, clearInterval })
for (const key of ['os', 'std', 'bjson']) delete globalThis[key]
}
if (globalThis.describe) delete globalThis.describe
if (process.env.EXODUS_TEST_PLATFORM === 'boa') {
// boa timers do not work in CLI
delete globalThis.setTimeout
delete globalThis.clearTimeout
// If .stack is undefined, jest-when and util-format fail
if (new Error('-').stack === undefined) Error.prototype.stack = '' // eslint-disable-line no-extend-native
}
if (
process.env.EXODUS_TEST_PLATFORM === 'hermes' ||
(process.env.EXODUS_TEST_IS_BAREBONE && !globalThis.clearTimeout)
) {
// Ok, we have broken timers, let's hack them around
const { setTimeout: setTimeoutOriginal, clearTimeout: clearTimeoutOriginal } = globalThis
const tickTimes = async (n) => {
if (process.env.EXODUS_TEST_PLATFORM === 'escargot') {
// escargot is _special_ (slow on await, unless we drain manually)
let promise = Promise.resolve()
for (let i = 0; i < n; i++) promise = promise.then(() => {})
globalThis.drainJobQueue()
await promise
} else {
const promise = Promise.resolve() // tickTimes(0) is equivalent to one Promise.resolve() as it's async
for (let i = 0; i < n; i++) await promise
}
}
// TODO: use interrupt timers on jsc
const tickPromiseInterval = process.env.EXODUS_TEST_PLATFORM === 'engine262' ? 5 : 50 // engine262 is slow
const schedule = setTimeoutOriginal || ((x) => tickTimes(tickPromiseInterval).then(() => x())) // e.g. SpiderMonkey doesn't even have setTimeout
const dateNow = Date.now.bind(Date)
const precision = clearTimeoutOriginal ? Infinity : 10 // have to tick this fast for clearTimeout to work
let current = 0
let loopTimeout
let publicId = 0
const timerMap = new Map()
let queue = []
const stopLoop = () => {
clearTimeoutOriginal?.(loopTimeout)
current++
}
const restartLoop = () => {
if (loopTimeout !== undefined) clearTimeoutOriginal?.(loopTimeout) // hermes clearTimeout doesn't follow spec on undefined
const at = queue[0].runAt
const id = ++current
const tick = () => {
if (id !== current) return
const remaining = at - dateNow()
if (remaining <= 0) return queueTick()
loopTimeout = schedule(tick, Math.min(precision, remaining))
}
loopTimeout = schedule(tick, Math.min(precision, at - dateNow()))
}
const queueSchedule = (entry) => {
if (!entry.publicId) entry.publicId = ++publicId // eslint-disable-line @exodus/mutable/no-param-reassign-prop-only
timerMap.set(entry.publicId, entry)
const before = queue.findIndex((x) => x.runAt > entry.runAt)
if (before === -1) {
queue.push(entry)
} else {
queue.splice(before, 0, entry)
}
if (entry === queue[0]) restartLoop()
return entry.publicId
}
const queueMicrotick = () => {
if (queue.length === 0 || !(queue[0].runAt <= dateNow())) return null
const next = queue.shift()
if (next.interval === undefined) {
timerMap.delete(next.publicId)
} else {
next.runAt += next.interval
queueSchedule(next)
}
next.callback(...next.args)
}
const queueTick = () => {
current++ // safeguard
while (queueMicrotick() !== null);
if (queue.length > 0) restartLoop()
}
globalThis.setTimeout = (callback, delay = 0, ...args) =>
queueSchedule({ callback, runAt: delay + dateNow(), args })
globalThis.setInterval = (callback, delay = 0, ...args) =>
queueSchedule({ callback, runAt: delay + dateNow(), interval: delay, args })
globalThis.clearTimeout = globalThis.clearInterval = (id) => {
const entry = timerMap.get(id)
if (!entry) return
timerMap.delete(id)
queue = queue.filter((x) => x !== entry)
if (queue.length === 0) stopLoop()
}
}
const { setTimeout } = globalThis // we need non-overriden by fake timers one
const isBarebone = process.env.EXODUS_TEST_IS_BAREBONE
if (typeof process === 'undefined') {
// Fixes process.exitCode handling
const process = {
__proto__: null,
_exitCode: 0,
set exitCode(value) {
process._exitCode = value
if (globalThis.process) globalThis.process.exitCode = value
if (globalThis.Deno) globalThis.Deno.exitCode = value
},
get exitCode() {
return process._exitCode
},
exit: (code = 0) => {
globalThis.Deno?.exit?.(code)
globalThis.process?.exit?.(code)
process.exitCode = code
process._maybeProcessExitCode()
},
_exitHook: null,
_maybeProcessExitCode: () => {
if (globalThis.Deno) return // has native exitCode support
if (process._exitHook) return process._exitHook(process._exitCode)
if (process._exitCode !== 0) {
setTimeout(() => {
if (isBarebone) print('EXODUS_TEST_FAILED_EXIT_CODE_1')
const err = new Error('Test failed')
err.stack = ''
throw err
}, 0)
}
},
cwd: () => {
// eslint-disable-next-line no-undef
if (typeof EXODUS_TEST_PROCESS_CWD === 'string') return EXODUS_TEST_PROCESS_CWD
throw new Error('Can not determine cwd, no process available')
},
}
globalThis.EXODUS_TEST_PROCESS = process
} else {
Object.assign(process, { argv: process.argv }) // apply values from defined bundled vars, if present
}
if (process.env.EXODUS_TEST_PLATFORM === 'hermes' || process.env.EXODUS_TEST_IS_BROWSER) {
const print = console.log.bind(console) // we don not want overrides
let logHeader = () => {
globalThis.EXODUS_TEST_PROCESS.exitCode = 1
print(`‼ FATAL Tests generated asynchronous activity after they ended.
This activity created errors and would have caused tests to fail, but instead triggered unhandledRejection events`)
logHeader = () => {}
setTimeout(() => globalThis.EXODUS_TEST_PROCESS._maybeProcessExitCode(), 0)
}
if (process.env.EXODUS_TEST_PLATFORM === 'hermes') {
const onUnhandled = (i, err) => {
logHeader()
print(`Uncaught error #${i}: ${err}`)
}
globalThis.HermesInternal?.enablePromiseRejectionTracker({ allRejections: true, onUnhandled })
} else if (process.env.EXODUS_TEST_IS_BROWSER) {
// Won't catch all errors, as we might still be running, but better than nothing
// We also don't print anything except the header, as browsers already print that
// Cancelling the default behavior is less robust as we want to treat this as error
globalThis.addEventListener('unhandledrejection', () => logHeader())
}
}
if (!globalThis.crypto?.getRandomValues && globalThis.EXODUS_TEST_CRYPTO_ENTROPY) {
let entropy
let entropyBase64 = globalThis.EXODUS_TEST_CRYPTO_ENTROPY
const loadEntropy = () => {
if (Uint8Array.fromBase64) {
entropy = Uint8Array.fromBase64(entropyBase64)
} else if (globalThis.atob) {
const raw = atob(entropyBase64)
const length = raw.length
entropy = new Uint8Array(length)
for (let i = 0; i < length; i++) entropy[i] = raw.charCodeAt(i) // eslint-disable-line unicorn/prefer-code-point
} else {
entropy = Buffer.from(entropyBase64, 'base64')
}
entropyBase64 = ''
}
let pos = 0
if (!globalThis.crypto) globalThis.crypto = {}
const TypedArray = Object.getPrototypeOf(Uint8Array)
globalThis.crypto.getRandomValues = (typedArray) => {
if (!(typedArray instanceof TypedArray)) throw new Error('Argument should be a TypedArray')
const view = new Uint8Array(typedArray.buffer, typedArray.byteOffset, typedArray.byteLength)
if (!entropy) loadEntropy()
if (pos + view.length <= entropy.length) {
view.set(entropy.subarray(pos, pos + view.length))
pos += view.length
return typedArray
}
throw new Error(`Not enough csprng entropy in this test bundle (ref: @exodus/test)`)
}
}
delete globalThis.EXODUS_TEST_CRYPTO_ENTROPY
if (globalThis.crypto?.getRandomValues && !globalThis.crypto?.randomUUID) {
const getRandomValues = globalThis.crypto.getRandomValues.bind(globalThis.crypto)
let entropy
const hex = (start, end) => entropy.slice(start, end).toString('hex')
globalThis.crypto.randomUUID = () => {
if (!entropy) entropy = Buffer.alloc(16)
getRandomValues(entropy)
entropy[6] = (entropy[6] & 0x0f) | 0x40 // version 4: 0100xxxx
entropy[8] = (entropy[8] & 0x3f) | 0x80 // variant 1: 10xxxxxx
// xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
return `${hex(0, 4)}-${hex(4, 6)}-${hex(6, 8)}-${hex(8, 10)}-${hex(10, 16)}`
}
}
if (!globalThis.crypto.subtle) globalThis.crypto.subtle = {} // For getRandomValues detection
if (process.env.EXODUS_TEST_IS_BAREBONE) {
if (!globalThis.URLSearchParams) globalThis.URLSearchParams = require('@ungap/url-search-params')
if (!globalThis.TextEncoder || !globalThis.TextDecoder) {
const { TextEncoder, TextDecoder } = require('exodus-test:text-encoding-utf')
if (!globalThis.TextEncoder) globalThis.TextEncoder = TextEncoder
if (!globalThis.TextDecoder) global.TextDecoder = TextDecoder
}
}