diff --git a/packages/client/src/Client.ts b/packages/client/src/Client.ts index 71df2f8..18e76c0 100644 --- a/packages/client/src/Client.ts +++ b/packages/client/src/Client.ts @@ -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; + /** + * A funcion called to load tests + */ tests(context: CustomContext): void | Promise, } & MochaConfig; @@ -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"); @@ -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(); + }); } }); @@ -478,6 +505,18 @@ export class Client extends ClientEventEmitter { } } + private pendingEvent: Promise = Promise.resolve(); + + /** + * Queue an event to be sent, use this to prevent out of order delivery + */ + private queueEvent(name: string, promisedArgs: Promise | 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 }); diff --git a/packages/react-native/src/index.tsx b/packages/react-native/src/index.tsx index d90b201..1178d73 100644 --- a/packages/react-native/src/index.tsx +++ b/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"; @@ -39,6 +41,17 @@ export const MochaRemoteContext = createContext({ 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({ kind: "waiting" }); @@ -46,14 +59,22 @@ export function MochaRemoteProvider({ children, tests, title = `React Native on 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((resolve) => setImmediate(resolve)); }); // Require in the tests tests(context); + // Make the context available to context consumers + setContext(context); }, }) .on("connection", () => { @@ -98,7 +119,7 @@ export function MochaRemoteProvider({ children, tests, title = `React Native on }, [setStatus, setContext]); return ( - + {children} ); @@ -125,7 +146,7 @@ function getStatusEmoji(status: Status) { } export function StatusEmoji(props: TextProps) { - const {status} = useMochaRemoteContext(); + const { status } = useMochaRemoteContext(); return {getStatusEmoji(status)} } @@ -144,7 +165,7 @@ function getStatusMessage(status: Status) { } export function StatusText(props: TextProps) { - const {status} = useMochaRemoteContext(); + const { status } = useMochaRemoteContext(); return {getStatusMessage(status)} } @@ -157,6 +178,6 @@ function getConnectionMessage(connected: boolean) { } export function ConnectionText(props: TextProps) { - const {connected} = useMochaRemoteContext(); + const { connected } = useMochaRemoteContext(); return {getConnectionMessage(connected)} }