Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Core: Fix late onerror handling #1629

Merged
merged 2 commits into from
Jul 3, 2021
Merged

Core: Fix late onerror handling #1629

merged 2 commits into from
Jul 3, 2021

Conversation

Krinkle
Copy link
Member

@Krinkle Krinkle commented Jun 28, 2021

Background

Previously, QUnit.onError and QUnit.onUnhandledRejection could report global errors by synthesizing a new test, even after a run has ended.

This is problematic when an errors ocurrs after all modules (and their hooks) have finished, and the overall test run has ended.

The most immediate problem is that hooks having finished already, means it is illegal for a new test to start since "after" has already run. To protect against such illegal calls, the hooks object is emptied internally, and this new test causes an internal error:

TypeError: Cannot read property 'length' of undefined

This is not underlying problem though, but rather our internal safeguard working as intended. The higher-level problem is that there is no appropiate way to report a late error as a test since the run has already ended. The QUnit.done() callbacks have run, and the runEnd event has been emitted.

Approach

Instead of trying to report (late) errors as a test, only print them to console.warn(), which goes to stderr in Node.js. For the CLI, also remember that uncaught errors were found and use that to make sure we don't change exitCode back to zero (e.g. in case we have an uncaught error after the last test but before our runEnd callback is called).

Changes

  • Generalise QUnit.onUnhandledRejection and re-use it for window.onerror (browser), and uncaught exceptions (CLI).

  • Fix broken use of QUnit.onError in process.on( "uncaughtException" ).
    This was passing the wrong parameters. Use the new onUncaughtException method instead.

  • Clarify that QUnit.onError is only for window.onerror. For now, keep its strange non-standard signature as-is (with the custom object parameter), but document this and its return value.

  • Remove the unused "..args" from QUnit.onError. This was only ever passed from one of our unit tests to give one extra argument (a string of "actual"), which then ended up passed as "actual" parameter to pushFailure(). We never used this in the actual onError binding, so remove this odd variadic construct for now.

  • Change ProcessingQueue#done, which is in charge of reporting the "No tests were run" error, to no longer rely on the way that QUnit.onError previously queued a late test.

    The first part of this function may run twice (same as before, once after an empty test run, and one more time after the synthetic test has finished and the queue is empty again). Change this so that we no longer assign finished = true in that first part. This means we will still support queueing of this one late test. But, since the quueue is empty, we do need to call advance() manually as otherwise it'd never get processed.

    Previously, finished = true was assigned first, which meant that QUnit.onError was adding a test under that condition. But this worked anyway because Test#queue internally had manual advancing exactly for this use case, which is also where we now emit a deprecation warning (to become an error in QUnit 3). Note that using this for anything other than the "No tests run" error was already unreliable since generally runEnd would have been emitted already. The "No tests run" test was exactly done from the one sweet spot where it was (and remains) safe because that threw an error and thus prevented runEnd from being emitted.

Fixes #1377.
Ref #1322.
Ref #1446.

The next commit in this branch for qunitjs#1511, will disallow adding
tests if `QUnit.done()` and `runEnd` have already happened, thus
leading these hacks to fail as follows:

