Skip to content

Commit 38dbf3b

Browse files
authored
Use react-native Devtools lib to symbolicate error stacks (#111)
1 parent be1b941 commit 38dbf3b

File tree

2 files changed

+73
-13
lines changed

2 files changed

+73
-13
lines changed

packages/client/src/Client.ts

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,13 @@ export type ClientConfig = {
8383
* @default "bdd"
8484
*/
8585
ui: InterfaceConfig;
86-
/** A funcion called to load tests */
86+
/**
87+
* Called when a test fails error occurs to allow transformations of the stacktrace
88+
*/
89+
transformFailure?: (test: Mocha.Test, error: Error) => Promise<Error>;
90+
/**
91+
* A funcion called to load tests
92+
*/
8793
tests(context: CustomContext): void | Promise<void>,
8894
} & MochaConfig;
8995

@@ -295,11 +301,30 @@ export class Client extends ClientEventEmitter {
295301
});
296302

297303
// Setup listeners for all events emitted by the runner
298-
for (const name in Runner.constants) {
299-
if (name.startsWith("EVENT")) {
300-
const eventName = Runner.constants[name as keyof typeof Runner.constants];
301-
runner.on(eventName, this.sendEvent.bind(this, eventName));
302-
}
304+
const mappedEventKeys = Object.keys(Runner.constants).filter(k => k.startsWith("EVENT")) as (keyof typeof Runner.constants)[];
305+
const mappedEventNames = new Set(mappedEventKeys.map(k => Runner.constants[k]));
306+
307+
const { transformFailure } = this.config;
308+
if (transformFailure) {
309+
// Don't automatically map the "fail" event
310+
mappedEventNames.delete(Runner.constants.EVENT_TEST_FAIL);
311+
// Register a listener which allows the user to transform the failure
312+
runner.on(Runner.constants.EVENT_TEST_FAIL, (test, error) => {
313+
this.queueEvent(Runner.constants.EVENT_TEST_FAIL,
314+
transformFailure(test, error).then((transformedError) => {
315+
return [test, transformedError];
316+
}, cause => {
317+
const err = new Error(`Failed to transform failure: ${cause.message}`, { cause });
318+
return [test, err];
319+
})
320+
);
321+
});
322+
}
323+
324+
for (const eventName of mappedEventNames) {
325+
runner.on(eventName, (...args) => {
326+
this.queueEvent(eventName, args);
327+
});
303328
}
304329

305330
this.debug("Running test suite");
@@ -312,8 +337,10 @@ export class Client extends ClientEventEmitter {
312337
runner.once(Runner.constants.EVENT_RUN_END, () => {
313338
this.emit("end", runner.failures);
314339
if (this.config.autoDisconnect) {
315-
this.debug("Disconnecting automatically after ended run");
316-
this.disconnect();
340+
this.debug("Disconnecting automatically after ended run and pending events");
341+
this.pendingEvent.then(() => {
342+
this.disconnect();
343+
});
317344
}
318345
});
319346

@@ -478,6 +505,18 @@ export class Client extends ClientEventEmitter {
478505
}
479506
}
480507

508+
private pendingEvent: Promise<void> = Promise.resolve();
509+
510+
/**
511+
* Queue an event to be sent, use this to prevent out of order delivery
512+
*/
513+
private queueEvent(name: string, promisedArgs: Promise<unknown[]> | unknown[]) {
514+
this.pendingEvent = this.pendingEvent.then(async () => {
515+
const args = await promisedArgs;
516+
this.sendEvent(name, ...args);
517+
});
518+
}
519+
481520
private sendEvent(name: string, ...args: unknown[]) {
482521
try {
483522
this.send({ action: "event", name, args });

packages/react-native/src/index.tsx

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import React, { useEffect, useState, createContext, useContext } from "react";
22
import { Text, Platform, TextProps } from 'react-native';
3+
import parseErrorStack, { StackFrame } from 'react-native/Libraries/Core/Devtools/parseErrorStack';
4+
import symbolicateStackTrace from 'react-native/Libraries/Core/Devtools/symbolicateStackTrace';
35

46
import { Client, CustomContext } from "mocha-remote-client";
57

@@ -39,21 +41,40 @@ export const MochaRemoteContext = createContext<MochaRemoteContextValue>({
3941
context: {},
4042
});
4143

44+
function isExternalFrame({ file }: StackFrame) {
45+
return !file.includes("/mocha-remote/packages/client/dist/") && !file.includes("/mocha-remote-client/dist/")
46+
}
47+
48+
function framesToStack(error: Error, frames: StackFrame[]) {
49+
const lines = frames.filter(isExternalFrame).map(({ methodName, column, file, lineNumber }) => {
50+
return ` at ${methodName} (${file}:${lineNumber}:${column})`
51+
});
52+
return `${error.name}: ${error.message}\n${lines.join("\n")}`;
53+
}
54+
4255
export function MochaRemoteProvider({ children, tests, title = `React Native on ${Platform.OS}` }: MochaRemoteProviderProps) {
4356
const [connected, setConnected] = useState(false);
4457
const [status, setStatus] = useState<Status>({ kind: "waiting" });
4558
const [context, setContext] = useState<CustomContext>({});
4659
useEffect(() => {
4760
const client = new Client({
4861
title,
62+
async transformFailure(_, err) {
63+
// TODO: Remove the two `as any` once https://github.com/facebook/react-native/pull/43566 gets released
64+
const stack = parseErrorStack(err.stack as any);
65+
const symbolicated = await symbolicateStackTrace(stack) as any;
66+
err.stack = framesToStack(err, symbolicated.stack);
67+
return err;
68+
},
4969
tests(context) {
50-
setContext(context);
5170
// Adding an async hook before each test to allow the UI to update
5271
beforeEach("async-pause", () => {
5372
return new Promise<void>((resolve) => setImmediate(resolve));
5473
});
5574
// Require in the tests
5675
tests(context);
76+
// Make the context available to context consumers
77+
setContext(context);
5778
},
5879
})
5980
.on("connection", () => {
@@ -98,7 +119,7 @@ export function MochaRemoteProvider({ children, tests, title = `React Native on
98119
}, [setStatus, setContext]);
99120

100121
return (
101-
<MochaRemoteContext.Provider value={{status, connected, context}}>
122+
<MochaRemoteContext.Provider value={{ status, connected, context }}>
102123
{children}
103124
</MochaRemoteContext.Provider>
104125
);
@@ -125,7 +146,7 @@ function getStatusEmoji(status: Status) {
125146
}
126147

127148
export function StatusEmoji(props: TextProps) {
128-
const {status} = useMochaRemoteContext();
149+
const { status } = useMochaRemoteContext();
129150
return <Text {...props}>{getStatusEmoji(status)}</Text>
130151
}
131152

@@ -144,7 +165,7 @@ function getStatusMessage(status: Status) {
144165
}
145166

146167
export function StatusText(props: TextProps) {
147-
const {status} = useMochaRemoteContext();
168+
const { status } = useMochaRemoteContext();
148169
return <Text {...props}>{getStatusMessage(status)}</Text>
149170
}
150171

@@ -157,6 +178,6 @@ function getConnectionMessage(connected: boolean) {
157178
}
158179

159180
export function ConnectionText(props: TextProps) {
160-
const {connected} = useMochaRemoteContext();
181+
const { connected } = useMochaRemoteContext();
161182
return <Text {...props}>{getConnectionMessage(connected)}</Text>
162183
}

0 commit comments

Comments
 (0)