diff --git a/.cirrus.yml b/.cirrus.yml index 32547f68a0..791c8c1f13 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -7,6 +7,7 @@ web_shard_template: &WEB_SHARD_TEMPLATE # As of October 2019, the Web shards needed more than 6G of RAM. CPU: 2 MEMORY: 8G + CHROME_NO_SANDBOX: true GOLD_SERVICE_ACCOUNT: ENCRYPTED[3afeea5ac7201151c3d0dc9648862f0462b5e4f55dc600ca8b692319622f7c3eda3d577b1b16cc2ef0311b7314c1c095] script: - dart --enable-asserts ./dev/bots/test.dart @@ -173,6 +174,9 @@ task: - dart --enable-asserts ./dev/bots/test.dart - bash <(curl -s https://codecov.io/bash) -c -f packages/flutter_tools/coverage/lcov.info -F flutter_tool + - name: web_integration_tests + << : *WEB_SHARD_TEMPLATE + - name: web_tests-0-linux << : *WEB_SHARD_TEMPLATE diff --git a/dev/bots/browser.dart b/dev/bots/browser.dart new file mode 100644 index 0000000000..4e3add3c05 --- /dev/null +++ b/dev/bots/browser.dart @@ -0,0 +1,59 @@ +// Copyright 2014 The Flutter Authors. 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:async'; +import 'dart:io' as io; + +import 'package:meta/meta.dart'; +import 'package:shelf/shelf.dart'; +import 'package:shelf/shelf_io.dart' as shelf_io; +import 'package:shelf_static/shelf_static.dart'; + +import 'package:flutter_devicelab/framework/browser.dart'; + +/// Runs Chrome, opens the given `appUrl`, and returns the result reported by the +/// app. +/// +/// The app is served from the `appDirectory`. Typically, the app is built +/// using `flutter build web` and served from `build/web`. +/// +/// The launched app is expected to report the result by sending an HTTP POST +/// request to "/test-result" containing result data as plain text body of the +/// request. This function has no opinion about what that string contains. +Future evalTestAppInChrome({ + @required String appUrl, + @required String appDirectory, + int serverPort = 8080, + int browserDebugPort = 8081, +}) async { + io.HttpServer server; + Chrome chrome; + try { + final Completer resultCompleter = Completer(); + server = await io.HttpServer.bind('localhost', serverPort); + final Cascade cascade = Cascade() + .add((Request request) async { + if (request.requestedUri.path.endsWith('/test-result')) { + resultCompleter.complete(await request.readAsString()); + return Response.ok('Test results received'); + } + return Response.notFound(''); + }) + .add(createStaticHandler(appDirectory)); + shelf_io.serveRequests(server, cascade.handler); + final io.Directory userDataDirectory = io.Directory.systemTemp.createTempSync('chrome_user_data_'); + chrome = await Chrome.launch(ChromeOptions( + headless: true, + debugPort: browserDebugPort, + url: appUrl, + userDataDirectory: userDataDirectory.path, + windowHeight: 500, + windowWidth: 500, + ), onError: resultCompleter.completeError); + return await resultCompleter.future; + } finally { + chrome?.stop(); + await server?.close(); + } +} diff --git a/dev/bots/pubspec.yaml b/dev/bots/pubspec.yaml index 2a86c9dc24..6a1bc71931 100644 --- a/dev/bots/pubspec.yaml +++ b/dev/bots/pubspec.yaml @@ -8,6 +8,8 @@ environment: dependencies: args: 1.5.2 crypto: 2.1.3 + flutter_devicelab: + path: ../devicelab googleapis: 0.54.0 googleapis_auth: 0.2.11+1 http: 0.12.0+4 diff --git a/dev/bots/run_command.dart b/dev/bots/run_command.dart index 2523e4bfe9..1e26032564 100644 --- a/dev/bots/run_command.dart +++ b/dev/bots/run_command.dart @@ -53,6 +53,15 @@ Stream runAndGetStdout(String executable, List arguments, { print('$clock ELAPSED TIME: ${prettyPrintDuration(time.elapsed)} for $green$commandDescription$reset in $cyan$relativeWorkingDir$reset'); } +/// Runs the `executable` and waits until the process exits. +/// +/// If the process exits with a non-zero exit code, exits this process with +/// exit code 1, unless `expectNonZeroExit` is set to true. +/// +/// `outputListener` is called for every line of standard output from the +/// process, and is given the [Process] object. This can be used to interrupt +/// an indefinitely running process, for example, by waiting until the process +/// emits certain output. Future runCommand(String executable, List arguments, { String workingDirectory, Map environment, @@ -63,6 +72,7 @@ Future runCommand(String executable, List arguments, { CapturedOutput output, bool skip = false, bool Function(String) removeLine, + void Function(String, Process) outputListener, }) async { assert( (outputMode == OutputMode.capture) == (output != null), @@ -88,7 +98,13 @@ Future runCommand(String executable, List arguments, { .transform(const Utf8Decoder()) .transform(const LineSplitter()) .where((String line) => removeLine == null || !removeLine(line)) - .map((String line) => '$line\n') + .map((String line) { + final String formattedLine = '$line\n'; + if (outputListener != null) { + outputListener(formattedLine, process); + } + return formattedLine; + }) .transform(const Utf8Encoder()); switch (outputMode) { case OutputMode.print: diff --git a/dev/bots/test.dart b/dev/bots/test.dart index 64cf3dc8f5..e2398e91f1 100644 --- a/dev/bots/test.dart +++ b/dev/bots/test.dart @@ -11,6 +11,7 @@ import 'package:googleapis_auth/auth_io.dart' as auth; import 'package:http/http.dart' as http; import 'package:path/path.dart' as path; +import 'browser.dart'; import 'flutter_compact_formatter.dart'; import 'run_command.dart'; import 'utils.dart'; @@ -116,7 +117,8 @@ Future main(List args) async { 'hostonly_devicelab_tests': _runHostOnlyDeviceLabTests, 'tool_coverage': _runToolCoverage, 'tool_tests': _runToolTests, - 'web_tests': _runWebTests, + 'web_tests': _runWebUnitTests, + 'web_integration_tests': _runWebIntegrationTests, }); } on ExitException catch (error) { error.apply(); @@ -522,7 +524,7 @@ Future _runFrameworkCoverage() async { } } -Future _runWebTests() async { +Future _runWebUnitTests() async { final Map subshards = {}; final Directory flutterPackageDirectory = Directory(path.join(flutterRoot, 'packages', 'flutter')); @@ -585,6 +587,94 @@ Future _runWebTests() async { await selectSubshard(subshards); } +Future _runWebIntegrationTests() async { + await _runWebStackTraceTest('profile'); + await _runWebStackTraceTest('release'); + await _runWebDebugStackTraceTest(); +} + +Future _runWebStackTraceTest(String buildMode) async { + final String testAppDirectory = path.join(flutterRoot, 'dev', 'integration_tests', 'web'); + final String appBuildDirectory = path.join('$testAppDirectory', 'build', 'web'); + + // Build the app. + await runCommand( + flutter, + [ 'clean' ], + workingDirectory: testAppDirectory, + ); + await runCommand( + flutter, + [ + 'build', + 'web', + '--$buildMode', + '-t', + 'lib/stack_trace.dart', + ], + workingDirectory: testAppDirectory, + environment: { + 'FLUTTER_WEB': 'true', + }, + ); + + // Run the app. + final String result = await evalTestAppInChrome( + appUrl: 'http://localhost:8080/index.html', + appDirectory: appBuildDirectory, + ); + + if (result.contains('--- TEST SUCCEEDED ---')) { + print('${green}Web stack trace integration test passed.$reset'); + } else { + print(result); + print('${red}Web stack trace integration test failed.$reset'); + exit(1); + } +} + +/// Debug mode is special because `flutter build web` doesn't build in debug mode. +/// +/// Instead, we use `flutter run --debug` and sniff out the standard output. +Future _runWebDebugStackTraceTest() async { + final String testAppDirectory = path.join(flutterRoot, 'dev', 'integration_tests', 'web'); + final CapturedOutput output = CapturedOutput(); + bool success = false; + await runCommand( + flutter, + [ + 'run', + '--debug', + '-d', + 'chrome', + '--web-run-headless', + 'lib/stack_trace.dart', + ], + output: output, + outputMode: OutputMode.capture, + outputListener: (String line, Process process) { + if (line.contains('--- TEST SUCCEEDED ---')) { + success = true; + } + if (success || line.contains('--- TEST FAILED ---')) { + process.stdin.add('q'.codeUnits); + } + }, + workingDirectory: testAppDirectory, + environment: { + 'FLUTTER_WEB': 'true', + }, + ); + + if (success) { + print('${green}Web stack trace integration test passed.$reset'); + } else { + print(output.stdout); + print('${red}Web stack trace integration test failed.$reset'); + exit(1); + } +} + Future _runFlutterWebTest(String workingDirectory, List tests) async { final List batch = []; for (int i = 0; i < tests.length; i += 1) { diff --git a/dev/devicelab/lib/framework/browser.dart b/dev/devicelab/lib/framework/browser.dart new file mode 100644 index 0000000000..e13e209f42 --- /dev/null +++ b/dev/devicelab/lib/framework/browser.dart @@ -0,0 +1,138 @@ +// Copyright 2014 The Flutter Authors. 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:io' as io; + +import 'package:meta/meta.dart'; + +import 'utils.dart' show forwardStandardStreams; + +/// Options passed to Chrome when launching it. +class ChromeOptions { + ChromeOptions({ + this.userDataDirectory, + this.url, + this.windowWidth = 1024, + this.windowHeight = 1024, + this.headless, + this.debugPort, + }); + + /// If not null passed as `--user-data-dir`. + final String userDataDirectory; + + /// If not null launches a Chrome tab at this URL. + final String url; + + /// The width of the Chrome window. + /// + /// This is important for screenshots and benchmarks. + final int windowWidth; + + /// The height of the Chrome window. + /// + /// This is important for screenshots and benchmarks. + final int windowHeight; + + /// Launches code in "headless" mode, which allows running Chrome in + /// environments without a display, such as LUCI and Cirrus. + final bool headless; + + /// The port Chrome will use for its debugging protocol. + /// + /// If null, Chrome is launched without debugging. When running in headless + /// mode without a debug port, Chrome quits immediately. For most tests it is + /// typical to set [headless] to true and set a non-null debug port. + final int debugPort; +} + +/// A function called when the Chrome process encounters an error. +typedef ChromeErrorCallback = void Function(String); + +/// Manages a single Chrome process. +class Chrome { + Chrome._(this._chromeProcess, this._onError) { + // If the Chrome process quits before it was asked to quit, notify the + // error listener. + _chromeProcess.exitCode.then((int exitCode) { + if (!_isStopped) { + _onError('Chrome process exited prematurely with exit code $exitCode'); + } + }); + } + + /// Launches Chrome with the give [options]. + /// + /// The [onError] callback is called with an error message when the Chrome + /// process encounters an error. In particular, [onError] is called when the + /// Chrome process exits prematurely, i.e. before [stop] is called. + static Future launch(ChromeOptions options, { String workingDirectory, @required ChromeErrorCallback onError }) async { + final io.ProcessResult versionResult = io.Process.runSync(_findSystemChromeExecutable(), const ['--version']); + print('Launching ${versionResult.stdout}'); + + final List args = [ + if (options.userDataDirectory != null) + '--user-data-dir=${options.userDataDirectory}', + if (options.url != null) + options.url, + if (io.Platform.environment['CHROME_NO_SANDBOX'] == 'true') + '--no-sandbox', + if (options.headless) + '--headless', + if (options.debugPort != null) + '--remote-debugging-port=${options.debugPort}', + '--window-size=${options.windowWidth},${options.windowHeight}', + '--disable-extensions', + '--disable-popup-blocking', + // Indicates that the browser is in "browse without sign-in" (Guest session) mode. + '--bwsi', + '--no-first-run', + '--no-default-browser-check', + '--disable-default-apps', + '--disable-translate', + ]; + final io.Process chromeProcess = await io.Process.start( + _findSystemChromeExecutable(), + args, + workingDirectory: workingDirectory, + ); + forwardStandardStreams(chromeProcess); + return Chrome._(chromeProcess, onError); + } + + final io.Process _chromeProcess; + final ChromeErrorCallback _onError; + bool _isStopped = false; + + /// Stops the Chrome process. + void stop() { + _isStopped = true; + _chromeProcess.kill(); + } +} + +String _findSystemChromeExecutable() { + // On some environments, such as the Dart HHH tester, Chrome resides in a + // non-standard location and is provided via the following environment + // variable. + final String envExecutable = io.Platform.environment['CHROME_EXECUTABLE']; + if (envExecutable != null) { + return envExecutable; + } + + if (io.Platform.isLinux) { + final io.ProcessResult which = + io.Process.runSync('which', ['google-chrome']); + + if (which.exitCode != 0) { + throw Exception('Failed to locate system Chrome installation.'); + } + + return (which.stdout as String).trim(); + } else if (io.Platform.isMacOS) { + return '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; + } else { + throw Exception('Web benchmarks cannot run on ${io.Platform.operatingSystem} yet.'); + } +} diff --git a/dev/devicelab/lib/tasks/web_benchmarks.dart b/dev/devicelab/lib/tasks/web_benchmarks.dart index 121de740ea..f0c01d5902 100644 --- a/dev/devicelab/lib/tasks/web_benchmarks.dart +++ b/dev/devicelab/lib/tasks/web_benchmarks.dart @@ -12,6 +12,7 @@ import 'package:shelf/shelf.dart'; import 'package:shelf/shelf_io.dart' as shelf_io; import 'package:shelf_static/shelf_static.dart'; +import 'package:flutter_devicelab/framework/browser.dart'; import 'package:flutter_devicelab/framework/framework.dart'; import 'package:flutter_devicelab/framework/utils.dart'; @@ -74,30 +75,12 @@ Future runWebBenchmark({ @required bool useCanvasKit }) async { )); server = await io.HttpServer.bind('localhost', benchmarkServerPort); - io.Process chromeProcess; + Chrome chrome; try { shelf_io.serveRequests(server, cascade.handler); - final bool isChromeNoSandbox = - io.Platform.environment['CHROME_NO_SANDBOX'] == 'true'; - final String dartToolDirectory = path.join('$macrobenchmarksDirectory/.dart_tool'); final String userDataDir = io.Directory(dartToolDirectory).createTempSync('chrome_user_data_').path; - final List args = [ - '--user-data-dir=$userDataDir', - 'http://localhost:$benchmarkServerPort/index.html', - if (isChromeNoSandbox) - '--no-sandbox', - '--window-size=1024,1024', - '--disable-extensions', - '--disable-popup-blocking', - // Indicates that the browser is in "browse without sign-in" (Guest session) mode. - '--bwsi', - '--no-first-run', - '--no-default-browser-check', - '--disable-default-apps', - '--disable-translate', - ]; // TODO(yjbanov): temporarily disables headful Chrome until we get // devicelab hardware that is able to run it. Our current @@ -106,38 +89,33 @@ Future runWebBenchmark({ @required bool useCanvasKit }) async { final bool isUncalibratedSmokeTest = io.Platform.environment['CALIBRATED'] != 'true'; // final bool isUncalibratedSmokeTest = // io.Platform.environment['UNCALIBRATED_SMOKE_TEST'] == 'true'; - if (isUncalibratedSmokeTest) { - print('Running in headless mode because running on uncalibrated hardware.'); - args.add('--headless'); + final ChromeOptions options = ChromeOptions( + url: 'http://localhost:$benchmarkServerPort/index.html', + userDataDirectory: userDataDir, + windowHeight: 1024, + windowWidth: 1024, + headless: isUncalibratedSmokeTest, // When running in headless mode Chrome exits immediately unless // a debug port is specified. - args.add('--remote-debugging-port=${benchmarkServerPort + 1}'); - } + debugPort: isUncalibratedSmokeTest ? benchmarkServerPort + 1 : null, + ); - chromeProcess = await startProcess( - _findSystemChromeExecutable(), - args, + print('Launching Chrome.'); + chrome = await Chrome.launch( + options, + onError: (String error) { + profileData.completeError(Exception(error)); + }, workingDirectory: cwd, ); - bool receivedProfileData = false; - chromeProcess.exitCode.then((int exitCode) { - if (!receivedProfileData) { - profileData.completeError(Exception( - 'Chrome process existed prematurely with exit code $exitCode', - )); - } - }); - forwardStandardStreams(chromeProcess); - print('Waiting for the benchmark to report benchmark profile.'); - final String backend = useCanvasKit ? 'canvaskit' : 'html'; final Map taskResult = {}; final List benchmarkScoreKeys = []; final List> profiles = await profileData.future; + print('Received profile data'); - receivedProfileData = true; for (final Map profile in profiles) { final String benchmarkName = profile['name'] as String; final String benchmarkScoreKey = '$benchmarkName.$backend.averageDrawFrameDuration'; @@ -148,32 +126,7 @@ Future runWebBenchmark({ @required bool useCanvasKit }) async { return TaskResult.success(taskResult, benchmarkScoreKeys: benchmarkScoreKeys); } finally { server.close(); - chromeProcess?.kill(); + chrome.stop(); } }); } - -String _findSystemChromeExecutable() { - // On some environments, such as the Dart HHH tester, Chrome resides in a - // non-standard location and is provided via the following environment - // variable. - final String envExecutable = io.Platform.environment['CHROME_EXECUTABLE']; - if (envExecutable != null) { - return envExecutable; - } - - if (io.Platform.isLinux) { - final io.ProcessResult which = - io.Process.runSync('which', ['google-chrome']); - - if (which.exitCode != 0) { - throw Exception('Failed to locate system Chrome installation.'); - } - - return (which.stdout as String).trim(); - } else if (io.Platform.isMacOS) { - return '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; - } else { - throw Exception('Web benchmarks cannot run on ${io.Platform.operatingSystem} yet.'); - } -} diff --git a/dev/integration_tests/web/lib/stack_trace.dart b/dev/integration_tests/web/lib/stack_trace.dart new file mode 100644 index 0000000000..fd4e1d1e73 --- /dev/null +++ b/dev/integration_tests/web/lib/stack_trace.dart @@ -0,0 +1,162 @@ +// Copyright 2014 The Flutter Authors. 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:html' as html; +import 'dart:ui'; + +import 'package:collection/collection.dart'; +import 'package:meta/dart2js.dart'; + +import 'package:flutter/foundation.dart'; + +/// Expected sequence of method calls. +const List callChain = ['baz', 'bar', 'foo']; + +final List expectedProfileStackFrames = callChain.map((String method) { + return StackFrame( + number: -1, + packageScheme: '', + package: '', + packagePath: '', + line: -1, + column: -1, + className: 'Object', + method: method, + source: '', + ); +}).toList(); + +// TODO(yjbanov): fix these stack traces when https://github.com/flutter/flutter/issues/50753 is fixed. +const List expectedDebugStackFrames = [ + StackFrame( + number: -1, + packageScheme: 'package', + package: 'web_integration', + packagePath: 'stack_trace.dart.lib.js', + line: 138, + column: 15, + className: '', + method: 'baz', + source: '', + ), + StackFrame( + number: -1, + packageScheme: 'package', + package: 'web_integration', + packagePath: 'stack_trace.dart.lib.js', + line: 135, + column: 17, + className: '', + method: 'bar', + source: '', + ), + StackFrame( + number: -1, + packageScheme: 'package', + package: 'web_integration', + packagePath: 'stack_trace.dart.lib.js', + line: 132, + column: 17, + className: '', + method: 'foo', + source: '', + ), +]; + +/// Tests that we do not crash while parsing Web stack traces. +/// +/// This test is run in debug, profile, and release modes. +void main() { + final StringBuffer output = StringBuffer(); + try { + try { + foo(); + } catch (expectedError, expectedStackTrace) { + final List parsedFrames = StackFrame.fromStackTrace(expectedStackTrace); + if (parsedFrames.isEmpty) { + throw Exception( + 'Failed to parse stack trace. Got empty list of stack frames.\n' + 'Stack trace:\n$expectedStackTrace' + ); + } + + // Symbols in release mode are randomly obfuscated, so there's no good way to + // validate the contents. However, profile mode can be checked. + if (kProfileMode) { + _checkStackFrameContents(parsedFrames, expectedProfileStackFrames, expectedStackTrace); + } + + if (kDebugMode) { + _checkStackFrameContents(parsedFrames, expectedDebugStackFrames, expectedStackTrace); + } + } + output.writeln('--- TEST SUCCEEDED ---'); + } catch (unexpectedError, unexpectedStackTrace) { + output.writeln('--- UNEXPECTED EXCEPTION ---'); + output.writeln(unexpectedError); + output.writeln(unexpectedStackTrace); + output.writeln('--- TEST FAILED ---'); + } + print(output); + html.HttpRequest.request( + '/test-result', + method: 'POST', + sendData: '$output', + ); +} + +@noInline +void foo() { + bar(); +} + +@noInline +void bar() { + baz(); +} + +@noInline +void baz() { + throw Exception('Test error message'); +} + +void _checkStackFrameContents(List parsedFrames, List expectedFrames, dynamic stackTrace) { + // Filter out stack frames outside this library so this test is less brittle. + final List actual = parsedFrames + .where((StackFrame frame) => callChain.contains(frame.method)) + .toList(); + final bool stackFramesAsExpected = ListEquality(StackFrameEquality()).equals(actual, expectedFrames); + if (!stackFramesAsExpected) { + throw Exception( + 'Stack frames parsed incorrectly:\n' + 'Expected:\n${expectedFrames.join('\n')}\n' + 'Actual:\n${actual.join('\n')}\n' + 'Stack trace:\n$stackTrace' + ); + } +} + +/// Use custom equality to ignore [StackFrame.source], which is not important +/// for the purposes of this test. +class StackFrameEquality implements Equality { + @override + bool equals(StackFrame e1, StackFrame e2) { + return e1.number == e2.number && + e1.packageScheme == e2.packageScheme && + e1.package == e2.package && + e1.packagePath == e2.packagePath && + e1.line == e2.line && + e1.column == e2.column && + e1.className == e2.className && + e1.method == e2.method; + } + + @override + int hash(StackFrame e) { + return hashValues(e.number, e.packageScheme, e.package, e.packagePath, e.line, e.column, e.className, e.method); + } + + @override + bool isValidKey(Object o) => o is StackFrame; +} diff --git a/packages/flutter/lib/src/foundation/stack_frame.dart b/packages/flutter/lib/src/foundation/stack_frame.dart index 4b4ea28ed6..ab5c4f1be3 100644 --- a/packages/flutter/lib/src/foundation/stack_frame.dart +++ b/packages/flutter/lib/src/foundation/stack_frame.dart @@ -6,6 +6,7 @@ import 'dart:ui' show hashValues; import 'package:meta/meta.dart'; +import 'constants.dart'; import 'object.dart'; /// A object representation of a frame from a stack trace. @@ -86,10 +87,22 @@ class StackFrame { .trim() .split('\n') .map(fromStackTraceLine) + // On the Web in non-debug builds the stack trace includes the exception + // message that precedes the stack trace itself. fromStackTraceLine will + // return null in that case. We will skip it here. + .skipWhile((StackFrame frame) => frame == null) .toList(); } static StackFrame _parseWebFrame(String line) { + if (kDebugMode) { + return _parseWebDebugFrame(line); + } else { + return _parseWebNonDebugFrame(line); + } + } + + static StackFrame _parseWebDebugFrame(String line) { final bool hasPackage = line.startsWith('package'); final RegExp parser = hasPackage ? RegExp(r'^(package:.+) (\d+):(\d+)\s+(.+)$') @@ -120,6 +133,50 @@ class StackFrame { ); } + // Non-debug builds do not point to dart code but compiled JavaScript, so + // line numbers are meaningless. We only attempt to parse the class and + // method name, which is more or less readable in profile builds, and + // minified in release builds. + static final RegExp _webNonDebugFramePattern = RegExp(r'^\s*at ([^\s]+).*$'); + + // Parses `line` as a stack frame in profile and release Web builds. If not + // recognized as a stack frame, returns null. + static StackFrame _parseWebNonDebugFrame(String line) { + final Match match = _webNonDebugFramePattern.firstMatch(line); + if (match == null) { + // On the Web in non-debug builds the stack trace includes the exception + // message that precedes the stack trace itself. Example: + // + // TypeError: Cannot read property 'hello$0' of null + // at _GalleryAppState.build$1 (http://localhost:8080/main.dart.js:149790:13) + // at StatefulElement.build$0 (http://localhost:8080/main.dart.js:129138:37) + // at StatefulElement.performRebuild$0 (http://localhost:8080/main.dart.js:129032:23) + // + // Instead of crashing when a line is not recognized as a stack frame, we + // return null. The caller, such as fromStackString, can then just skip + // this frame. + return null; + } + + final List classAndMethod = match.group(1).split('.'); + final String className = classAndMethod.length > 1 ? classAndMethod.first : ''; + final String method = classAndMethod.length > 1 + ? classAndMethod.skip(1).join('.') + : classAndMethod.single; + + return StackFrame( + number: -1, + packageScheme: '', + package: '', + packagePath: '', + line: -1, + column: -1, + className: className, + method: method, + source: line, + ); + } + /// Parses a single [StackFrame] from a single line of a [StackTrace]. static StackFrame fromStackTraceLine(String line) { assert(line != null); diff --git a/packages/flutter_tools/lib/src/commands/run.dart b/packages/flutter_tools/lib/src/commands/run.dart index f40fc27a9f..15fa50a883 100644 --- a/packages/flutter_tools/lib/src/commands/run.dart +++ b/packages/flutter_tools/lib/src/commands/run.dart @@ -338,6 +338,9 @@ class RunCommand extends RunCommandBase { DebuggingOptions _createDebuggingOptions() { final BuildInfo buildInfo = getBuildInfo(); + final int browserDebugPort = featureFlags.isWebEnabled && argResults.wasParsed('web-browser-debug-port') + ? int.parse(stringArg('web-browser-debug-port')) + : null; if (buildInfo.mode.isRelease) { return DebuggingOptions.disabled( buildInfo, @@ -345,6 +348,8 @@ class RunCommand extends RunCommandBase { hostname: featureFlags.isWebEnabled ? stringArg('web-hostname') : '', port: featureFlags.isWebEnabled ? stringArg('web-port') : '', webEnableExposeUrl: featureFlags.isWebEnabled && boolArg('web-allow-expose-url'), + webRunHeadless: featureFlags.isWebEnabled && boolArg('web-run-headless'), + webBrowserDebugPort: browserDebugPort, ); } else { return DebuggingOptions.enabled( @@ -367,6 +372,8 @@ class RunCommand extends RunCommandBase { hostname: featureFlags.isWebEnabled ? stringArg('web-hostname') : '', port: featureFlags.isWebEnabled ? stringArg('web-port') : '', webEnableExposeUrl: featureFlags.isWebEnabled && boolArg('web-allow-expose-url'), + webRunHeadless: featureFlags.isWebEnabled && boolArg('web-run-headless'), + webBrowserDebugPort: browserDebugPort, vmserviceOutFile: stringArg('vmservice-out-file'), // Allow forcing fast-start to off to prevent doing more work on devices that // don't support it. diff --git a/packages/flutter_tools/lib/src/device.dart b/packages/flutter_tools/lib/src/device.dart index c2f33fc72b..b3dd7d189b 100644 --- a/packages/flutter_tools/lib/src/device.dart +++ b/packages/flutter_tools/lib/src/device.dart @@ -536,6 +536,8 @@ class DebuggingOptions { this.hostname, this.port, this.webEnableExposeUrl, + this.webRunHeadless = false, + this.webBrowserDebugPort, this.vmserviceOutFile, this.fastStart = false, }) : debuggingEnabled = true; @@ -545,6 +547,8 @@ class DebuggingOptions { this.port, this.hostname, this.webEnableExposeUrl, + this.webRunHeadless = false, + this.webBrowserDebugPort, this.cacheSkSL = false, }) : debuggingEnabled = false, useTestFonts = false, @@ -585,6 +589,17 @@ class DebuggingOptions { final String port; final String hostname; final bool webEnableExposeUrl; + + /// Whether to run the browser in headless mode. + /// + /// Some CI environments do not provide a display and fail to launch the + /// browser with full graphics stack. Some browsers provide a special + /// "headless" mode that runs the browser with no graphics. + final bool webRunHeadless; + + /// The port the browser should use for its debugging protocol. + final int webBrowserDebugPort; + /// A file where the vmservice URL should be written after the application is started. final String vmserviceOutFile; final bool fastStart; diff --git a/packages/flutter_tools/lib/src/runner/flutter_command.dart b/packages/flutter_tools/lib/src/runner/flutter_command.dart index 397e438d33..a17cb74fca 100644 --- a/packages/flutter_tools/lib/src/runner/flutter_command.dart +++ b/packages/flutter_tools/lib/src/runner/flutter_command.dart @@ -173,6 +173,19 @@ abstract class FlutterCommand extends Command { 'when running on remote machines.', hide: hide, ); + argParser.addFlag('web-run-headless', + defaultsTo: false, + help: 'Launches the browser in headless mode. Currently only Chrome ' + 'supports this option.', + hide: true, + ); + argParser.addOption('web-browser-debug-port', + help: 'The debug port the browser should use. If not specified, a ' + 'random port is selected. Currently only Chrome supports this option. ' + 'It serves the Chrome DevTools Protocol ' + '(https://chromedevtools.github.io/devtools-protocol/).', + hide: true, + ); } void usesTargetOption() { diff --git a/packages/flutter_tools/lib/src/web/chrome.dart b/packages/flutter_tools/lib/src/web/chrome.dart index 99953cf371..f1e67c6770 100644 --- a/packages/flutter_tools/lib/src/web/chrome.dart +++ b/packages/flutter_tools/lib/src/web/chrome.dart @@ -97,8 +97,11 @@ class ChromeLauncher { /// `headless` defaults to false, and controls whether we open a headless or /// a `headfull` browser. /// + /// `debugPort` is Chrome's debugging protocol port. If null, a random free + /// port is picked automatically. + /// /// `skipCheck` does not attempt to make a devtools connection before returning. - Future launch(String url, { bool headless = false, bool skipCheck = false, Directory dataDir }) async { + Future launch(String url, { bool headless = false, int debugPort, bool skipCheck = false, Directory dataDir }) async { // This is a JSON file which contains configuration from the // browser session, such as window position. It is located // under the Chrome data-dir folder. @@ -117,7 +120,7 @@ class ChromeLauncher { } } - final int port = await globals.os.findFreePort(); + final int port = debugPort ?? await globals.os.findFreePort(); final List args = [ chromeExecutable, // Using a tmp directory ensures that a new instance of chrome launches diff --git a/packages/flutter_tools/lib/src/web/web_device.dart b/packages/flutter_tools/lib/src/web/web_device.dart index 518d72b95a..cf48542e3b 100644 --- a/packages/flutter_tools/lib/src/web/web_device.dart +++ b/packages/flutter_tools/lib/src/web/web_device.dart @@ -134,10 +134,14 @@ class ChromeDevice extends Device { // See [ResidentWebRunner.run] in flutter_tools/lib/src/resident_web_runner.dart // for the web initialization and server logic. final String url = platformArgs['uri'] as String; - _chrome = await chromeLauncher.launch(url, + _chrome = await chromeLauncher.launch( + url, dataDir: globals.fs.currentDirectory .childDirectory('.dart_tool') - .childDirectory('chrome-device')); + .childDirectory('chrome-device'), + headless: debuggingOptions.webRunHeadless, + debugPort: debuggingOptions.webBrowserDebugPort, + ); globals.logger.sendEvent('app.webLaunchUrl', {'url': url, 'launched': true}); diff --git a/packages/flutter_tools/test/general.shard/web/chrome_test.dart b/packages/flutter_tools/test/general.shard/web/chrome_test.dart index 83247b9201..5bcdc94c78 100644 --- a/packages/flutter_tools/test/general.shard/web/chrome_test.dart +++ b/packages/flutter_tools/test/general.shard/web/chrome_test.dart @@ -54,10 +54,10 @@ void main() { resetChromeForTesting(); }); - test('can launch chrome and connect to the devtools', () => testbed.run(() async { - const List expected = [ + List expectChromeArgs({int debugPort = 1234}) { + return [ 'example_chrome', - '--remote-debugging-port=1234', + '--remote-debugging-port=$debugPort', '--disable-background-timer-throttling', '--disable-extensions', '--disable-popup-blocking', @@ -68,11 +68,18 @@ void main() { '--disable-translate', 'example_url', ]; + } + test('can launch chrome and connect to the devtools', () => testbed.run(() async { await chromeLauncher.launch('example_url', skipCheck: true); final VerificationResult result = verify(globals.processManager.start(captureAny)); + expect(result.captured.single, containsAll(expectChromeArgs())); + })); - expect(result.captured.single, containsAll(expected)); + test('can launch chrome with a custom debug port', () => testbed.run(() async { + await chromeLauncher.launch('example_url', skipCheck: true, debugPort: 10000); + final VerificationResult result = verify(globals.processManager.start(captureAny)); + expect(result.captured.single, containsAll(expectChromeArgs(debugPort: 10000))); })); test('can seed chrome temp directory with existing preferences', () => testbed.run(() async { diff --git a/packages/flutter_tools/test/integration.shard/test_driver.dart b/packages/flutter_tools/test/integration.shard/test_driver.dart index 7fa4c91b4b..d46e5d873b 100644 --- a/packages/flutter_tools/test/integration.shard/test_driver.dart +++ b/packages/flutter_tools/test/integration.shard/test_driver.dart @@ -498,7 +498,7 @@ class FlutterRunTestDriver extends FlutterTestDriver { // fast. unawaited(_process.exitCode.then((_) { if (!prematureExitGuard.isCompleted) { - prematureExitGuard.completeError('Process existed prematurely: ${args.join(' ')}: $_errorBuffer'); + prematureExitGuard.completeError('Process exited prematurely: ${args.join(' ')}: $_errorBuffer'); } }));