Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/devtools/client/webconsole/actions/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const {
const { IdGenerator } = require("devtools/client/webconsole/utils/id-generator");
const { ThreadFront } = require("protocol/thread");
const { LogpointHandlers } = require("protocol/logpoint");
const { TestMessageHandlers } = require("protocol/find-tests");

const {
MESSAGES_ADD,
Expand All @@ -36,6 +37,7 @@ export function setupMessages(store) {
LogpointHandlers.onResult = (logGroupId, point, time, location, pause, values) =>
store.dispatch(onLogpointResult(logGroupId, point, time, location, pause, values));
LogpointHandlers.clearLogpoint = logGroupId => store.dispatch(messagesClearLogpoint(logGroupId));
TestMessageHandlers.onTestMessage = msg => store.dispatch(onConsoleMessage(msg));

ThreadFront.findConsoleMessages(
(_, msg) => store.dispatch(onConsoleMessage(msg)),
Expand Down
251 changes: 251 additions & 0 deletions src/protocol/find-tests.ts
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");
Copy link

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. handleTestEvent in the env?), but that might not be feasible - I have no idea how this project works.

Copy link
Contributor Author

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:

if (process.addRecordReplayAnnotations) {
  process.addRecordReplayAnnotation("JestTestStart", { ...info about test... });
}

I don't know if it would be possible to use handleTestEvent for 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.

Copy link

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 a handleTestEvent call, but I haven't verified. In that case you can at the very least point to where the test function is

Copy link
Contributor Author

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.

Copy link

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

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;
} = {};
2 changes: 1 addition & 1 deletion src/protocol/logpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ function saveAnalysisError(locations: Location[], condition: string) {
}

// Define some logpoint helpers to manage pause data.
const Helpers = `
export const Helpers = `
const finalData = { frames: [], scopes: [], objects: [] };
function addPauseData({ frames, scopes, objects }) {
finalData.frames.push(...(frames || []));
Expand Down
3 changes: 3 additions & 0 deletions src/ui/actions/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { UIThunkAction } from "ui/actions";
import * as actions from "ui/actions/app";
import * as selectors from "ui/reducers/app";
import { ThreadFront } from "protocol/thread";
import { findAutomatedTests } from "protocol/find-tests";
import { assert, waitForTime } from "protocol/utils";
import { validateUUID } from "ui/utils/helpers";
import { prefs } from "ui/utils/prefs";
Expand Down Expand Up @@ -145,6 +146,8 @@ export function createSession(recordingId: string): UIThunkAction {
const recordingTarget = await ThreadFront.recordingTargetWaiter.promise;
dispatch(actions.setRecordingTarget(recordingTarget));

findAutomatedTests();

// We don't want to show the non-dev version of the app for node replays.
if (recordingTarget === "node") {
dispatch(setViewMode("dev"));
Expand Down