Skip to content

Commit

Permalink
Use react-native Devtools lib to symbolicate error stacks (#111)
Browse files Browse the repository at this point in the history
  • Loading branch information
kraenhansen committed Mar 20, 2024
1 parent be1b941 commit 38dbf3b
Show file tree
Hide file tree
Showing 2 changed files with 73 additions and 13 deletions.
55 changes: 47 additions & 8 deletions packages/client/src/Client.ts
Expand Up @@ -83,7 +83,13 @@ export type ClientConfig = {
* @default "bdd"
*/
ui: InterfaceConfig;
/** A funcion called to load tests */
/**
* Called when a test fails error occurs to allow transformations of the stacktrace
*/
transformFailure?: (test: Mocha.Test, error: Error) => Promise<Error>;
/**
* A funcion called to load tests
*/
tests(context: CustomContext): void | Promise<void>,
} & MochaConfig;

Expand Down Expand Up @@ -295,11 +301,30 @@ export class Client extends ClientEventEmitter {
});

// Setup listeners for all events emitted by the runner
for (const name in Runner.constants) {
if (name.startsWith("EVENT")) {
const eventName = Runner.constants[name as keyof typeof Runner.constants];
runner.on(eventName, this.sendEvent.bind(this, eventName));
}
const mappedEventKeys = Object.keys(Runner.constants).filter(k => k.startsWith("EVENT")) as (keyof typeof Runner.constants)[];
const mappedEventNames = new Set(mappedEventKeys.map(k => Runner.constants[k]));

const { transformFailure } = this.config;
if (transformFailure) {
// Don't automatically map the "fail" event
mappedEventNames.delete(Runner.constants.EVENT_TEST_FAIL);
// Register a listener which allows the user to transform the failure
runner.on(Runner.constants.EVENT_TEST_FAIL, (test, error) => {
this.queueEvent(Runner.constants.EVENT_TEST_FAIL,
transformFailure(test, error).then((transformedError) => {
return [test, transformedError];
}, cause => {
const err = new Error(`Failed to transform failure: ${cause.message}`, { cause });
return [test, err];
})
);
});
}

for (const eventName of mappedEventNames) {
runner.on(eventName, (...args) => {
this.queueEvent(eventName, args);
});
}

this.debug("Running test suite");
Expand All @@ -312,8 +337,10 @@ export class Client extends ClientEventEmitter {
runner.once(Runner.constants.EVENT_RUN_END, () => {
this.emit("end", runner.failures);
if (this.config.autoDisconnect) {
this.debug("Disconnecting automatically after ended run");
this.disconnect();
this.debug("Disconnecting automatically after ended run and pending events");
this.pendingEvent.then(() => {
this.disconnect();
});
}
});

Expand Down Expand Up @@ -478,6 +505,18 @@ export class Client extends ClientEventEmitter {
}
}

private pendingEvent: Promise<void> = Promise.resolve();

/**
* Queue an event to be sent, use this to prevent out of order delivery
*/
private queueEvent(name: string, promisedArgs: Promise<unknown[]> | unknown[]) {
this.pendingEvent = this.pendingEvent.then(async () => {
const args = await promisedArgs;
this.sendEvent(name, ...args);
});
}

private sendEvent(name: string, ...args: unknown[]) {
try {
this.send({ action: "event", name, args });
Expand Down
31 changes: 26 additions & 5 deletions packages/react-native/src/index.tsx
@@ -1,5 +1,7 @@
import React, { useEffect, useState, createContext, useContext } from "react";
import { Text, Platform, TextProps } from 'react-native';
import parseErrorStack, { StackFrame } from 'react-native/Libraries/Core/Devtools/parseErrorStack';
import symbolicateStackTrace from 'react-native/Libraries/Core/Devtools/symbolicateStackTrace';

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

Expand Down Expand Up @@ -39,21 +41,40 @@ export const MochaRemoteContext = createContext<MochaRemoteContextValue>({
context: {},
});

function isExternalFrame({ file }: StackFrame) {
return !file.includes("/mocha-remote/packages/client/dist/") && !file.includes("/mocha-remote-client/dist/")
}

function framesToStack(error: Error, frames: StackFrame[]) {
const lines = frames.filter(isExternalFrame).map(({ methodName, column, file, lineNumber }) => {
return ` at ${methodName} (${file}:${lineNumber}:${column})`
});
return `${error.name}: ${error.message}\n${lines.join("\n")}`;
}

export function MochaRemoteProvider({ children, tests, title = `React Native on ${Platform.OS}` }: MochaRemoteProviderProps) {
const [connected, setConnected] = useState(false);
const [status, setStatus] = useState<Status>({ kind: "waiting" });
const [context, setContext] = useState<CustomContext>({});
useEffect(() => {
const client = new Client({
title,
async transformFailure(_, err) {
// TODO: Remove the two `as any` once https://github.com/facebook/react-native/pull/43566 gets released
const stack = parseErrorStack(err.stack as any);
const symbolicated = await symbolicateStackTrace(stack) as any;
err.stack = framesToStack(err, symbolicated.stack);
return err;
},
tests(context) {
setContext(context);
// Adding an async hook before each test to allow the UI to update
beforeEach("async-pause", () => {
return new Promise<void>((resolve) => setImmediate(resolve));
});
// Require in the tests
tests(context);
// Make the context available to context consumers
setContext(context);
},
})
.on("connection", () => {
Expand Down Expand Up @@ -98,7 +119,7 @@ export function MochaRemoteProvider({ children, tests, title = `React Native on
}, [setStatus, setContext]);

return (
<MochaRemoteContext.Provider value={{status, connected, context}}>
<MochaRemoteContext.Provider value={{ status, connected, context }}>
{children}
</MochaRemoteContext.Provider>
);
Expand All @@ -125,7 +146,7 @@ function getStatusEmoji(status: Status) {
}

export function StatusEmoji(props: TextProps) {
const {status} = useMochaRemoteContext();
const { status } = useMochaRemoteContext();
return <Text {...props}>{getStatusEmoji(status)}</Text>
}

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

export function StatusText(props: TextProps) {
const {status} = useMochaRemoteContext();
const { status } = useMochaRemoteContext();
return <Text {...props}>{getStatusMessage(status)}</Text>
}

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

export function ConnectionText(props: TextProps) {
const {connected} = useMochaRemoteContext();
const { connected } = useMochaRemoteContext();
return <Text {...props}>{getConnectionMessage(connected)}</Text>
}

0 comments on commit 38dbf3b

Please sign in to comment.