diff --git a/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevServerHelper.java b/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevServerHelper.java index ce934bc7b5f..1032db45c37 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevServerHelper.java +++ b/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevServerHelper.java @@ -13,7 +13,10 @@ import javax.annotation.Nullable; import java.io.File; import java.io.IOException; +import java.util.Arrays; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Locale; import java.util.Map; import java.util.concurrent.TimeUnit; @@ -27,9 +30,11 @@ import android.os.Handler; import com.facebook.common.logging.FLog; import com.facebook.infer.annotation.Assertions; import com.facebook.react.bridge.UiThreadUtil; +import com.facebook.react.common.MapBuilder; import com.facebook.react.common.ReactConstants; import com.facebook.react.common.network.OkHttpCallUtil; import com.facebook.react.devsupport.interfaces.PackagerStatusCallback; +import com.facebook.react.devsupport.interfaces.StackFrame; import com.facebook.react.modules.systeminfo.AndroidInfoHelpers; import com.facebook.react.packagerconnection.FileIoHandler; import com.facebook.react.packagerconnection.JSPackagerClient; @@ -38,14 +43,17 @@ import com.facebook.react.packagerconnection.NotificationOnlyHandler; import com.facebook.react.packagerconnection.RequestOnlyHandler; import com.facebook.react.packagerconnection.Responder; +import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import okhttp3.Call; import okhttp3.Callback; import okhttp3.ConnectionPool; +import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Request; +import okhttp3.RequestBody; import okhttp3.Response; import okhttp3.ResponseBody; import okio.Buffer; @@ -79,6 +87,7 @@ public class DevServerHelper { private static final String PACKAGER_STATUS_URL_FORMAT = "http://%s/status"; private static final String HEAP_CAPTURE_UPLOAD_URL_FORMAT = "http://%s/jscheapcaptureupload"; private static final String INSPECTOR_DEVICE_URL_FORMAT = "http://%s/inspector/device?name=%s"; + private static final String SYMBOLICATE_URL_FORMAT = "http://%s/symbolicate"; private static final String PACKAGER_OK_STATUS = "packager-status:running"; @@ -102,6 +111,10 @@ public class DevServerHelper { void onPokeSamplingProfilerCommand(@Nullable final Responder responder); } + public interface SymbolicationListener { + void onSymbolicationComplete(@Nullable Iterable stackFrames); + } + private final DevInternalSettings mSettings; private final OkHttpClient mClient; private final Handler mRestartOnChangePollingHandler; @@ -154,7 +167,10 @@ public class DevServerHelper { }); handlers.putAll(new FileIoHandler().handlers()); - mPackagerClient = new JSPackagerClient("devserverhelper", mSettings.getPackagerConnectionSettings(), handlers); + mPackagerClient = new JSPackagerClient( + "devserverhelper", + mSettings.getPackagerConnectionSettings(), + handlers); mPackagerClient.init(); return null; @@ -209,17 +225,72 @@ public class DevServerHelper { }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); } + public void symbolicateStackTrace( + Iterable stackFrames, + final SymbolicationListener listener) { + try { + final String symbolicateURL = createSymbolicateURL( + mSettings.getPackagerConnectionSettings().getDebugServerHost()); + final JSONArray jsonStackFrames = new JSONArray(); + for (final StackFrame stackFrame : stackFrames) { + jsonStackFrames.put(new JSONObject( + MapBuilder.of( + "file", stackFrame.getFile(), + "methodName", stackFrame.getMethod(), + "lineNumber", stackFrame.getLine(), + "column", stackFrame.getColumn()))); + } + final Request request = new Request.Builder() + .url(symbolicateURL) + .post(RequestBody.create( + MediaType.parse("application/json"), + new JSONObject().put("stack", jsonStackFrames).toString())) + .build(); + Call symbolicateCall = Assertions.assertNotNull(mClient.newCall(request)); + symbolicateCall.enqueue(new Callback() { + @Override + public void onFailure(Call call, IOException e) { + FLog.w( + ReactConstants.TAG, + "Got IOException when attempting symbolicate stack trace: " + e.getMessage()); + listener.onSymbolicationComplete(null); + } + + @Override + public void onResponse(Call call, final Response response) throws IOException { + try { + listener.onSymbolicationComplete(Arrays.asList( + StackTraceHelper.convertJsStackTrace(new JSONObject( + response.body().string()).getJSONArray("stack")))); + } catch (JSONException exception) { + listener.onSymbolicationComplete(null); + } + } + }); + } catch (JSONException e) { + FLog.w( + ReactConstants.TAG, + "Got JSONException when attempting symbolicate stack trace: " + e.getMessage()); + } + } + /** Intent action for reloading the JS */ public static String getReloadAppAction(Context context) { return context.getPackageName() + RELOAD_APP_ACTION_SUFFIX; } public String getWebsocketProxyURL() { - return String.format(Locale.US, WEBSOCKET_PROXY_URL_FORMAT, mSettings.getPackagerConnectionSettings().getDebugServerHost()); + return String.format( + Locale.US, + WEBSOCKET_PROXY_URL_FORMAT, + mSettings.getPackagerConnectionSettings().getDebugServerHost()); } public String getHeapCaptureUploadUrl() { - return String.format(Locale.US, HEAP_CAPTURE_UPLOAD_URL_FORMAT, mSettings.getPackagerConnectionSettings().getDebugServerHost()); + return String.format( + Locale.US, + HEAP_CAPTURE_UPLOAD_URL_FORMAT, + mSettings.getPackagerConnectionSettings().getDebugServerHost()); } public String getInspectorDeviceUrl() { @@ -258,7 +329,12 @@ public class DevServerHelper { return mSettings.isHotModuleReplacementEnabled(); } - private static String createBundleURL(String host, String jsModulePath, boolean devMode, boolean hmr, boolean jsMinify) { + private static String createBundleURL( + String host, + String jsModulePath, + boolean devMode, + boolean hmr, + boolean jsMinify) { return String.format(Locale.US, BUNDLE_URL_FORMAT, host, jsModulePath, devMode, hmr, jsMinify); } @@ -266,6 +342,10 @@ public class DevServerHelper { return String.format(Locale.US, RESOURCE_URL_FORMAT, host, resourcePath); } + private static String createSymbolicateURL(String host) { + return String.format(Locale.US, SYMBOLICATE_URL_FORMAT, host); + } + public String getDevServerBundleURL(final String jsModulePath) { return createBundleURL( mSettings.getPackagerConnectionSettings().getDebugServerHost(), @@ -317,10 +397,15 @@ public class DevServerHelper { Matcher match = regex.matcher(contentType); if (match.find()) { String boundary = match.group(1); - MultipartStreamReader bodyReader = new MultipartStreamReader(response.body().source(), boundary); + MultipartStreamReader bodyReader = new MultipartStreamReader( + response.body().source(), + boundary); boolean completed = bodyReader.readAllParts(new MultipartStreamReader.ChunkCallback() { @Override - public void execute(Map headers, Buffer body, boolean finished) throws IOException { + public void execute( + Map headers, + Buffer body, + boolean finished) throws IOException { // This will get executed for every chunk of the multipart response. The last chunk // (finished = true) will be the JS bundle, the other ones will be progress events // encoded as JSON. @@ -332,7 +417,8 @@ public class DevServerHelper { } processBundleResult(url, status, body, outputFile, callback); } else { - if (!headers.containsKey("Content-Type") || !headers.get("Content-Type").equals("application/json")) { + if (!headers.containsKey("Content-Type") || + !headers.get("Content-Type").equals("application/json")) { return; } try { @@ -358,12 +444,21 @@ public class DevServerHelper { }); if (!completed) { callback.onFailure(new DebugServerException( - "Error while reading multipart response.\n\nResponse code: " + response.code() + "\n\n" + - "URL: " + call.request().url().toString() + "\n\n")); + "Error while reading multipart response.\n\nResponse code: " + + response.code() + "\n\n" + "URL: " + call.request().url().toString() + + "\n\n")); } } else { - // In case the server doesn't support multipart/mixed responses, fallback to normal download. - processBundleResult(url, response.code(), Okio.buffer(response.body().source()), outputFile, callback); + /** + * In case the server doesn't support multipart/mixed responses, + * fallback to normal download. + */ + processBundleResult( + url, + response.code(), + Okio.buffer(response.body().source()), + outputFile, + callback); } } }); @@ -383,8 +478,12 @@ public class DevServerHelper { callback.onFailure(debugServerException); } else { StringBuilder sb = new StringBuilder(); - sb.append("The development server returned response error code: ").append(statusCode).append("\n\n") - .append("URL: ").append(url).append("\n\n") + sb.append("The development server returned response error code: ") + .append(statusCode) + .append("\n\n") + .append("URL: ") + .append(url) + .append("\n\n") .append("Body:\n") .append(bodyString); callback.onFailure(new DebugServerException(sb.toString())); @@ -412,7 +511,8 @@ public class DevServerHelper { } public void isPackagerRunning(final PackagerStatusCallback callback) { - String statusURL = createPackagerStatusURL(mSettings.getPackagerConnectionSettings().getDebugServerHost()); + String statusURL = createPackagerStatusURL( + mSettings.getPackagerConnectionSettings().getDebugServerHost()); Request request = new Request.Builder() .url(statusURL) .build(); @@ -532,11 +632,17 @@ public class DevServerHelper { } private String createOnChangeEndpointUrl() { - return String.format(Locale.US, ONCHANGE_ENDPOINT_URL_FORMAT, mSettings.getPackagerConnectionSettings().getDebugServerHost()); + return String.format( + Locale.US, + ONCHANGE_ENDPOINT_URL_FORMAT, + mSettings.getPackagerConnectionSettings().getDebugServerHost()); } private String createLaunchJSDevtoolsCommandUrl() { - return String.format(Locale.US, LAUNCH_JS_DEVTOOLS_COMMAND_URL_FORMAT, mSettings.getPackagerConnectionSettings().getDebugServerHost()); + return String.format( + Locale.US, + LAUNCH_JS_DEVTOOLS_COMMAND_URL_FORMAT, + mSettings.getPackagerConnectionSettings().getDebugServerHost()); } public void launchJSDevtools() { @@ -558,18 +664,37 @@ public class DevServerHelper { } public String getSourceMapUrl(String mainModuleName) { - return String.format(Locale.US, SOURCE_MAP_URL_FORMAT, mSettings.getPackagerConnectionSettings().getDebugServerHost(), mainModuleName, getDevMode(), getHMR(), getJSMinifyMode()); + return String.format( + Locale.US, + SOURCE_MAP_URL_FORMAT, + mSettings.getPackagerConnectionSettings().getDebugServerHost(), + mainModuleName, + getDevMode(), + getHMR(), + getJSMinifyMode()); } public String getSourceUrl(String mainModuleName) { - return String.format(Locale.US, BUNDLE_URL_FORMAT, mSettings.getPackagerConnectionSettings().getDebugServerHost(), mainModuleName, getDevMode(), getHMR(), getJSMinifyMode()); + return String.format( + Locale.US, + BUNDLE_URL_FORMAT, + mSettings.getPackagerConnectionSettings().getDebugServerHost(), + mainModuleName, + getDevMode(), + getHMR(), + getJSMinifyMode()); } public String getJSBundleURLForRemoteDebugging(String mainModuleName) { // The host IP we use when connecting to the JS bundle server from the emulator is not the // same as the one needed to connect to the same server from the JavaScript proxy running on the // host itself. - return createBundleURL(getHostForJSProxy(), mainModuleName, getDevMode(), getHMR(), getJSMinifyMode()); + return createBundleURL( + getHostForJSProxy(), + mainModuleName, + getDevMode(), + getHMR(), + getJSMinifyMode()); } /** @@ -581,7 +706,9 @@ public class DevServerHelper { public @Nullable File downloadBundleResourceFromUrlSync( final String resourcePath, final File outputFile) { - final String resourceURL = createResourceURL(mSettings.getPackagerConnectionSettings().getDebugServerHost(), resourcePath); + final String resourceURL = createResourceURL( + mSettings.getPackagerConnectionSettings().getDebugServerHost(), + resourcePath); final Request request = new Request.Builder() .url(resourceURL) .build(); diff --git a/ReactAndroid/src/main/java/com/facebook/react/devsupport/StackTraceHelper.java b/ReactAndroid/src/main/java/com/facebook/react/devsupport/StackTraceHelper.java index 3eab17d6944..22360b9368c 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/devsupport/StackTraceHelper.java +++ b/ReactAndroid/src/main/java/com/facebook/react/devsupport/StackTraceHelper.java @@ -12,11 +12,17 @@ package com.facebook.react.devsupport; import javax.annotation.Nullable; import java.io.File; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import com.facebook.react.bridge.ReadableArray; import com.facebook.react.bridge.ReadableMap; import com.facebook.react.devsupport.interfaces.StackFrame; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + /** * Helper class converting JS and Java stack traces into arrays of {@link StackFrame} objects. */ @@ -25,6 +31,9 @@ public class StackTraceHelper { public static final java.lang.String COLUMN_KEY = "column"; public static final java.lang.String LINE_NUMBER_KEY = "lineNumber"; + private static final Pattern STACK_FRAME_PATTERN = Pattern.compile( + "^(?:(.*?)@)?(.*?)\\:([0-9]+)\\:([0-9]+)$"); + /** * Represents a generic entry in a stack trace, be it originally from JS or Java. */ @@ -117,6 +126,56 @@ public class StackTraceHelper { return result; } + /** + * Convert a JavaScript stack trace (see {@code parseErrorStack} JS module) to an array of + * {@link StackFrame}s. + */ + public static StackFrame[] convertJsStackTrace(JSONArray stack) { + int size = stack != null ? stack.length() : 0; + StackFrame[] result = new StackFrame[size]; + try { + for (int i = 0; i < size; i++) { + JSONObject frame = stack.getJSONObject(i); + String methodName = frame.getString("methodName"); + String fileName = frame.getString("file"); + int lineNumber = -1; + if (frame.has(LINE_NUMBER_KEY) && !frame.isNull(LINE_NUMBER_KEY)) { + lineNumber = frame.getInt(LINE_NUMBER_KEY); + } + int columnNumber = -1; + if (frame.has(COLUMN_KEY) && !frame.isNull(COLUMN_KEY)) { + columnNumber = frame.getInt(COLUMN_KEY); + } + result[i] = new StackFrameImpl(fileName, methodName, lineNumber, columnNumber); + } + } catch (JSONException exception) { + throw new RuntimeException(exception); + } + return result; + } + + /** + * Convert a JavaScript stack trace to an array of {@link StackFrame}s. + */ + public static StackFrame[] convertJsStackTrace(String stack) { + String[] stackTrace = stack.split("\n"); + StackFrame[] result = new StackFrame[stackTrace.length]; + for (int i = 0; i < stackTrace.length; ++i) { + Matcher matcher = STACK_FRAME_PATTERN.matcher(stackTrace[i]); + if (!matcher.find()) { + throw new IllegalArgumentException( + "Unexpected stack frame format: " + stackTrace[i]); + } + + result[i] = new StackFrameImpl( + matcher.group(2), + matcher.group(1) == null ? "(unknown)" : matcher.group(1), + Integer.parseInt(matcher.group(3)), + Integer.parseInt(matcher.group(4))); + } + return result; + } + /** * Convert a {@link Throwable} to an array of {@link StackFrame}s. */ diff --git a/ReactAndroid/src/test/java/com/facebook/react/devsupport/BUCK b/ReactAndroid/src/test/java/com/facebook/react/devsupport/BUCK index 5d0cf4b931e..605c934b5c1 100644 --- a/ReactAndroid/src/test/java/com/facebook/react/devsupport/BUCK +++ b/ReactAndroid/src/test/java/com/facebook/react/devsupport/BUCK @@ -22,6 +22,7 @@ rn_robolectric_test( react_native_target("java/com/facebook/react/bridge:bridge"), react_native_target("java/com/facebook/react/common:common"), react_native_target("java/com/facebook/react/devsupport:devsupport"), + react_native_target("java/com/facebook/react/devsupport:interfaces"), react_native_tests_target("java/com/facebook/react/bridge:testhelpers"), ], ) diff --git a/ReactAndroid/src/test/java/com/facebook/react/devsupport/StackTraceHelperTest.java b/ReactAndroid/src/test/java/com/facebook/react/devsupport/StackTraceHelperTest.java new file mode 100644 index 00000000000..cb44a46f4d9 --- /dev/null +++ b/ReactAndroid/src/test/java/com/facebook/react/devsupport/StackTraceHelperTest.java @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +package com.facebook.react.devsupport; + +import com.facebook.react.devsupport.interfaces.StackFrame; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import static org.fest.assertions.api.Assertions.assertThat; +import static org.fest.assertions.api.Assertions.failBecauseExceptionWasNotThrown; + +@RunWith(RobolectricTestRunner.class) +public class StackTraceHelperTest { + + @Test + public void testParseStackFrameWithMethod() { + final StackFrame frame = StackTraceHelper.convertJsStackTrace( + "render@Test.bundle:1:2000")[0]; + assertThat(frame.getMethod()).isEqualTo("render"); + assertThat(frame.getFileName()).isEqualTo("Test.bundle"); + assertThat(frame.getLine()).isEqualTo(1); + assertThat(frame.getColumn()).isEqualTo(2000); + } + + @Test + public void testParseStackFrameWithoutMethod() { + final StackFrame frame = StackTraceHelper.convertJsStackTrace( + "Test.bundle:1:2000")[0]; + assertThat(frame.getMethod()).isEqualTo("(unknown)"); + assertThat(frame.getFileName()).isEqualTo("Test.bundle"); + assertThat(frame.getLine()).isEqualTo(1); + assertThat(frame.getColumn()).isEqualTo(2000); + } + + @Test + public void testParseStackFrameWithInvalidFrame() { + try { + StackTraceHelper.convertJsStackTrace("Test.bundle:ten:twenty"); + failBecauseExceptionWasNotThrown(IllegalArgumentException.class); + } catch (Exception e) { + assertThat(e).isInstanceOf(IllegalArgumentException.class); + } + } +}