````
Running "qunit:all" (qunit) task
Testing http://localhost:4000/test/index.html
[…]
Testing http://localhost:4000/test/module-skip.html ....
Error: Unexpected new test after the run already ended
    at new Test (http://localhost:4000/qunit/qunit.js:2206:13)
^C
```

In addition, due to a known issue in grunt-contrib-qunit, these would
also indefinitely hack instead of actually failing.

Ref gruntjs/grunt-contrib-qunit#178.
Ref qunitjs#1377.
Ref qunitjs#1511.
@Krinkle
Copy link
Member Author

Krinkle commented Jun 28, 2021

@smcclure15 I've split this in two commits for easier review. I ran the first one in a separate branch against CI first to make sure it passes on its own, however, GitHub won't do or show that here unfortunately.

Copy link
Member

@smcclure15 smcclure15 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is awesome! Thanks for tackling this task at the higher level it was needing. Code and messaging feels much cleaner now in this state.

Left a few questions/nits, but otherwise looks great

src/cli/run.js Show resolved Hide resolved
src/core.js Outdated Show resolved Hide resolved
src/core/on-uncaught-exception.js Outdated Show resolved Hide resolved
@@ -71,7 +71,8 @@ QUnit.module( "CLI Main", () => {
try {
await execute( "qunit syntax-error/test.js" );
} catch ( e ) {
assert.true( e.stdout.includes( "not ok 1 syntax-error/test.js > Failed to load the test file with error:" ) );
assert.true( e.stdout.includes( "not ok 1 global failure" ) );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW I've found

assert.true(x.includes(y), x)

to be helpful (to add the "actual" as a diagnostic to inspect on failure)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ack. I saw that pattern in a few places. I also saw the pushResult({ result: str.includes(), actual: str }) pattern in a few other places in this file which I think renders in a slightly less confusing way. We could pick one of those - or define a local assert.includes util to start with - and use that consistently for all similar needs in this file. (Also happy to take a patch for that after this lands 🙂 ).

src/cli/run.js Show resolved Hide resolved
src/cli/run.js Show resolved Hide resolved
src/core.js Outdated Show resolved Hide resolved
export default function onUncaughtException( error ) {
const message = ( error.message ? error.toString() : error );

// We could let callers specify an extra offset to add to the number passed to
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(ref)

== Background ==

Previously, QUnit.onError and QUnit.onUnhandledRejection could report
global errors by synthesizing a new test, even after a run has ended.

This is problematic when an errors ocurrs after all modules (and their
hooks) have finished, and the overall test run has ended.

The most immediate problem is that hooks having finished already,
means it is illegal for a new test to start since "after" has already
run. To protect against such illegal calls, the hooks object is
emptied internally, and this new test causes an internal error:

```
TypeError: Cannot read property 'length' of undefined
```

This is not underlying problem though, but rather our internal
safeguard working as intended. The higher-level problem is that there
is no appropiate way to report a late error as a test since the run
has already ended. The `QUnit.done()` callbacks have run, and
the `runEnd` event has been emitted.

== Approach ==

Instead of trying to report (late) errors as a test, only print them
to `console.warn()`, which goes to stderr in Node.js. For the CLI, also
remember that uncaught errors were found and use that to make sure we
don't change exitCode back to zero (e.g. in case we have an uncaught
error after the last test but before our `runEnd` callback is called).

== Changes ==

* Generalise `QUnit.onUnhandledRejection` and re-use it for
  `window.onerror` (browser), and uncaught exceptions (CLI).

* Fix broken use of `QUnit.onError` in `process.on( "uncaughtException" )`.
  This was passing the wrong parameters. Use the new onUncaughtException
  method instead.

* Clarify that `QUnit.onError` is only for `window.onerror`. For now,
  keep its strange non-standard signature as-is (with the custom object
  parameter), but document this and its return value.

* Remove the unused "..args" from `QUnit.onError`. This was only ever
  passed from one of our unit tests to give one extra argument (a
  string of "actual"), which then ended up passed as "actual" parameter
  to `pushFailure()`. We never used this in the actual onError binding,
  so remove this odd variadic construct for now.

* Change `ProcessingQueue#done`, which is in charge of reporting
  the "No tests were run" error, to no longer rely on the way that
  `QUnit.onError` previously queued a late test.

  The first part of this function may run twice (same as before, once
  after an empty test run, and one more time after the synthetic
  test has finished and the queue is empty again). Change this so that
  we no longer assign `finished = true` in that first part. This means
  we will still support queueing of this one late test. But, since the
  quueue is empty, we do need to call `advance()` manually as otherwise
  it'd never get processed.

  Previously, `finished = true` was assigned first, which meant that
  `QUnit.onError` was adding a test under that condition. But this
  worked anyway because `Test#queue` internally had manual advancing
  exactly for this use case, which is also where we now emit a
  deprecation warning (to become an error in QUnit 3). Note that using
  this for anything other than the "No tests run" error was already
  unreliable since generally runEnd would have been emitted already.
  The "No tests run" test was exactly done from the one sweet spot
  where it was (and remains) safe because that threw an error and thus
  prevented runEnd from being emitted.

Fixes qunitjs#1377.
Ref qunitjs#1322.
Ref qunitjs#1446.
@Krinkle Krinkle merged commit 07de3c6 into qunitjs:main Jul 3, 2021
@Krinkle Krinkle deleted the fix-onerror branch July 3, 2021 15:36
Krinkle added a commit that referenced this pull request Jul 5, 2021
Capture the status quo before changing it.

Minor changes:

* Switch remaining notEquals/indexOf uses to the preferred
  `assert.true( str.includes() )` idiom.

* Fix duplicate printing of error message due to V8's `Error#stack`,
  as used by onUncaughtException.
  Ref #1629.

* Start normalizing stderror in tests like we do with stdout.

* Account for qunit.js stack frames from native Promise in V8,
  which doesn't include a function name or paranthesis.

Ref #1446.
Ref #1633.
Krinkle added a commit that referenced this pull request Jul 5, 2021
Capture the status quo before changing it.

Minor changes:

* Switch remaining notEquals/indexOf uses to the preferred
  `assert.true( str.includes() )` idiom.

* Fix duplicate printing of error message due to V8's `Error#stack`,
  as used by onUncaughtException.
  Ref #1629.

* Start normalizing stderror in tests like we do with stdout.

* Account for qunit.js stack frames from native Promise in V8,
  which doesn't include a function name or paranthesis.

Ref #1446.
Ref #1633.
Krinkle added a commit that referenced this pull request Jul 5, 2021
Capture the status quo before changing it.

Minor changes:

* Switch remaining notEquals/indexOf uses to the preferred
  `assert.true( str.includes() )` idiom.

* Fix duplicate printing of error message due to V8's `Error#stack`,
  as used by onUncaughtException.
  Ref #1629.

* Start normalizing stderror in tests like we do with stdout.

* Account for qunit.js stack frames from native Promise in V8,
  which doesn't include a function name or paranthesis.

Ref #1446.
Ref #1633.
@steveszc
Copy link

steveszc commented Sep 27, 2021

@Krinkle Just came across this change as it is breaking some functionality in https://github.com/steveszc/ember-cli-memory-leak-detector

My use-case is that I need to inject a test after all other tests have finished running (once the tests finish I capture a heap snapshot, search it for memory leaks created during the test run, and create a new test that either passes or fails depending on whether memory leaks were found).

I previously relied on the (apparently unintentional) ability to create a test after the test suite has finished. I understand that I have a rather unconventional use-case but it would great if I can continue reporting memory leaks as a failed test within Qunit as it provides a great DX.

Is there a way to do this moving forward?
Is there another venue/forum where I can bring this up for discussion? Happy to create a new issue if there is no easy work around.

@Krinkle
Copy link
Member Author

Krinkle commented Sep 27, 2021

@steveszc Thanks. I've pulled your comment into a new issue at #1663.

In short: Yes, I believe there should be (and is) a way to do this moving forward. If those aren't a good fit for some reason, then I'll proritise getting the needed accomodation in-place for the next minor release. I'll elaborate at #1663.

Krinkle added a commit that referenced this pull request Jun 1, 2024
Deprecated warning was added in 2.17.0.

Ref #1377.
Ref #1629.
Krinkle added a commit that referenced this pull request Jun 1, 2024
Deprecated warning was added in 2.17.0.

Ref #1377.
Ref #1629.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

Core: hooks invoked after a test is considered torn down can cause error
3 participants