From 38dbf3b49072b44f35d99f339def45c4e7465eec Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= <kraen.hansen@mongodb.com>
Date: Wed, 20 Mar 2024 13:15:10 +0100
Subject: [PATCH] Use react-native Devtools lib to symbolicate error stacks
 (#111)

---
 packages/client/src/Client.ts       | 55 ++++++++++++++++++++++++-----
 packages/react-native/src/index.tsx | 31 +++++++++++++---
 2 files changed, 73 insertions(+), 13 deletions(-)

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<Error>;
+  /**
+   * A funcion called to load tests
+   */
   tests(context: CustomContext): void | Promise<void>,
 } & 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<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 });
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<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" });
@@ -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<void>((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 (
-    <MochaRemoteContext.Provider value={{status, connected, context}}>
+    <MochaRemoteContext.Provider value={{ status, connected, context }}>
       {children}
     </MochaRemoteContext.Provider>
   );
@@ -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>
 }
 
@@ -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>
 }
 
@@ -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>
 }