Skip to content

Commit

Permalink
feat: recieve and pass AbortSignal
Browse files Browse the repository at this point in the history
PR-URL: nodejs/node#43554
Reviewed-By: Benjamin Gruenbaum <benjamingr@gmail.com>
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
(cherry picked from commit 389b7e138e89a339fabe4ad628bf09cd9748f957)
  • Loading branch information
MoLow authored and aduh95 committed Jul 21, 2022
1 parent e155dae commit 558abfc
Show file tree
Hide file tree
Showing 17 changed files with 724 additions and 94 deletions.
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,15 @@ jobs:
strategy:
matrix:
node: ['14', '16', '18']
include:
- node: '14'
env: --experimental-abortcontroller --no-warnings
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node }}
- run: npm ci
- run: npm test
env:
NODE_OPTIONS: ${{ matrix.env }}
33 changes: 29 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Minimal dependencies, with full test suite.
Differences from the core implementation:

- Doesn't hide its own stack frames.
- Requires `--experimental-abortcontroller` CLI flag to work on Node.js v14.x.

## Docs

Expand Down Expand Up @@ -333,6 +334,7 @@ internally.
- `only` {boolean} If truthy, and the test context is configured to run
`only` tests, then this test will be run. Otherwise, the test is skipped.
**Default:** `false`.
* `signal` {AbortSignal} Allows aborting an in-progress test
- `skip` {boolean|string} If truthy, the test is skipped. If a string is
provided, that string is displayed in the test results as the reason for
skipping the test. **Default:** `false`.
Expand Down Expand Up @@ -386,8 +388,9 @@ thus prevent the scheduled cancellation.
does not have a name.
* `options` {Object} Configuration options for the suite.
supports the same options as `test([name][, options][, fn])`
* `fn` {Function} The function under suite.
a synchronous function declaring all subtests and subsuites.
* `fn` {Function|AsyncFunction} The function under suite
declaring all subtests and subsuites.
The first argument to this function is a [`SuiteContext`][] object.
**Default:** A no-op function.
* Returns: `undefined`.

Expand Down Expand Up @@ -455,6 +458,16 @@ have the `only` option set. Otherwise, all tests are run. If Node.js was not
started with the [`--test-only`][] command-line option, this function is a
no-op.

### `context.signal`

* [`AbortSignal`][] Can be used to abort test subtasks when the test has been aborted.

```js
test('top level test', async (t) => {
await fetch('some/uri', { signal: t.signal });
});
```

### `context.skip([message])`

- `message` {string} Optional skip message to be displayed in TAP output.
Expand Down Expand Up @@ -503,8 +516,20 @@ execution of the test function. This function does not return a value.
This function is used to create subtests under the current test. This function
behaves in the same fashion as the top level [`test()`][] function.

[tap]: https://testanything.org/
[`testcontext`]: #class-testcontext
## Class: `SuiteContext`

An instance of `SuiteContext` is passed to each suite function in order to
interact with the test runner. However, the `SuiteContext` constructor is not
exposed as part of the API.

### `context.signal`

* [`AbortSignal`][] Can be used to abort test subtasks when the test has been aborted.

[`AbortSignal`]: https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal
[TAP]: https://testanything.org/
[`SuiteContext`]: #class-suitecontext
[`TestContext`]: #class-testcontext
[`test()`]: #testname-options-fn
[describe options]: #describename-options-fn
[it options]: #testname-options-fn
Expand Down
6 changes: 6 additions & 0 deletions lib/internal/abort_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
'use strict'

module.exports = {
AbortController,
AbortSignal
}
9 changes: 9 additions & 0 deletions lib/internal/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,16 @@ function hideInternalStackFrames (error) {
})
}

class AbortError extends Error {
constructor (message = 'The operation was aborted', options = undefined) {
super(message, options)
this.code = 'ABORT_ERR'
this.name = 'AbortError'
}
}

