| // Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file | |
| // for details. All rights reserved. Use of this source code is governed by a | |
| // BSD-style license that can be found in the LICENSE file. | |
| import 'dart:math' as math; | |
| import 'package:dwds/src/config/tool_configuration.dart'; | |
| import 'package:dwds/src/connections/app_connection.dart'; | |
| import 'package:dwds/src/debugging/classes.dart'; | |
| import 'package:dwds/src/debugging/debugger.dart'; | |
| import 'package:dwds/src/debugging/execution_context.dart'; | |
| import 'package:dwds/src/debugging/inspector.dart'; | |
| import 'package:dwds/src/debugging/instance.dart'; | |
| import 'package:dwds/src/debugging/libraries.dart'; | |
| import 'package:dwds/src/debugging/location.dart'; | |
| import 'package:dwds/src/debugging/metadata/provider.dart'; | |
| import 'package:dwds/src/debugging/remote_debugger.dart'; | |
| import 'package:dwds/src/readers/asset_reader.dart'; | |
| import 'package:dwds/src/utilities/conversions.dart'; | |
| import 'package:dwds/src/utilities/dart_uri.dart'; | |
| import 'package:dwds/src/utilities/domain.dart'; | |
| import 'package:dwds/src/utilities/objects.dart'; | |
| import 'package:dwds/src/utilities/server.dart'; | |
| import 'package:dwds/src/utilities/shared.dart'; | |
| import 'package:logging/logging.dart'; | |
| import 'package:vm_service/vm_service.dart'; | |
| import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart'; | |
| /// An inspector for a running Dart application contained in the | |
| /// [WipConnection]. | |
| /// | |
| /// Provides information about currently loaded scripts and objects and support | |
| /// for eval. | |
| class ChromeAppInspector extends AppInspector { | |
| final _logger = Logger('AppInspector'); | |
| final RemoteDebugger remoteDebugger; | |
| final ExecutionContext _executionContext; | |
| @override | |
| late final ChromeLibraryHelper libraryHelper = ChromeLibraryHelper(this); | |
| late ChromeAppClassHelper _classHelper; | |
| late ChromeAppInstanceHelper _instanceHelper; | |
| final AssetReader _assetReader; | |
| final Locations _locations; | |
| /// JavaScript expression that evaluates to the Dart stack trace mapper. | |
| static const stackTraceMapperExpression = '\$dartStackTraceUtility.mapper'; | |
| /// Regex used to extract the message from an exception description. | |
| static final exceptionMessageRegex = RegExp(r'^.*$', multiLine: true); | |
| /// Regex used to extract a stack trace line from the exception description. | |
| static final stackTraceLineRegex = RegExp(r'^\s*at\s.*$', multiLine: true); | |
| ChromeAppInspector._( | |
| super.appConnection, | |
| super.isolate, | |
| this.remoteDebugger, | |
| this._assetReader, | |
| this._locations, | |
| super.root, | |
| this._executionContext, | |
| ); | |
| /// Reset all caches and recompute any mappings. | |
| /// | |
| /// Should be called across hot reloads with a valid [ModifiedModuleReport]. | |
| @override | |
| Future<void> initialize({ModifiedModuleReport? modifiedModuleReport}) async { | |
| await super.initialize(modifiedModuleReport: modifiedModuleReport); | |
| // TODO(srujzs): We can invalidate these in a smarter way instead of | |
| // reinitializing when doing a hot reload, but these helpers recompute info | |
| // on demand later and therefore are not in the critical path. | |
| _classHelper = ChromeAppClassHelper(this); | |
| _instanceHelper = ChromeAppInstanceHelper(this); | |
| // Populate the extension RPCs in the isolate. | |
| await getExtensionRpcs(); | |
| } | |
| static Future<ChromeAppInspector> create( | |
| AppConnection appConnection, | |
| RemoteDebugger remoteDebugger, | |
| AssetReader assetReader, | |
| Locations locations, | |
| String root, | |
| Debugger debugger, | |
| ExecutionContext executionContext, | |
| ) async { | |
| final id = createId(); | |
| final time = DateTime.now().millisecondsSinceEpoch; | |
| final name = 'main()'; | |
| final isolate = Isolate( | |
| id: id, | |
| number: id, | |
| name: name, | |
| startTime: time, | |
| runnable: true, | |
| pauseOnExit: false, | |
| pauseEvent: Event( | |
| kind: EventKind.kPauseStart, | |
| timestamp: time, | |
| isolate: IsolateRef( | |
| id: id, | |
| name: name, | |
| number: id, | |
| isSystemIsolate: false, | |
| ), | |
| ), | |
| livePorts: 0, | |
| libraries: [], | |
| breakpoints: [], | |
| exceptionPauseMode: debugger.pauseState, | |
| isSystemIsolate: false, | |
| isolateFlags: [], | |
| extensionRPCs: [], | |
| ); | |
| final inspector = ChromeAppInspector._( | |
| appConnection, | |
| isolate, | |
| remoteDebugger, | |
| assetReader, | |
| locations, | |
| root, | |
| executionContext, | |
| ); | |
| debugger.updateInspector(inspector); | |
| await inspector.initialize(); | |
| return inspector; | |
| } | |
| /// Returns the ID for the execution context or null if not found. | |
| Future<int?> get contextId async { | |
| try { | |
| return await _executionContext.id; | |
| } catch (e, s) { | |
| _logger.severe('Missing execution context ID: ', e, s); | |
| return null; | |
| } | |
| } | |
| /// Get the value of the field named [fieldName] from [receiver]. | |
| Future<RemoteObject> loadField(RemoteObject receiver, String fieldName) { | |
| final load = globalToolConfiguration.loadStrategy.dartRuntimeDebugger | |
| .getPropertyJsExpression(fieldName); | |
| return jsCallFunctionOn(receiver, load, []); | |
| } | |
| /// Call a method by name on [receiver], with arguments [positionalArgs] and | |
| /// [namedArgs]. | |
| Future<RemoteObject> _invokeMethod( | |
| RemoteObject receiver, | |
| String methodName, [ | |
| List<RemoteObject> positionalArgs = const [], | |
| Map namedArgs = const {}, | |
| ]) async { | |
| // TODO(alanknight): Support named arguments. | |
| if (namedArgs.isNotEmpty) { | |
| throw UnsupportedError('Named arguments are not yet supported'); | |
| } | |
| // We use the JS pseudo-variable 'arguments' to get the list of all | |
| // arguments. | |
| final send = globalToolConfiguration.loadStrategy.dartRuntimeDebugger | |
| .callInstanceMethodJsExpression(methodName); | |
| final remote = await jsCallFunctionOn(receiver, send, positionalArgs); | |
| return remote; | |
| } | |
| /// Calls Chrome's Runtime.callFunctionOn method. | |
| /// | |
| /// [evalExpression] should be a JS function definition that can accept | |
| /// [arguments]. | |
| Future<RemoteObject> jsCallFunctionOn( | |
| RemoteObject receiver, | |
| String evalExpression, | |
| List<RemoteObject> arguments, { | |
| bool returnByValue = false, | |
| }) async { | |
| final jsArguments = arguments.map(callArgumentFor).toList(); | |
| final response = await remoteDebugger.sendCommand( | |
| 'Runtime.callFunctionOn', | |
| params: { | |
| 'functionDeclaration': evalExpression, | |
| 'arguments': jsArguments, | |
| 'objectId': receiver.objectId, | |
| 'returnByValue': returnByValue, | |
| }, | |
| ); | |
| final result = getResultOrHandleError( | |
| response, | |
| evalContents: evalExpression, | |
| ); | |
| return RemoteObject(result); | |
| } | |
| /// Calls Chrome's Runtime.callFunctionOn method with a global function. | |
| /// | |
| /// [evalExpression] should be a JS function definition that can accept | |
| /// [arguments]. | |
| Future<RemoteObject> _jsCallFunction( | |
| String evalExpression, | |
| List<Object> arguments, { | |
| bool returnByValue = false, | |
| }) async { | |
| final jsArguments = arguments.map(callArgumentFor).toList(); | |
| final response = await remoteDebugger.sendCommand( | |
| 'Runtime.callFunctionOn', | |
| params: { | |
| 'functionDeclaration': evalExpression, | |
| 'arguments': jsArguments, | |
| 'executionContextId': await contextId, | |
| 'returnByValue': returnByValue, | |
| }, | |
| ); | |
| final result = getResultOrHandleError( | |
| response, | |
| evalContents: evalExpression, | |
| ); | |
| return RemoteObject(result); | |
| } | |
| /// Invoke the function named [selector] on the object identified by | |
| /// [targetId]. | |
| /// | |
| /// The [targetId] can be the URL of a Dart library, in which case this means | |
| /// invoking a top-level function. The [arguments] are always strings that are | |
| /// Dart object Ids (which can also be Chrome RemoteObject objectIds that are | |
| /// for non-Dart JS objects.) | |
| Future<RemoteObject> invoke( | |
| String targetId, | |
| String selector, [ | |
| List<dynamic> arguments = const [], | |
| ]) async { | |
| final remoteArguments = arguments | |
| .cast<String>() | |
| .map(remoteObjectFor) | |
| .toList(); | |
| // We special case the Dart library, where invokeMethod won't work because | |
| // it's not really a Dart object. | |
| if (isLibraryId(targetId)) { | |
| final library = await getObject(targetId) as Library; | |
| return await _invokeLibraryFunction(library, selector, remoteArguments); | |
| } else { | |
| return _invokeMethod( | |
| remoteObjectFor(targetId), | |
| selector, | |
| remoteArguments, | |
| ); | |
| } | |
| } | |
| /// Invoke the function named [selector] from [library] with [arguments]. | |
| Future<RemoteObject> _invokeLibraryFunction( | |
| Library library, | |
| String selector, | |
| List<RemoteObject> arguments, | |
| ) { | |
| final libraryUri = library.uri; | |
| if (libraryUri == null) { | |
| throwInvalidParam('invoke', 'library uri is null'); | |
| } | |
| return globalToolConfiguration.loadStrategy.id == 'ddc-library-bundle' | |
| ? _evaluateLibraryMethodWithDdcLibraryBundle( | |
| libraryUri, | |
| selector, | |
| arguments, | |
| ) | |
| : _evaluateInLibrary( | |
| libraryUri, | |
| 'function () { return this.$selector.apply(this, arguments); }', | |
| arguments, | |
| ); | |
| } | |
| /// Evaluate [expression] by calling Chrome's Runtime.evaluate. | |
| Future<RemoteObject> jsEvaluate( | |
| String expression, { | |
| bool returnByValue = false, | |
| bool awaitPromise = false, | |
| }) async { | |
| // TODO(alanknight): Support a version with arguments if needed. | |
| final response = await remoteDebugger.sendCommand( | |
| 'Runtime.evaluate', | |
| params: { | |
| 'expression': expression, | |
| 'returnByValue': returnByValue, | |
| 'awaitPromise': awaitPromise, | |
| 'contextId': await contextId, | |
| }, | |
| ); | |
| final result = getResultOrHandleError(response, evalContents: expression); | |
| return RemoteObject(result); | |
| } | |
| /// Evaluate the JS function with source [jsFunction] in the context of | |
| /// the library identified by [libraryUri] with [arguments]. | |
| Future<RemoteObject> _evaluateInLibrary( | |
| String libraryUri, | |
| String jsFunction, | |
| List<RemoteObject> arguments, | |
| ) async { | |
| final findLibraryJsExpression = globalToolConfiguration | |
| .loadStrategy | |
| .dartRuntimeDebugger | |
| .callLibraryMethodJsExpression(libraryUri, jsFunction); | |
| final remoteLibrary = await jsEvaluate(findLibraryJsExpression); | |
| return jsCallFunctionOn(remoteLibrary, jsFunction, arguments); | |
| } | |
| /// Evaluates the specified top-level method [methodName] within the library | |
| /// identified by [libraryUri] using the Dart Development Compiler (DDC) | |
| /// library bundle strategy with the given optional [arguments]. | |
| Future<RemoteObject> _evaluateLibraryMethodWithDdcLibraryBundle( | |
| String libraryUri, | |
| String methodName, [ | |
| List<RemoteObject> arguments = const [], | |
| ]) { | |
| final expression = globalToolConfiguration.loadStrategy.dartRuntimeDebugger | |
| .callLibraryMethodJsExpression(libraryUri, methodName); | |
| return _jsCallFunction(expression, arguments); | |
| } | |
| /// Call [function] with objects referred by [argumentIds] as arguments. | |
| Future<RemoteObject> callFunction( | |
| String function, | |
| Iterable<String> argumentIds, | |
| ) { | |
| final arguments = argumentIds.map(remoteObjectFor).toList(); | |
| return _jsCallFunction(function, arguments); | |
| } | |
| Future<InstanceRef?> instanceRefFor(Object value) => | |
| _instanceHelper.instanceRefFor(value); | |
| Future<Instance?> instanceFor(RemoteObject value) => | |
| _instanceHelper.instanceFor(value); | |
| Future<Library?> getLibrary(String objectId) async { | |
| final libraryRef = await libraryRefFor(objectId); | |
| if (libraryRef == null) return null; | |
| return libraryHelper.libraryFor(libraryRef); | |
| } | |
| Future<Obj> getObject(String objectId, {int? offset, int? count}) async { | |
| try { | |
| final library = await getLibrary(objectId); | |
| if (library != null) { | |
| return library; | |
| } | |
| final clazz = await _classHelper.forObjectId(objectId); | |
| if (clazz != null) { | |
| return clazz; | |
| } | |
| final scriptRef = scriptRefsById[objectId]; | |
| if (scriptRef != null) { | |
| return await _getScript(scriptRef); | |
| } | |
| final instance = await _instanceHelper.instanceFor( | |
| remoteObjectFor(objectId), | |
| offset: offset, | |
| count: count, | |
| ); | |
| if (instance != null) { | |
| return instance; | |
| } | |
| } catch (e, s) { | |
| _logger.fine('getObject $objectId failed', e, s); | |
| rethrow; | |
| } | |
| throw UnsupportedError( | |
| 'Only libraries, instances, classes, and scripts ' | |
| 'are supported for getObject', | |
| ); | |
| } | |
| Future<Script> _getScript(ScriptRef scriptRef) async { | |
| final scriptId = scriptRef.id; | |
| final scriptUri = scriptRef.uri; | |
| if (scriptId == null || scriptUri == null) { | |
| throwInvalidParam('getObject', 'No script info for script $scriptRef'); | |
| } | |
| final serverPath = DartUri(scriptUri, root).serverPath; | |
| final source = await _assetReader.dartSourceContents(serverPath); | |
| if (source == null) { | |
| throwInvalidParam( | |
| 'getObject', | |
| 'No source for $scriptRef with serverPath: $serverPath', | |
| ); | |
| } | |
| final libraryId = scriptIdToLibraryId[scriptId]; | |
| if (libraryId == null) { | |
| throwInvalidParam('getObject', 'No library for script $scriptRef'); | |
| } | |
| return Script( | |
| uri: scriptRef.uri, | |
| library: await libraryRefFor(libraryId), | |
| id: scriptId, | |
| ) | |
| ..tokenPosTable = await _locations.tokenPosTableFor(serverPath) | |
| ..source = source; | |
| } | |
| Future<MemoryUsage> getMemoryUsage() async { | |
| final response = await remoteDebugger.sendCommand('Runtime.getHeapUsage'); | |
| final result = response.result; | |
| if (result == null) { | |
| throw RPCError( | |
| 'getMemoryUsage', | |
| RPCErrorKind.kInternalError.code, | |
| 'Null result from chrome Devtools.', | |
| ); | |
| } | |
| final jsUsage = HeapUsage(result); | |
| final usage = MemoryUsage.parse({ | |
| 'heapUsage': jsUsage.usedSize, | |
| 'heapCapacity': jsUsage.totalSize, | |
| 'externalUsage': 0, | |
| }); | |
| if (usage == null) { | |
| throw RPCError( | |
| 'getMemoryUsage', | |
| RPCErrorKind.kInternalError.code, | |
| 'Failed to parse memory usage result.', | |
| ); | |
| } | |
| return usage; | |
| } | |
| /// Return the VM SourceReport for the given parameters. | |
| /// | |
| /// Currently this implements the 'PossibleBreakpoints' report kind. | |
| Future<SourceReport> getSourceReport( | |
| List<String> reports, { | |
| String? scriptId, | |
| }) { | |
| if (reports.contains(SourceReportKind.kCoverage)) { | |
| throwInvalidParam( | |
| 'getSourceReport', | |
| 'Source report kind ${SourceReportKind.kCoverage} not supported', | |
| ); | |
| } | |
| if (reports.isEmpty) { | |
| throwInvalidParam( | |
| 'getSourceReport', | |
| 'Invalid parameter: no value for source report kind provided.', | |
| ); | |
| } | |
| if (reports.length > 1 || | |
| reports.first != SourceReportKind.kPossibleBreakpoints) { | |
| throwInvalidParam('getSourceReport', 'Unsupported source report kind.'); | |
| } | |
| return _getPossibleBreakpoints(scriptId); | |
| } | |
| Future<SourceReport> _getPossibleBreakpoints(String? scriptId) async { | |
| // TODO(devoncarew): Consider adding some caching for this method. | |
| final scriptRef = scriptWithId(scriptId); | |
| if (scriptRef == null) { | |
| throwInvalidParam('getSourceReport', 'scriptRef not found for $scriptId'); | |
| } | |
| final scriptUri = scriptRef.uri; | |
| if (scriptUri == null) { | |
| throwInvalidParam('getSourceReport', 'scriptUri not found for $scriptId'); | |
| } | |
| final dartUri = DartUri(scriptUri, root); | |
| final mappedLocations = await _locations.locationsForDart( | |
| dartUri.serverPath, | |
| ); | |
| // Unlike the Dart VM, the token positions match exactly to the possible | |
| // breakpoints. This is because the token positions are derived from the | |
| // DDC source maps which Chrome also uses. | |
| final tokenPositions = <int>[ | |
| for (final location in mappedLocations) location.tokenPos, | |
| ]; | |
| tokenPositions.sort(); | |
| final range = SourceReportRange( | |
| scriptIndex: 0, | |
| startPos: tokenPositions.isEmpty ? -1 : tokenPositions.first, | |
| endPos: tokenPositions.isEmpty ? -1 : tokenPositions.last, | |
| compiled: true, | |
| possibleBreakpoints: tokenPositions, | |
| ); | |
| final ranges = [range]; | |
| return SourceReport(scripts: [scriptRef], ranges: ranges); | |
| } | |
| /// Calls the Chrome Runtime.getProperties API for the object with [objectId]. | |
| /// | |
| /// Note that the property names are JS names, e.g. | |
| /// Symbol(DartClass.actualName) and will need to be converted. For a system | |
| /// List or Map, [offset] and/or [count] can be provided to indicate a desired | |
| /// range of entries. They will be ignored if there is no [length]. | |
| Future<List<Property>> getProperties( | |
| String objectId, { | |
| int? offset, | |
| int? count, | |
| int? length, | |
| }) async { | |
| var rangeId = objectId; | |
| // Ignore offset/count if there is no length: | |
| if (length != null) { | |
| if (_isEmptyRange(offset: offset, count: count, length: length)) { | |
| return []; | |
| } | |
| if (_isSubRange(offset: offset, count: count)) { | |
| final range = await _subRange( | |
| objectId, | |
| offset: offset ?? 0, | |
| count: count, | |
| length: length, | |
| ); | |
| rangeId = range.objectId ?? rangeId; | |
| } | |
| } | |
| final jsProperties = await sendCommandAndValidateResult<List>( | |
| remoteDebugger, | |
| method: 'Runtime.getProperties', | |
| resultField: 'result', | |
| params: {'objectId': rangeId, 'ownProperties': true}, | |
| ); | |
| return jsProperties | |
| .map<Property>((each) => Property(each as Map<String, dynamic>)) | |
| .where(_isVisibleProperty) | |
| .toList(); | |
| } | |
| bool _isVisibleProperty(Property property) { | |
| // Filter out any RTI objects from the new DDC type system. See: | |
| // https://github.com/dart-lang/webdev/issues/2316 | |
| final isRtiObject = | |
| property.value?.className?.startsWith('dart_rti.Rti') ?? false; | |
| return !isRtiObject; | |
| } | |
| /// Calculate the number of available elements in the range. | |
| static int _calculateRangeCount({ | |
| int? count, | |
| required int offset, | |
| required int length, | |
| }) => count == null ? length - offset : math.min(count, length - offset); | |
| /// Find a sub-range of the entries for a Map/List when offset and/or count | |
| /// have been specified on a getObject request. | |
| /// | |
| /// If the object referenced by [id] is not a system List or Map then this | |
| /// will just return a RemoteObject for it and ignore [offset], [count] and | |
| /// [length]. If it is, then [length] should be the number of entries in the | |
| /// List/Map and [offset] and [count] should indicate the desired range. | |
| Future<RemoteObject> _subRange( | |
| String id, { | |
| required int offset, | |
| required int length, | |
| int? count, | |
| }) async { | |
| // TODO(#809): Sometimes we already know the type of the object, and | |
| // we could take advantage of that to short-circuit. | |
| final receiver = remoteObjectFor(id); | |
| final rangeCount = _calculateRangeCount( | |
| count: count, | |
| offset: offset, | |
| length: length, | |
| ); | |
| final args = [ | |
| offset, | |
| rangeCount, | |
| ].map(dartIdFor).map(remoteObjectFor).toList(); | |
| // If this is a List, just call sublist. If it's a Map, get the entries, but | |
| // avoid doing a toList on a large map using skip/take to get the section we | |
| // want. To make those alternatives easier in JS, pass both count and end. | |
| final expression = globalToolConfiguration.loadStrategy.dartRuntimeDebugger | |
| .getSubRangeJsExpression(); | |
| return await jsCallFunctionOn(receiver, expression, args); | |
| } | |
| static bool _isEmptyRange({required int length, int? offset, int? count}) { | |
| if (count == 0) return true; | |
| if (offset == null) return false; | |
| return offset >= length; | |
| } | |
| static bool _isSubRange({int? offset, int? count}) { | |
| if (offset == 0 && count == null) return false; | |
| return offset != null || count != null; | |
| } | |
| /// Returns true for objects we display for the user. | |
| bool isDisplayableObject(Object? object) => | |
| object is Sentinel || | |
| object is InstanceRef && | |
| !isNativeJsObject(object) && | |
| !isNativeJsError(object); | |
| /// Returns true for non-dart JavaScript objects. | |
| bool isNativeJsObject(InstanceRef instanceRef) { | |
| return _instanceHelper.metadataHelper.isNativeJsObject( | |
| instanceRef.classRef, | |
| ); | |
| } | |
| /// Returns true for JavaScript exceptions. | |
| bool isNativeJsError(InstanceRef instanceRef) { | |
| return _instanceHelper.metadataHelper.isNativeJsError(instanceRef.classRef); | |
| } | |
| /// Runs an eval on the page to compute all existing registered extensions. | |
| /// | |
| /// Combines this with the RPCs registered in the [isolate]. Use this over | |
| /// [Isolate.extensionRPCs] as this computes a live set. | |
| /// | |
| /// Updates [Isolate.extensionRPCs] to this set. | |
| @override | |
| Future<Set<String>> getExtensionRpcs() async { | |
| final expression = globalToolConfiguration.loadStrategy.dartRuntimeDebugger | |
| .getDartDeveloperExtensionNamesJsExpression(); | |
| final extensionRpcs = <String>{}; | |
| final params = { | |
| 'expression': expression, | |
| 'returnByValue': true, | |
| 'contextId': await contextId, | |
| }; | |
| try { | |
| final response = await remoteDebugger.sendCommand( | |
| 'Runtime.evaluate', | |
| params: params, | |
| ); | |
| final result = getResultOrHandleError(response, evalContents: expression); | |
| extensionRpcs.addAll(List.from(result['value'] as List? ?? [])); | |
| } catch (e, s) { | |
| _logger.severe( | |
| 'Error calling Runtime.evaluate with params $params', | |
| e, | |
| s, | |
| ); | |
| } | |
| isolate.extensionRPCs = List<String>.of(extensionRpcs); | |
| return extensionRpcs; | |
| } | |
| /// Convert a JS exception description into a description containing | |
| /// a Dart stack trace. | |
| Future<String> mapExceptionStackTrace(String description) async { | |
| RemoteObject mapperResult; | |
| try { | |
| mapperResult = await _jsCallFunction(stackTraceMapperExpression, <Object>[ | |
| description, | |
| ]); | |
| } catch (_) { | |
| return description; | |
| } | |
| final mappedStack = mapperResult.value?.toString(); | |
| if (mappedStack == null || mappedStack.isEmpty) { | |
| return description; | |
| } | |
| final message = _allLinesBeforeStackTrace(description); | |
| return '$message$mappedStack'; | |
| } | |
| String _allLinesBeforeStackTrace(String description) { | |
| var message = ''; | |
| for (final match in exceptionMessageRegex.allMatches(description)) { | |
| final isStackTraceLine = stackTraceLineRegex.hasMatch(match[0] ?? ''); | |
| if (isStackTraceLine) break; | |
| message += '${match[0]}\n'; | |
| } | |
| return message; | |
| } | |
| } |