-
Notifications
You must be signed in to change notification settings - Fork 133
Show jest tests which ran in the recording as console messages #5496
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
Merged
Merged
Changes from 1 commit
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,251 @@ | ||
| // Perform some analysis to find and describe automated tests that were recorded. | ||
|
|
||
| import { ThreadFront } from "protocol/thread/thread"; | ||
| import analysisManager, { AnalysisHandler, AnalysisParams } from "./analysisManager"; | ||
| import { Helpers } from "./logpoint"; | ||
| import { assert } from "protocol/utils"; | ||
| import { client } from "./socket"; | ||
| import { | ||
| AnalysisEntry, | ||
| ExecutionPoint, | ||
| Message, | ||
| Location, | ||
| PointDescription, | ||
| } from "@recordreplay/protocol"; | ||
|
|
||
| // Information about a jest test which ran in the recording. | ||
| interface JestTestInfo { | ||
| // Hierarchical names for this test, from child to parent. | ||
| names: string[]; | ||
|
|
||
| // Start point of the test. | ||
| startPoint: PointDescription; | ||
|
|
||
| // If the test failed, the place where it failed at. | ||
| errorPoint?: ExecutionPoint; | ||
|
|
||
| // If the test failed, description of the failure. | ||
| errorText?: string; | ||
| } | ||
|
|
||
| // Mapper returning analysis results for jest test callback invocations | ||
| // which are associated with a test instead of a hook, returning the name | ||
| // of the test and the point where the callback is invoked. | ||
| const JestTestMapper = ` | ||
| ${Helpers} | ||
| const { point, time, pauseId } = input; | ||
| const { frameId, functionName } = getTopFrame(); | ||
| const { result: isHookResult } = sendCommand("Pause.evaluateInFrame", { | ||
| frameId, | ||
| expression: "isHook", | ||
| }); | ||
| if (isHookResult.returned && isHookResult.returned.value) { | ||
| return []; | ||
| } | ||
| const names = []; | ||
| const nameExpressions = [ | ||
| "testOrHook.name", | ||
| "testOrHook.parent.name", | ||
| "testOrHook.parent.parent.name", | ||
| "testOrHook.parent.parent.parent.name", | ||
| "testOrHook.parent.parent.parent.parent.name", | ||
| "testOrHook.parent.parent.parent.parent.parent.name", | ||
| ]; | ||
| for (const expression of nameExpressions) { | ||
| const { result: nameResult } = sendCommand("Pause.evaluateInFrame", { | ||
| frameId, | ||
| expression, | ||
| }); | ||
| if (nameResult.returned && nameResult.returned.value) { | ||
| names.push(nameResult.returned.value); | ||
| } else { | ||
| break; | ||
| } | ||
| } | ||
| return [{ | ||
| key: point, | ||
| value: { names }, | ||
| }]; | ||
| `; | ||
|
|
||
| // Manages the state associated with any jest tests within the recording. | ||
| class JestTestState { | ||
| sessionId: string; | ||
|
|
||
| // Locations where the inner callback passed to callAsyncCircusFn is invoked, | ||
| // starting the test or hook. | ||
| invokeCallbackLocations: Location[]; | ||
|
|
||
| // Location of the catch block which indicates a test failure. | ||
| catchBlockLocation: Location; | ||
|
|
||
| // Any tests we found. | ||
| tests: JestTestInfo[] = []; | ||
|
|
||
| constructor(invokeCallbackLocations: Location[], catchBlockLocation: Location) { | ||
| assert(ThreadFront.sessionId); | ||
| this.sessionId = ThreadFront.sessionId; | ||
|
|
||
| this.invokeCallbackLocations = invokeCallbackLocations; | ||
| this.catchBlockLocation = catchBlockLocation; | ||
| } | ||
|
|
||
| async loadTests() { | ||
| const params: AnalysisParams = { | ||
| sessionId: this.sessionId, | ||
| mapper: JestTestMapper, | ||
| effectful: true, | ||
| locations: this.invokeCallbackLocations.map(location => ({ location })), | ||
| }; | ||
|
|
||
| const analysisResults: AnalysisEntry[] = []; | ||
|
|
||
| const handler: AnalysisHandler<void> = {}; | ||
| handler.onAnalysisResult = results => analysisResults.push(...results); | ||
|
|
||
| await analysisManager.runAnalysis(params, handler); | ||
|
|
||
| await Promise.all(analysisResults.map(async ({ key: callPoint, value: { names } }) => { | ||
| const { target } = await client.Debugger.findStepInTarget({ point: callPoint }, this.sessionId); | ||
| if (target.frame) { | ||
| this.tests.push({ names, startPoint: target }); | ||
| } | ||
| })); | ||
| } | ||
|
|
||
| async loadFailures() { | ||
| } | ||
| } | ||
|
|
||
| // Look for a source containing the specified pattern. | ||
| async function findMatchingSourceId(pattern: string): Promise<string | null> { | ||
| await ThreadFront.ensureAllSources(); | ||
| for (const [sourceId, source] of ThreadFront.sources.entries()) { | ||
| if (source.url?.includes(pattern)) { | ||
| return sourceId; | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| // Get the location of the first breakpoint on the specified line. | ||
| // Note: This requires a linear scan of the lines containing breakpoints | ||
| // and will be inefficient if used on large sources. | ||
| async function getBreakpointLocationOnLine(sourceId: string, targetLine: number): Promise<Location | null> { | ||
| const breakpointPositions = await ThreadFront.getBreakpointPositionsCompressed(sourceId); | ||
| for (const { line, columns } of breakpointPositions) { | ||
| if (line == targetLine && columns.length) { | ||
| return { sourceId, line, column: columns[0] }; | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| // Look for places in the recording used to run jest tests. | ||
| async function setupJestTests(): Promise<JestTestState | null> { | ||
| // Look for a source containing the callAsyncCircusFn function which is used to | ||
| // run tests using recent versions of Jest. | ||
| const circusUtilsSourceId = await findMatchingSourceId("jest-circus/build/utils.js"); | ||
| if (!circusUtilsSourceId) { | ||
| return null; | ||
| } | ||
|
|
||
| const { contents } = await ThreadFront.getSourceContents(circusUtilsSourceId); | ||
| const lines = contents.split("\n"); | ||
|
|
||
| // Whether we've seen the start of the callAsyncCircusFn function. | ||
| let foundCallAsyncCircusFn = false; | ||
|
|
||
| const invokeCallbackLocations: Location[] = []; | ||
| let catchBlockLocation: Location | undefined; | ||
|
|
||
| // Whether we are inside the callAsyncCircusFn catch block. | ||
| let insideCatchBlock = false; | ||
|
|
||
| for (let i = 0; i < lines.length; i++) { | ||
| const lineContents = lines[i]; | ||
| const line = i + 1; | ||
|
|
||
| if (lineContents.includes("const callAsyncCircusFn = ")) { | ||
| foundCallAsyncCircusFn = true; | ||
| } | ||
|
|
||
| if (!foundCallAsyncCircusFn) { | ||
| continue; | ||
| } | ||
|
|
||
| // Lines invoking the callback start with "returnedValue = ..." | ||
| // except when setting the initial undefined value of returnedValue. | ||
| if (lineContents.includes("returnedValue = ") && | ||
| !lineContents.includes("returnedValue = undefined")) { | ||
| const location = await getBreakpointLocationOnLine(circusUtilsSourceId, line); | ||
| if (location) { | ||
| invokeCallbackLocations.push(location); | ||
| } | ||
| } | ||
|
|
||
| if (lineContents.includes(".catch(error => {")) { | ||
| insideCatchBlock = true; | ||
| } | ||
|
|
||
| // We should be able to break at this line in the catch block. | ||
| if (insideCatchBlock && lineContents.includes("completed = true")) { | ||
| const location = await getBreakpointLocationOnLine(circusUtilsSourceId, line); | ||
| if (location) { | ||
| catchBlockLocation = location; | ||
| } | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| // There should be three places where the inner callback can be invoked. | ||
| if (invokeCallbackLocations.length != 3) { | ||
| return null; | ||
| } | ||
|
|
||
| // We should have found a catch block location to break at. | ||
| if (!catchBlockLocation) { | ||
| return null; | ||
| } | ||
|
|
||
| return new JestTestState(invokeCallbackLocations, catchBlockLocation); | ||
| } | ||
|
|
||
| async function findJestTests() { | ||
| const state = await setupJestTests(); | ||
| if (!state) { | ||
| return; | ||
| } | ||
|
|
||
| await state.loadTests(); | ||
|
|
||
| await Promise.all(state.tests.map(async ({ names, startPoint }) => { | ||
| let name = names[0] || "<unknown>"; | ||
| for (let i = 1; i < names.length; i++) { | ||
| if (names[i] != "ROOT_DESCRIBE_BLOCK") { | ||
| name = names[i] + " \u25b6 " + name; | ||
| } | ||
| } | ||
| const pause = ThreadFront.ensurePause(startPoint.point, startPoint.time); | ||
| const pauseId = await pause.pauseIdWaiter.promise; | ||
| TestMessageHandlers.onTestMessage?.({ | ||
| source: "ConsoleAPI", | ||
| level: "info", | ||
| text: `JestTest ${name}`, | ||
| point: startPoint, | ||
| pauseId, | ||
| data: {}, | ||
| }); | ||
| })); | ||
|
|
||
| await state.loadFailures(); | ||
| } | ||
|
|
||
| // Look for automated tests associated with a recent version of jest. | ||
| export async function findAutomatedTests() { | ||
| await findJestTests(); | ||
| } | ||
|
|
||
| export const TestMessageHandlers: { | ||
| onTestMessage?: (msg: Message) => void; | ||
| } = {}; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
note that we might start bundling (jestjs/jest#12348) at which point at least the filename will break and possibly the function name (depending on sourcemaps).
Not sure how to handle that. It would be better to have a real hook for this (e.g.
handleTestEventin the env?), but that might not be feasible - I have no idea how this project works.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.
Definitely, the pattern matching on file names and code patterns in this PR is not robust at all, and longer term it would be great to have a way of identifying places where tests started and failed that doesn't rely on detecting these patterns. We have a mechanism to add annotations to a recording which could be used to find these points. It isn't currently exposed in node, but if it was we could have something like:
I don't know if it would be possible to use
handleTestEventfor this --- we would need these annotations to be as close as possible to where the test starts running, so we can find the entry point to the test function which was passed in. Right now we pattern match to detect the calls to the test function, and then step in to each of those calls to find the point to jump to when someone clicks on that test.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.
We have
--testLocationInResults. Would that be enough? I would assume that info is also included in ahandleTestEventcall, but I haven't verified. In that case you can at the very least point to where the test function isThere 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 that would be enough. If the test event callback is notified about every test, and has the name of that test, the file/line/column of the test callback, and whether the test passed, that should be enough information to reconstruct everything we're getting from the pattern matching we're doing. The test callback would call process.addRecordReplayAnnotation with this information, and then the devtools can read that from the recording and use it to figure out where the tests ran.
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, its called when test is declared, execution starts, execution enda etc. I'm not 100% sure the location info is present for all cases, but if not we can probably fix that