module.exports = {
AbortError,
codes,
inspectWithNoCustomRetry,
kIsNodeError
Expand Down
89 changes: 37 additions & 52 deletions lib/internal/main/test_runner.js
Original file line number Diff line number Diff line change
@@ -1,36 +1,35 @@
// https://github.com/nodejs/node/blob/e2225ba8e1c00995c0f8bd56e607ea7c5b463ab9/lib/internal/main/test_runner.js
// https://github.com/nodejs/node/blob/389b7e138e89a339fabe4ad628bf09cd9748f957/lib/internal/main/test_runner.js
'use strict'
const {
ArrayFrom,
ArrayPrototypeFilter,
ArrayPrototypeIncludes,
ArrayPrototypeJoin,
ArrayPrototypePush,
ArrayPrototypeSlice,
ArrayPrototypeSort,
Promise,
PromiseAll,
SafeArrayIterator,
SafePromiseAll,
SafeSet
} = require('#internal/per_context/primordials')
const {
prepareMainThreadExecution
} = require('#internal/bootstrap/pre_execution')
const { spawn } = require('child_process')
const { readdirSync, statSync } = require('fs')
const { finished } = require('#internal/streams/end-of-stream')
const console = require('#internal/console/global')
const {
codes: {
ERR_TEST_FAILURE
}
} = require('#internal/errors')
const { toArray } = require('#internal/streams/operators').promiseReturningOperators
const { test } = require('#internal/test_runner/harness')
const { kSubtestsFailed } = require('#internal/test_runner/test')
const {
isSupportedFileType,
doesPathMatchFilter
} = require('#internal/test_runner/utils')
const { basename, join, resolve } = require('path')
const { once } = require('events')
const kFilterArgs = ['--test']

prepareMainThreadExecution(false)
Expand Down Expand Up @@ -104,53 +103,39 @@ function filterExecArgv (arg) {
}

function runTestFile (path) {
return test(path, () => {
return new Promise((resolve, reject) => {
const args = ArrayPrototypeFilter(process.execArgv, filterExecArgv)
ArrayPrototypePush(args, path)

const child = spawn(process.execPath, args)
// TODO(cjihrig): Implement a TAP parser to read the child's stdout
// instead of just displaying it all if the child fails.
let stdout = ''
let stderr = ''
let err

child.on('error', (error) => {
err = error
})

child.stdout.setEncoding('utf8')
child.stderr.setEncoding('utf8')

child.stdout.on('data', (chunk) => {
stdout += chunk
})

child.stderr.on('data', (chunk) => {
stderr += chunk
})

child.once('exit', async (code, signal) => {
if (code !== 0 || signal !== null) {
if (!err) {
await PromiseAll(new SafeArrayIterator([finished(child.stderr), finished(child.stdout)]))
err = new ERR_TEST_FAILURE('test failed', kSubtestsFailed)
err.exitCode = code
err.signal = signal
err.stdout = stdout
err.stderr = stderr
// The stack will not be useful since the failures came from tests
// in a child process.
err.stack = undefined
}

return reject(err)
}

resolve()
})
return test(path, async (t) => {
const args = ArrayPrototypeFilter(process.execArgv, filterExecArgv)
ArrayPrototypePush(args, path)

const child = spawn(process.execPath, args, { signal: t.signal, encoding: 'utf8' })
// TODO(cjihrig): Implement a TAP parser to read the child's stdout
// instead of just displaying it all if the child fails.
let err

child.on('error', (error) => {
err = error
})

const { 0: { code, signal }, 1: stdout, 2: stderr } = await SafePromiseAll([
once(child, 'exit', { signal: t.signal }),
toArray.call(child.stdout, { signal: t.signal }),
toArray.call(child.stderr, { signal: t.signal })
])

if (code !== 0 || signal !== null) {
if (!err) {
err = new ERR_TEST_FAILURE('test failed', kSubtestsFailed)
err.exitCode = code
err.signal = signal
err.stdout = ArrayPrototypeJoin(stdout, '')
err.stderr = ArrayPrototypeJoin(stderr, '')
// The stack will not be useful since the failures came from tests
// in a child process.
err.stack = undefined
}

throw err
}
})
}

Expand Down
4 changes: 3 additions & 1 deletion lib/internal/per_context/primordials.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,13 @@ exports.ObjectPrototypeHasOwnProperty = (obj, property) => Object.prototype.hasO
exports.ReflectApply = (target, self, args) => Reflect.apply(target, self, args)
exports.Promise = Promise
exports.PromiseAll = iterator => Promise.all(iterator)
exports.PromisePrototypeThen = (promise, thenFn, catchFn) => promise.then(thenFn, catchFn)
exports.PromiseResolve = val => Promise.resolve(val)
exports.PromiseRace = val => Promise.race(val)
exports.SafeArrayIterator = class ArrayIterator {constructor (array) { this.array = array }[Symbol.iterator] () { return this.array.values() }}
exports.SafeMap = Map
exports.SafePromiseAll = (array, mapFn) => Promise.all(array.map(mapFn))
exports.SafePromiseAll = (array, mapFn) => Promise.all(mapFn ? array.map(mapFn) : array)
exports.SafePromiseRace = (array, mapFn) => Promise.race(mapFn ? array.map(mapFn) : array)
exports.SafeSet = Set
exports.SafeWeakMap = WeakMap
exports.StringPrototypeMatch = (str, reg) => str.match(reg)
Expand Down
24 changes: 24 additions & 0 deletions lib/internal/streams/operators.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
const {
ArrayPrototypePush
} = require('#internal/per_context/primordials')
const { validateAbortSignal } = require('#internal/validators')
const { AbortError } = require('#internal/errors')

async function toArray (options) {
if (options?.signal != null) {
validateAbortSignal(options.signal, 'options.signal')
}

const result = []
for await (const val of this) {
if (options?.signal?.aborted) {
throw new AbortError(undefined, { cause: options.signal.reason })
}
ArrayPrototypePush(result, val)
}
return result
}

module.exports.promiseReturningOperators = {
toArray
}
Loading

0 comments on commit 558abfc

Please sign in to comment.