-
-
Notifications
You must be signed in to change notification settings - Fork 6.5k
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
feat: better timeout errors through deadlines (prototype) #10905
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is pretty clean! I really like this idea and would love to ship it in Jest 27. I wonder if we need some way to opt out - I assume this will add significant overhead to a promise heavy test run. Possibly via some config option? If that's active, then we add the plugin to the babel plugin in babel-jest
.
I didn't add it to the preset. I expect most users want to run it only on their tests, not the rest of their code; not sure how that would work with a preset?
Touched on it above, but I think we don't want it in the preset, but babel-jest
can activate it based on an option?
Also, I wonder if we should somehow apply this within circus as well for tests/hooks that return promises rather than use async-await
? I guess we could document the magic only works with async await
"@babel/template": "^7.3.3", | ||
"@babel/types": "^7.3.3", | ||
"@types/babel__core": "^7.0.0", | ||
"@types/babel__traverse": "^7.0.6" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
unused?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This started as a copy-paste of the hoist plugin. They are currently unused... however.
In the hoist plugin, these things are used in there to ensure that we have resolved the right globals, and to explicitly call jest's globals' expect, instead of just whatever variable named expect
happens to be in scope, like my plugin does.
I wonder if we need to copy (share) the functionality from hoist
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@jeysal thoughts?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah I think Jest is used enough that someone will have a shadowing expect
that means a different thing and run into this, so we'll need to handle it. It should be relatively easy to insert a const identifierGeneratedByBabel = expect
at top level and reference that instead
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I made it prepend const generatedByBabelExpect = require("@jest/globals").expect;
and reference that, which should cover most usecases? I worry about writing it as const generatedByBabelExpect = expect
; that's not going to work in some case, surely.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah that was written a bit hastily, I suppose that would collide with a var expect
declared below or something. Reading the expect
from the global this may work. But I think your solution is good.
@SimenB was there some bugs remaining when using certain things from @jest/globals
that may be relevant here, or is it fine? Maybe I'm confusing it with the globalThis.expect !== require('expect')
thing
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
e.g. detox overrides global.expect
: #10484 (comment)
not sure how much of an issue in practice that is.
It might make more sense to attach this only to the test runner (on test
, or maybe even jest
) rather than adding it to expect
? Not sure
packages/babel-plugin-jest-deadlines/src/__tests__/deadlinesPlugin.test.ts
Show resolved
Hide resolved
packages/babel-plugin-jest-deadlines/src/__tests__/deadlinesPlugin.test.ts
Outdated
Show resolved
Hide resolved
timeoutId = setTimeout(resolve, ms); | ||
}); | ||
const here = new Error(`deadline exceeded (waited here for ${ms}ms)`); | ||
here.stack = here.stack |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should probably guard this behind some check that it actually timed out as accessing the stack is not free
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe this won't run unless the setTimeout
"sleep" above resolves (it's awaited), which doesn't happen in the non-timeout case?
The promise will never resolve most of the time, though. My node isn't quite good enough to know if that's a real problem; whether we leak the closure and some context in it somehow.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you're correct! I do think we'll be leaking as you mention, though.
Could we do something like
async function timeout<T>(promise: Promise<T>, ms: number): Promise<T> {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
let resolvePromise: () => void;
try {
return await Promise.race([
promise,
new Promise<void>((resolve, reject) => {
resolvePromise = resolve;
timeoutId = setTimeout(() => {
timeoutId = undefined;
const here = new Error(`deadline exceeded (waited here for ${ms}ms)`);
here.stack = here.stack
?.split('\n')
.filter(line => !isUs(line))
.join('\n');
reject(here);
}, ms);
}),
]);
} finally {
resolvePromise.?();
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Apparently not. If you reject/throw from within the Promise there, you lose the stack trace, which messes up the whole purpose of this. p-timeout
has the same bug.
I rewrote the old implementation to await something more like your code, but with some booleans for whether it resolved or was cancelled. (pushed)
6a42019
to
4603d5d
Compare
I wouldn't expect it to add significant overhead to regular tests. In the happy path case, you're doing an extra For the type of test I imagine writing ("call this webservice, call that webservice, write this db record, read that db record, assert"), none of those things is even close to showing up on a profiling report, and there's only four of them. If you were to use the plugin to rewrite all of your code, instead of just your tests, then maybe you'll end up with a performance sensitive On the other hand, I'm sure there are people writing tests which are actually sub-second in all cases. |
Outstanding issues I'd like help with:
Things I would like to look at when I've had more sleep, but aren't critical blockers for merging this / some of this / something like this:
|
d8ef10d
to
17b9fe0
Compare
return timeout(promise, deadline() - Date.now()); | ||
} | ||
|
||
function isUs(line: string): boolean { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
do we need this at all? jest-message-util
should already handle filtering out unwanted frames. Might be needed, I haven't tested
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's needed for the tests to pass; you end up with at ../../home/faux/clone/jest/packages/jest-circus/build/deadlineTimeout.js:53:22
in the stack otherwise.
I have no idea whether that could/should be fixed in jest-message-util
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would have thought this line caught it: https://github.com/facebook/jest/blob/d393800b0358c79998c003b978ef162a71fe01e9/packages/jest-message-util/src/index.ts#L50
Not worth it to dig into I think
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It always keeps the first line, and the first line is going to be inside jest, because we're explicitly throwing.
a7dc831
to
a6a0ec3
Compare
The test failures with negative times raise an interesting point.
Also, sometimes the stack trace is absent (only on node 10?). Maybe the |
a6a0ec3
to
dbb7175
Compare
Significant wins here, no more event wrangling, no more mutation, no more weird initialisation, and will probably work in hooks.
This shouldn't really ever be hit; at least, for healthy tests? It will probably never be seen by users, as it should be masked by the rest of the timeout mechanism.
dbb7175
to
42cabaa
Compare
c.f. jestjs#10895, jestjs#10905 Expose the test's timeout back to the the test, so it can make decisions based on this, if it wants. Some proposed uses are included in the above issue, but the implementation has got rather big, and none of the rest absolutely have to be core. This part does.
c.f. jestjs#10895, jestjs#10905 Expose the test's timeout back to the the test, so it can make decisions based on this, if it wants. Some proposed uses are included in the above issue, but the implementation has got rather big, and none of the rest absolutely have to be core. This part does.
c.f. jestjs#10895, jestjs#10905 Expose the test's timeout back to the the test, so it can make decisions based on this, if it wants. Some proposed uses are included in the above issue, but the implementation has got rather big, and none of the rest absolutely have to be core. This part does.
c.f. jestjs#10895, jestjs#10905 Expose the test's timeout back to the the test, so it can make decisions based on this, if it wants. Some proposed uses are included in the above issue, but the implementation has got rather big, and none of the rest absolutely have to be core. This part does.
This PR is stale because it has been open 1 year with no activity. Remove stale label or comment or this will be closed in 30 days. |
This PR was closed because it has been stalled for 30 days with no activity. Please open a new PR if the issue is still relevant, linking to this one. |
This pull request has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs. |
Summary
Prototype implementation of #10895, please see there for the main reasoning. This isn't supposed to be a finished implementation, or even a reasonable way to do it.
This is a partial port of this monkey patchy implementation: https://github.com/FauxFaux/jest-fixup-timeouts
Logical idea:
test.deadline()
, and throughexpect.withinDeadline(Promise)
, which races it against the deadlinea = await foo()
intoa = await expect.withinDeadline(foo())
.Deadline location / hooks
In this attempt, the
deadline
is hidden on thecurrentlyRunningTest
in the global Runner State, which is where the originaltimeout
was stored. An accessor is provided toexpect
and user code.This seemed like a reasonable place, but unlike with the hack, it is not available within
beforeAll
/afterAll
, so fails for those. This doesn't matter to the current main timeout implementation, because it has long forgotten about any "jest" concepts, and is only concerned about "running", which is probably reasonable.I can't see anywhere else to stash it, except as an actual global, i.e. on Runner State, or in a new symbol?
babel plugin
I didn't add it to the preset. I expect most users want to run it only on their tests, not the rest of their code; not sure how that would work with a preset?
On a second look, it probably wants to rewrite
await foo;
as well asawait foo();
.Test plan
There's an e2e test for both features which show how it's supposed to be used.