├── .github └── workflows │ └── mikeals-workflow.yml ├── .gitignore ├── README.md ├── cli.js ├── deno.js ├── package.json ├── src ├── browser.js ├── cli.js ├── display │ ├── index.js │ ├── serial.js │ └── style.js └── runner.js ├── test.js └── test ├── fixture └── noop.js ├── scripts └── test-error.js ├── test-runner-concurrent.js └── test-runner-serial.js /.github/workflows/mikeals-workflow.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | name: Build, Test and maybe Publish 3 | jobs: 4 | test: 5 | name: Build & Test 6 | runs-on: ubuntu-latest 7 | strategy: 8 | matrix: 9 | node-version: [12.x, 14.x] 10 | steps: 11 | - uses: actions/checkout@v2 12 | - name: Use Node.js ${{ matrix.node-version }} 13 | uses: actions/setup-node@v1 14 | with: 15 | node-version: ${{ matrix.node-version }} 16 | - name: Cache node_modules 17 | id: cache-modules 18 | uses: actions/cache@v1 19 | with: 20 | path: node_modules 21 | key: ${{ matrix.node-version }}-${{ runner.OS }}-build-${{ hashFiles('package.json') }} 22 | - name: Build 23 | if: steps.cache-modules.outputs.cache-hit != 'true' 24 | run: npm install 25 | - name: Test 26 | run: npm_config_yes=true npx best-test@latest 27 | publish: 28 | name: Publish 29 | needs: test 30 | runs-on: ubuntu-latest 31 | if: github.event_name == 'push' && ( github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' ) 32 | steps: 33 | - uses: actions/checkout@v2 34 | - name: Cache node_modules 35 | id: cache-modules 36 | uses: actions/cache@v1 37 | with: 38 | path: node_modules 39 | key: 12.x-${{ runner.OS }}-build-${{ hashFiles('package.json') }} 40 | - name: Build 41 | if: steps.cache-modules.outputs.cache-hit != 'true' 42 | run: npm install 43 | - name: Test 44 | run: npm_config_yes=true npx best-test@latest 45 | 46 | - name: Publish 47 | uses: mikeal/merge-release@master 48 | env: 49 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 50 | NPM_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | package-lock.json 2 | node_modules 3 | coverage 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # estest 2 | 3 | Native ESM test system. 4 | 5 | Estest defines a test format that is completely agnostic of test frameworks 6 | and requires no globals to be injected into the environments. Tests are ESModules 7 | and `estest` will run them on any JS platform that supports native ESM (Node.js, 8 | Deno, Browser[WIP], etc). 9 | 10 | ### Running in Node.js 11 | 12 | ``` 13 | npx estest test.js 14 | ``` 15 | 16 | ### Running in Deno 17 | 18 | ``` 19 | deno run --allow-read https://raw.githubusercontent.com/mikeal/estest/master/deno.js test.js 20 | ``` 21 | 22 | ## Test Authoring 23 | 24 | The test format is very simple and does not require you to import `estest` or take 25 | on any aspects of a framework. 26 | 27 | Tests are async functions. They fail if they throw, they succeed if they complete. 28 | 29 | Tests are run concurrently by default, so if you have state to setup before the 30 | tests you simply nest your tests and run the requisite code first. 31 | 32 | You have a few options for how to export tests. 33 | 34 | ### Default Function exports 35 | 36 | ```js 37 | export default async test => { 38 | await setupWhateverIWant() 39 | test('first test!, async test => { 40 | // passes 41 | }) 42 | test('first fail!', test => { 43 | throw new Error('Fail!') 44 | }) 45 | } 46 | ``` 47 | 48 | ### Array of tests (no names) 49 | 50 | ```js 51 | const tests = [] 52 | tests.push(async test => { /* passes */ }) 53 | tests.push(test => { throw new Error('Fail!') }) 54 | 55 | export { tests } 56 | ``` 57 | 58 | ### Object of tests (w/ names) 59 | 60 | ```js 61 | const tests = { 62 | 'first test!: async test => { /* passes */ }, 63 | 'first fail!': test => { throw new Error('Fail!') }) 64 | } 65 | export { tests } 66 | ``` 67 | 68 | ## Nesting tests 69 | 70 | ```js 71 | const addRecursive = (test, i=0) => { 72 | if (i > 100) return 73 | test(`recursion at ${i}`, test => addRecursive(test, i+1)) 74 | } 75 | 76 | export default test => { 77 | setupAFewThings() 78 | test('first', test => { 79 | setupMoreThings() 80 | test('first nesting', async test => { 81 | await setupAsyncThings() 82 | test('we can do this literally forever', async test => { 83 | addRecursive(test) 84 | }) 85 | }) 86 | }) 87 | } 88 | ``` 89 | 90 | As you can see, this API is a very powerful way generate tests programatically. 91 | 92 | ## Test cleanup 93 | 94 | The `test` function also has a `.after()` method that will run after the function 95 | is completed whether it passes or fails. 96 | 97 | ```js 98 | test('one', async test => { 99 | let env 100 | test.after(() => { 101 | if (!env) { 102 | test('node', () => { 103 | /* passes */ 104 | }) 105 | } 106 | }) 107 | if (!process.browser) throw new Error('Not browser') 108 | env = process.browser 109 | }) 110 | ``` 111 | 112 | -------------------------------------------------------------------------------- /cli.js: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | ':' // comment; exec /usr/bin/env node --unhandled-rejections=strict "$0" "$@" 3 | import yargs from 'yargs' 4 | import run from './src/cli.js' 5 | 6 | const options = yargs => { 7 | yargs.positional('files', { 8 | desc: 'Test files you want to run' 9 | }) 10 | yargs.option('browser', { 11 | desc: 'Run browser tests', 12 | type: 'boolean', 13 | default: false 14 | }) 15 | yargs.option('break', { 16 | desc: 'Run test serially until the first break and then stop', 17 | alias: 'b' 18 | }) 19 | } 20 | 21 | const _run = argv => run({ ...argv, stdout: process.stdout, cwd: process.cwd() }) 22 | 23 | /* eslint-disable-next-line */ 24 | const argv = yargs.command('$0 [files..]', 'Run test files', options, _run).argv 25 | -------------------------------------------------------------------------------- /deno.js: -------------------------------------------------------------------------------- 1 | /* globals Deno */ 2 | import run from './src/cli.js' 3 | 4 | const files = [...Deno.args] 5 | run({ files, debug: true, stdout: Deno.stdout, cwd: Deno.cwd() }) 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "estest", 3 | "version": "0.0.0-dev", 4 | "description": "ESM native test system", 5 | "main": "index.js", 6 | "type": "module", 7 | "bin": { 8 | "estest": "cli.js" 9 | }, 10 | "scripts": { 11 | "test:node": "node cli.js test/test-*.js", 12 | "test:deno": "deno run --allow-read deno.js test/test-runner-concurrent.js", 13 | "test:browser": "node cli.js --browser test/test-runner-concurrent.js", 14 | "test": "standard && npm run test:node && npm run test:browser" 15 | }, 16 | "keywords": [], 17 | "author": "Mikeal Rogers (https://www.mikealrogers.com/)", 18 | "license": "(Apache-2.0 AND MIT)", 19 | "devDependencies": { 20 | "c8": "^7.3.0", 21 | "standard": "^14.3.4" 22 | }, 23 | "dependencies": { 24 | "puppeteer": "^5.2.1", 25 | "toulon": "0.0.1", 26 | "yargs": "^15.4.1" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/browser.js: -------------------------------------------------------------------------------- 1 | import toulon from 'toulon' 2 | import puppeteer from 'puppeteer' 3 | import { promises as fs } from 'fs' 4 | 5 | const runner = fs.readFile(new URL('runner.js', import.meta.url)) 6 | 7 | const html = ` 21 | 22 | 23 | ` 24 | 25 | export default display => async argv => { 26 | display = await display(argv) 27 | let { concurrency } = argv 28 | if (argv.break) concurrency = 1 29 | const handler = async (opts, req, res) => { 30 | if (req.url === '/_dagdb/runner.js') { 31 | res.setHeader('content-type', 'text/javascript') 32 | res.end(await runner) 33 | return 34 | } 35 | if (req.url.startsWith('/_cwd/')) { 36 | const f = await fs.readFile(req.url.replace('/_cwd', argv.cwd)) 37 | res.setHeader('content-type', 'text/javascript') 38 | res.end(f) 39 | } 40 | } 41 | const browser = await toulon(puppeteer, { handler }) 42 | const run = async (filename, errors) => { 43 | let finish 44 | let onError 45 | const until = new Promise((resolve, reject) => { 46 | finish = resolve 47 | onError = e => { 48 | reject(e) 49 | } 50 | // TODO: wire up reject to any fail major fail states 51 | }) 52 | const stubs = { pipe: x => x, concurrency: concurrency || null } 53 | const opts = await display(filename) 54 | const api = { ...stubs, ...opts, finish, browser: true, stdout: null } 55 | const onConsole = async msg => { 56 | const args = await Promise.all(msg.args().map(h => h.jsonValue().then(s => { 57 | if (typeof s === 'string') { 58 | try { return JSON.parse(s) } catch { } 59 | } 60 | return s 61 | }))) 62 | const type = msg.type() 63 | if (type === 'error') return console.error(...args) 64 | console.log(...args) 65 | } 66 | await browser.tab(html, onError, onConsole, api) 67 | await until 68 | if (opts.errors) opts.errors.forEach(e => errors.push(e)) 69 | } 70 | run.cleanup = () => browser.close() 71 | return run 72 | } 73 | -------------------------------------------------------------------------------- /src/cli.js: -------------------------------------------------------------------------------- 1 | import runner from './runner.js' 2 | import serialDisplay from './display/serial.js' 3 | import defaultDisplay from './display/index.js' 4 | import browser from './browser.js' 5 | 6 | const concurrency = 100 7 | 8 | export default async argv => { 9 | const { files, stdout, cwd } = argv 10 | if (!files) throw new Error('No test files') 11 | let display = defaultDisplay 12 | if (argv.break) { 13 | display = serialDisplay 14 | } 15 | if (argv.browser) { 16 | display = await browser(display) 17 | } 18 | const run = await display(argv) 19 | const pending = new Set() 20 | const ring = p => { 21 | pending.add(p) 22 | return p.then(value => { 23 | pending.delete(p) 24 | return value 25 | }) 26 | } 27 | const errors = [] 28 | const runFile = async filename => { 29 | if (!argv.browser) { 30 | const opts = await run(filename) 31 | await ring(runner({ ...opts, stdout, cwd, breakOnFail: argv.break || false })) 32 | if (opts.errors) { 33 | opts.errors.forEach(e => errors.push(e)) 34 | } 35 | } else { 36 | await ring(run(filename, errors)) 37 | } 38 | } 39 | await Promise.race(files.splice(0, concurrency).map(runFile)) 40 | while (files.length) { 41 | await Promise.race([...pending]) 42 | await runFile(files.shift()) 43 | } 44 | await Promise.all([...pending]) 45 | if (run.cleanup) await run.cleanup() 46 | if (display.cleanup) await display.cleanup(argv) 47 | if (errors.length) { 48 | console.error(errors.join('\n')) 49 | process.exit(1) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/display/index.js: -------------------------------------------------------------------------------- 1 | import style from './style.js' 2 | 3 | const join = (...args) => args.join('') 4 | const red = (...args) => join(style.fg.Red, ...args, style.Reset) 5 | const green = (...args) => join(style.fg.Green, ...args, style.Reset) 6 | const white = (...args) => join(style.fg.White, ...args, style.Reset) 7 | const magenta = (...args) => join(style.fg.Magenta, ...args, style.Reset) 8 | 9 | const lines = {} 10 | 11 | const display = argv => async filename => { 12 | const { stdout, cwd } = argv 13 | const write = async line => { 14 | if (argv.debug || !stdout.isTTY) { 15 | const status = [ 16 | `started: ${white(line.started)}`, 17 | `passed: ${green(line.passed)}`, 18 | `failed: ${red(line.failed)}` 19 | ] 20 | console.log(`${line.prefix}(${status.join(', ')})`) 21 | } else { 22 | display.cleanup = () => console.log('') 23 | const c = { started: 0, passed: 0, failed: 0 } 24 | Object.values(lines).forEach(l => { 25 | c.started += l.started 26 | c.passed += l.passed 27 | c.failed += l.failed 28 | }) 29 | const status = [ 30 | `started: ${white(c.started)}`, 31 | `passed: ${green(c.passed)}`, 32 | `failed: ${red(c.failed)}` 33 | ] 34 | stdout.cursorTo(0) 35 | const prefix = white('estest') + magenta(': ') 36 | await new Promise(resolve => { 37 | stdout.write(`${prefix}(${status.join(', ')})`, resolve) 38 | }) 39 | } 40 | } 41 | const line = { 42 | prefix: white(filename + ': '), 43 | i: Object.keys(lines).length, 44 | running: 0, 45 | passed: 0, 46 | failed: 0, 47 | started: 0 48 | } 49 | const errors = [] 50 | const onPass = async (testName) => { 51 | line.passed += 1 52 | await write(line) 53 | } 54 | const onFail = async (testName, e) => { 55 | line.failed += 1 56 | errors.push(red(`${filename} ${testName} failed:`)) 57 | errors.push(white(e.stack)) 58 | await write(line) 59 | } 60 | const onStart = async node => { 61 | line.running += 1 62 | line.started += 1 63 | const { testName } = node 64 | node.onPass = (...args) => onPass(testName, ...args) 65 | node.onFail = (...args) => onFail(testName, ...args) 66 | await write(line) 67 | } 68 | const onEnd = node => { 69 | line.running -= 1 70 | } 71 | await write(line) 72 | lines[filename] = line 73 | return { filename, onStart, onEnd, onFail, onPass, errors, cwd, stdout } 74 | } 75 | 76 | export default display 77 | -------------------------------------------------------------------------------- /src/display/serial.js: -------------------------------------------------------------------------------- 1 | import style from './style.js' 2 | 3 | const red = (...args) => console.log(style.fg.Red, ...args, style.Reset) 4 | const green = (...args) => console.log(style.fg.Green, ...args, style.Reset) 5 | const white = (...args) => console.log(style.fg.White, ...args, style.Reset) 6 | 7 | const indent = node => { 8 | let i = '-' 9 | while (node.parent) { 10 | node = node.parent 11 | i += '-' 12 | } 13 | return i 14 | } 15 | 16 | export default argv => filename => { 17 | const onStart = node => { 18 | const { testName } = node 19 | const i = indent(node) 20 | node.onPass = () => { 21 | green(i, testName, 'passed') 22 | } 23 | node.onFail = e => { 24 | red(`${filename} ${testName} failed:`) 25 | white(e.stack) 26 | red(i, testName, 'failed') 27 | } 28 | white(i, testName, 'started') 29 | } 30 | const onEnd = node => { 31 | // console.log(indent(node), node.testName, 'ended') 32 | } 33 | return { filename, onStart, onEnd, concurrency: 1 } 34 | } 35 | -------------------------------------------------------------------------------- /src/display/style.js: -------------------------------------------------------------------------------- 1 | const style = { 2 | Reset: '\x1b[0m', 3 | Bright: '\x1b[1m', 4 | Dim: '\x1b[2m', 5 | Underscore: '\x1b[4m', 6 | Blink: '\x1b[5m', 7 | Reverse: '\x1b[7m', 8 | Hidden: '\x1b[8m', 9 | fg: { 10 | Black: '\x1b[30m', 11 | Red: '\x1b[31m', 12 | Green: '\x1b[32m', 13 | Yellow: '\x1b[33m', 14 | Blue: '\x1b[34m', 15 | Magenta: '\x1b[35m', 16 | Cyan: '\x1b[36m', 17 | White: '\x1b[37m', 18 | Crimson: '\x1b[38m' // القرمزي 19 | }, 20 | bg: { 21 | Black: '\x1b[40m', 22 | Red: '\x1b[41m', 23 | Green: '\x1b[42m', 24 | Yellow: '\x1b[43m', 25 | Blue: '\x1b[44m', 26 | Magenta: '\x1b[45m', 27 | Cyan: '\x1b[46m', 28 | White: '\x1b[47m', 29 | Crimson: '\x1b[48m' 30 | } 31 | } 32 | export default style 33 | -------------------------------------------------------------------------------- /src/runner.js: -------------------------------------------------------------------------------- 1 | // adapted from https://nodejs.org/api/path.html#path_path_resolve_paths 2 | const CHAR_FORWARD_SLASH = '/' 3 | const percentRegEx = /%/g 4 | const backslashRegEx = /\\/g 5 | const newlineRegEx = /\n/g 6 | const carriageReturnRegEx = /\r/g 7 | const tabRegEx = /\t/g 8 | function pathToFileURL (filepath, cwd) { 9 | let resolved 10 | if (filepath.startsWith('./')) filepath = filepath.slice(2) 11 | if (filepath.startsWith('/')) resolved = filepath 12 | else resolved = (cwd || '') + '/' + filepath 13 | 14 | // path.resolve strips trailing slashes so we must add them back 15 | const filePathLast = filepath.charCodeAt(filepath.length - 1) 16 | if ((filePathLast === CHAR_FORWARD_SLASH) && 17 | resolved[resolved.length - 1] !== '/') { resolved += '/' } 18 | const outURL = new URL('file://') 19 | if (resolved.includes('%')) { resolved = resolved.replace(percentRegEx, '%25') } 20 | // In posix, "/" is a valid character in paths 21 | if (resolved.includes('\\')) { resolved = resolved.replace(backslashRegEx, '%5C') } 22 | if (resolved.includes('\n')) { resolved = resolved.replace(newlineRegEx, '%0A') } 23 | if (resolved.includes('\r')) { resolved = resolved.replace(carriageReturnRegEx, '%0D') } 24 | if (resolved.includes('\t')) { resolved = resolved.replace(tabRegEx, '%09') } 25 | outURL.pathname = resolved 26 | return outURL 27 | } 28 | 29 | const runner = async api => { 30 | let { filename, onStart, onEnd, cwd, browser, concurrency, breakOnFail } = api 31 | const pending = [] 32 | const create = ({ name, fn, filename, parent }) => { 33 | const test = (name, fn) => create({ name, fn, filename, parent: test }) 34 | test.testName = name 35 | 36 | const _after = [] 37 | const _afterPromises = [] 38 | const after = async () => { 39 | while (_after.length) { 40 | const resolve = _after.shift() 41 | const promise = _afterPromises.shift() 42 | resolve(test) 43 | await promise 44 | } 45 | } 46 | test.after = fn => { 47 | let p = new Promise(resolve => _after.push(resolve)) 48 | if (fn) p = p.then(test => fn(test)) 49 | _afterPromises.push(p) 50 | return p 51 | } 52 | test.fn = async (...args) => { 53 | await fn(...args) 54 | after() 55 | } 56 | 57 | test.filename = filename 58 | test.parent = parent 59 | if (parent && pending.indexOf(parent) !== -1) { 60 | pending.splice(pending.indexOf(parent), 0, test) 61 | } else { 62 | pending.push(test) 63 | } 64 | return test 65 | } 66 | if (!filename) throw new Error('No filename') 67 | let url 68 | if (browser) { 69 | url = '/_cwd/' + filename 70 | } else { 71 | url = pathToFileURL(filename, cwd) 72 | } 73 | const module = { ...await import(url) } 74 | if (!module.default) module.default = module.test 75 | if (module.default) { 76 | await module.default((name, fn) => create({ name, fn, filename })) 77 | } else { 78 | if (module.tests) { 79 | for (const [name, fn] of Object.entries(module.tests)) { 80 | create({ name, fn, filename }) 81 | } 82 | } else { 83 | throw new Error('This module does not export anything regonizable as a test') 84 | } 85 | } 86 | 87 | concurrency = module.concurrency || concurrency || 100 88 | 89 | if (pending.length === 0) throw new Error('No tests!') 90 | let start = 0 91 | const _run = async node => { 92 | await onStart(node) 93 | let threw = true 94 | try { 95 | await node.fn(node) 96 | threw = false 97 | } catch (e) { 98 | await node.onFail(e) 99 | if (breakOnFail) process.exit(1) 100 | } 101 | if (!threw) await node.onPass() 102 | await onEnd(node) 103 | pending.splice(pending.indexOf(node), 1) 104 | start-- 105 | } 106 | const running = [] 107 | const wrap = node => { 108 | const p = _run(node) 109 | running.push(p) 110 | p.then(() => running.splice(running.indexOf(p), 1)) 111 | } 112 | while (pending.length || running.length) { 113 | pending.slice(start, concurrency).forEach(wrap) 114 | start += concurrency 115 | await Promise.race(running) 116 | concurrency = 1 117 | } 118 | } 119 | 120 | export default runner 121 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | export default test => test('one', () => console.log('inTest')) 2 | -------------------------------------------------------------------------------- /test/fixture/noop.js: -------------------------------------------------------------------------------- 1 | export default test => { 2 | test('basics', test => { 3 | }) 4 | } 5 | -------------------------------------------------------------------------------- /test/scripts/test-error.js: -------------------------------------------------------------------------------- 1 | export default test => { 2 | test('throw error', () => { 3 | throw new Error('You should see this error') 4 | }) 5 | } 6 | -------------------------------------------------------------------------------- /test/test-runner-concurrent.js: -------------------------------------------------------------------------------- 1 | const same = (x, y) => { 2 | if (x !== y) throw new Error(`${x} does not equal ${y}`) 3 | } 4 | 5 | export default async test => { 6 | const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) 7 | 8 | const times = [] 9 | test('test 1', async test => { 10 | times.push(Date.now()) 11 | await sleep(10) 12 | same(times.length, 2) 13 | }) 14 | test('test 2', async test => { 15 | times.push(Date.now()) 16 | await sleep(10) 17 | same(times.length, 2) 18 | }) 19 | let _complete = false 20 | let _afterCalled = false 21 | const _after = test('after begin', async test => { 22 | await sleep(10) 23 | _complete = true 24 | }).after(() => { _afterCalled = true }) 25 | test('after complete', async () => { 26 | await _after 27 | same(_complete, true) 28 | }) 29 | test('after callback', async () => { 30 | await _after 31 | same(_afterCalled, true) 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /test/test-runner-serial.js: -------------------------------------------------------------------------------- 1 | import runner from '../src/runner.js' 2 | import { deepStrictEqual } from 'assert' 3 | import { fileURLToPath } from 'url' 4 | import { dirname, join } from 'path' 5 | 6 | const __filename = fileURLToPath(import.meta.url) 7 | const __dirname = dirname(__filename) 8 | 9 | const same = deepStrictEqual 10 | 11 | const fixture = join(__dirname, 'fixture') 12 | 13 | const testRunner = async test => { 14 | test('basic runner', async test => { 15 | let xStart = false 16 | let xFail = false 17 | let xPass = false 18 | let onEnd 19 | const done = new Promise(resolve => { 20 | onEnd = async node => resolve(node) 21 | }) 22 | const onStart = async node => { 23 | xStart = true 24 | node.onPass = () => { xPass = true } 25 | node.onFail = () => { xFail = true } 26 | } 27 | await runner({ filename: join(fixture, 'noop.js'), onStart, onEnd }) 28 | await done 29 | same(xStart, true) 30 | same(xFail, false) 31 | same(xPass, true) 32 | }) 33 | 34 | test('testName', test => { 35 | same(test.testName, 'testName') 36 | }) 37 | test('nested', test => { 38 | same(test.testName, 'nested') 39 | let nestedComplete = false 40 | test('one', test => { 41 | same(test.testName, 'one') 42 | same(test.parent.testName, 'nested') 43 | test('one-two', test => { 44 | same(test.testName, 'one-two') 45 | same(test.parent.testName, 'one') 46 | same(test.parent.parent.testName, 'nested') 47 | nestedComplete = true 48 | }) 49 | }) 50 | test('oneCompleted', test => { 51 | same(nestedComplete, true) 52 | }) 53 | }) 54 | } 55 | 56 | const concurrency = 1 57 | export { testRunner as test, concurrency } 58 | --------------------------------------------------------------------------------