| // 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:async'; |
| import 'dart:convert'; |
| import 'dart:io'; |
| |
| import 'package:build_daemon/client.dart'; |
| import 'package:build_daemon/data/build_status.dart'; |
| import 'package:build_daemon/data/build_target.dart'; |
| import 'package:dwds/asset_reader.dart'; |
| import 'package:dwds/dart_web_debug_service.dart'; |
| import 'package:dwds/src/connections/app_connection.dart'; |
| import 'package:dwds/src/connections/debug_connection.dart'; |
| import 'package:dwds/src/debugging/webkit_debugger.dart'; |
| import 'package:dwds/src/loaders/build_runner_require.dart'; |
| import 'package:dwds/src/loaders/frontend_server_strategy_provider.dart'; |
| import 'package:dwds/src/loaders/strategy.dart'; |
| import 'package:dwds/src/readers/proxy_server_asset_reader.dart'; |
| import 'package:dwds/src/services/chrome_proxy_service.dart'; |
| import 'package:dwds/src/services/expression_compiler.dart'; |
| import 'package:dwds/src/services/expression_compiler_service.dart'; |
| import 'package:dwds/src/utilities/dart_uri.dart'; |
| import 'package:dwds/src/utilities/server.dart'; |
| import 'package:file/local.dart'; |
| import 'package:frontend_server_common/src/resident_runner.dart'; |
| import 'package:http/http.dart'; |
| import 'package:http/io_client.dart'; |
| import 'package:logging/logging.dart' as logging; |
| import 'package:path/path.dart' as p; |
| import 'package:shelf/shelf.dart'; |
| import 'package:shelf_proxy/shelf_proxy.dart'; |
| import 'package:test/test.dart'; |
| import 'package:test_common/logging.dart'; |
| import 'package:test_common/test_sdk_configuration.dart'; |
| import 'package:test_common/utilities.dart'; |
| import 'package:vm_service/vm_service.dart'; |
| import 'package:vm_service/vm_service_io.dart'; |
| import 'package:webdriver/async_io.dart'; |
| import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart'; |
| |
| import 'project.dart'; |
| import 'server.dart'; |
| import 'utilities.dart'; |
| |
| final _exeExt = Platform.isWindows ? '.exe' : ''; |
| |
| const isRPCError = TypeMatcher<RPCError>(); |
| const isSentinelException = TypeMatcher<SentinelException>(); |
| |
| final Matcher throwsRPCError = throwsA(isRPCError); |
| final Matcher throwsSentinelException = throwsA(isSentinelException); |
| |
| Matcher isRPCErrorWithMessage(String message) => |
| isA<RPCError>().having((e) => e.message, 'message', contains(message)); |
| Matcher throwsRPCErrorWithMessage(String message) => |
| throwsA(isRPCErrorWithMessage(message)); |
| |
| Matcher isRPCErrorWithCode(int code) => |
| isA<RPCError>().having((e) => e.code, 'code', equals(code)); |
| Matcher throwsRPCErrorWithCode(int code) => throwsA(isRPCErrorWithCode(code)); |
| |
| enum CompilationMode { buildDaemon, frontendServer } |
| |
| class TestContext { |
| final TestProject project; |
| final TestSdkConfigurationProvider sdkConfigurationProvider; |
| |
| String get appUrl => _appUrl!; |
| late String? _appUrl; |
| |
| WipConnection get tabConnection => _tabConnection!; |
| late WipConnection? _tabConnection; |
| |
| TestServer get testServer => _testServer!; |
| TestServer? _testServer; |
| |
| Dwds? get dwds => _testServer?.dwds; |
| |
| BuildDaemonClient get daemonClient => _daemonClient!; |
| BuildDaemonClient? _daemonClient; |
| |
| ResidentWebRunner get webRunner => _webRunner!; |
| ResidentWebRunner? _webRunner; |
| |
| WebDriver get webDriver => _webDriver!; |
| WebDriver? _webDriver; |
| |
| Process get chromeDriver => _chromeDriver!; |
| late Process? _chromeDriver; |
| |
| WebkitDebugger get webkitDebugger => _webkitDebugger!; |
| late WebkitDebugger? _webkitDebugger; |
| |
| Handler get assetHandler => _assetHandler!; |
| late Handler? _assetHandler; |
| |
| Client get client => _client!; |
| late Client? _client; |
| |
| ExpressionCompilerService? ddcService; |
| |
| int get port => _port!; |
| late int? _port; |
| |
| Directory get outputDir => _outputDir!; |
| late Directory? _outputDir; |
| |
| late WipConnection extensionConnection; |
| late AppConnection appConnection; |
| late DebugConnection debugConnection; |
| |
| final _logger = logging.Logger('Context'); |
| |
| final _serviceNameToMethod = <String, String?>{}; |
| |
| /// Internal VM service. |
| /// |
| /// Prefer using [vmService] instead in tests when possible, to include testing |
| /// of the VmServerConnection (bypassed when using [service]). |
| ChromeProxyService get service => fetchChromeProxyService(debugConnection); |
| |
| /// External VM service. |
| VmService get vmService => debugConnection.vmService; |
| |
| TestContext(this.project, this.sdkConfigurationProvider) { |
| DartUri.currentDirectory = project.absolutePackageDirectory; |
| |
| project.validate(); |
| |
| _logger.info( |
| 'Serving: ${project.directoryToServe}/${project.filePathToServe}', |
| ); |
| _logger.info('Project: ${project.absolutePackageDirectory}'); |
| _logger.info('Packages: ${project.packageConfigFile}'); |
| _logger.info('Entry: ${project.dartEntryFilePath}'); |
| } |
| |
| Future<void> setUp({ |
| TestSettings testSettings = const TestSettings(), |
| TestAppMetadata appMetadata = const TestAppMetadata.externalApp(), |
| TestDebugSettings debugSettings = const TestDebugSettings.noDevTools(), |
| }) async { |
| try { |
| // Build settings to return from load strategy. |
| final buildSettings = TestBuildSettings( |
| appEntrypoint: project.dartEntryFilePackageUri, |
| canaryFeatures: testSettings.canaryFeatures, |
| isFlutterApp: testSettings.isFlutterApp, |
| experiments: testSettings.experiments, |
| ); |
| |
| // Make sure configuration was created correctly. |
| final sdkLayout = sdkConfigurationProvider.sdkLayout; |
| final configuration = await sdkConfigurationProvider.configuration; |
| configuration.validate(); |
| await project.cleanUp(); |
| |
| DartUri.currentDirectory = project.absolutePackageDirectory; |
| |
| configureLogWriter(); |
| |
| _client = IOClient( |
| HttpClient() |
| ..maxConnectionsPerHost = 200 |
| ..idleTimeout = const Duration(seconds: 30) |
| ..connectionTimeout = const Duration(seconds: 30), |
| ); |
| |
| final systemTempDir = Directory.systemTemp; |
| _outputDir = systemTempDir.createTempSync('foo bar'); |
| |
| final chromeDriverPort = await findUnusedPort(); |
| final chromeDriverUrlBase = 'wd/hub'; |
| try { |
| _chromeDriver = await Process.start('chromedriver$_exeExt', [ |
| '--port=$chromeDriverPort', |
| '--url-base=$chromeDriverUrlBase', |
| ]); |
| // On windows this takes a while to boot up, wait for the first line |
| // of stdout as a signal that it is ready. |
| final stdOutLines = |
| chromeDriver.stdout |
| .transform(utf8.decoder) |
| .transform(const LineSplitter()) |
| .asBroadcastStream(); |
| |
| final stdErrLines = |
| chromeDriver.stderr |
| .transform(utf8.decoder) |
| .transform(const LineSplitter()) |
| .asBroadcastStream(); |
| |
| stdOutLines.listen( |
| (line) => _logger.finest('ChromeDriver stdout: $line'), |
| ); |
| stdErrLines.listen( |
| (line) => _logger.warning('ChromeDriver stderr: $line'), |
| ); |
| |
| await stdOutLines.first; |
| } catch (e) { |
| throw StateError( |
| 'Could not start ChromeDriver. Is it installed?\nError: $e', |
| ); |
| } |
| |
| await Process.run(sdkLayout.dartPath, [ |
| 'pub', |
| 'upgrade', |
| ], workingDirectory: project.absolutePackageDirectory); |
| |
| ExpressionCompiler? expressionCompiler; |
| AssetReader assetReader; |
| Stream<BuildResults> buildResults; |
| LoadStrategy loadStrategy; |
| var basePath = ''; |
| var filePathToServe = project.filePathToServe; |
| |
| _port = await findUnusedPort(); |
| switch (testSettings.compilationMode) { |
| case CompilationMode.buildDaemon: |
| { |
| final options = [ |
| if (testSettings.enableExpressionEvaluation) ...[ |
| '--define', |
| 'build_web_compilers|ddc=generate-full-dill=true', |
| ], |
| for (final experiment in buildSettings.experiments) |
| '--enable-experiment=$experiment', |
| if (buildSettings.canaryFeatures) ...[ |
| '--define', |
| 'build_web_compilers|ddc=canary=true', |
| '--define', |
| 'build_web_compilers|sdk_js=canary=true', |
| ], |
| '--verbose', |
| ]; |
| _daemonClient = await connectClient( |
| sdkLayout.dartPath, |
| project.absolutePackageDirectory, |
| options, |
| (log) { |
| final record = log.toLogRecord(); |
| final name = |
| record.loggerName == '' ? '' : '${record.loggerName}: '; |
| _logger.log( |
| record.level, |
| '$name${record.message}', |
| record.error, |
| record.stackTrace, |
| ); |
| }, |
| ); |
| daemonClient.registerBuildTarget( |
| DefaultBuildTarget((b) => b..target = project.directoryToServe), |
| ); |
| daemonClient.startBuild(); |
| |
| await waitForSuccessfulBuild(); |
| |
| final assetServerPort = daemonPort( |
| project.absolutePackageDirectory, |
| ); |
| _assetHandler = proxyHandler( |
| 'http://localhost:$assetServerPort/${project.directoryToServe}/', |
| client: client, |
| ); |
| assetReader = ProxyServerAssetReader( |
| assetServerPort, |
| root: project.directoryToServe, |
| ); |
| |
| if (testSettings.enableExpressionEvaluation) { |
| ddcService = ExpressionCompilerService( |
| 'localhost', |
| port, |
| verbose: testSettings.verboseCompiler, |
| sdkConfigurationProvider: sdkConfigurationProvider, |
| ); |
| expressionCompiler = ddcService; |
| } |
| |
| loadStrategy = |
| BuildRunnerRequireStrategyProvider( |
| assetHandler, |
| testSettings.reloadConfiguration, |
| assetReader, |
| buildSettings, |
| ).strategy; |
| |
| buildResults = daemonClient.buildResults; |
| } |
| break; |
| case CompilationMode.frontendServer: |
| { |
| filePathToServe = webCompatiblePath([ |
| project.directoryToServe, |
| project.filePathToServe, |
| ]); |
| |
| _logger.info('Serving: $filePathToServe'); |
| |
| final entry = p.toUri( |
| p.join(project.webAssetsPath, project.dartEntryFileName), |
| ); |
| final fileSystem = LocalFileSystem(); |
| final packageUriMapper = await PackageUriMapper.create( |
| fileSystem, |
| project.packageConfigFile, |
| useDebuggerModuleNames: testSettings.useDebuggerModuleNames, |
| ); |
| |
| final compilerOptions = TestCompilerOptions( |
| experiments: buildSettings.experiments, |
| canaryFeatures: buildSettings.canaryFeatures, |
| moduleFormat: testSettings.moduleFormat, |
| ); |
| |
| _webRunner = ResidentWebRunner( |
| mainUri: entry, |
| urlTunneler: debugSettings.urlEncoder, |
| projectDirectory: p.toUri(project.absolutePackageDirectory), |
| packageConfigFile: project.packageConfigFile, |
| packageUriMapper: packageUriMapper, |
| fileSystemRoots: [p.toUri(project.absolutePackageDirectory)], |
| fileSystemScheme: 'org-dartlang-app', |
| outputPath: outputDir.path, |
| compilerOptions: compilerOptions, |
| sdkLayout: sdkLayout, |
| verbose: testSettings.verboseCompiler, |
| ); |
| |
| final assetServerPort = await findUnusedPort(); |
| await webRunner.run( |
| fileSystem, |
| appMetadata.hostname, |
| assetServerPort, |
| filePathToServe, |
| ); |
| |
| if (testSettings.enableExpressionEvaluation) { |
| expressionCompiler = webRunner.expressionCompiler; |
| } |
| |
| basePath = webRunner.devFS.assetServer.basePath; |
| assetReader = webRunner.devFS.assetServer; |
| _assetHandler = webRunner.devFS.assetServer.handleRequest; |
| loadStrategy = switch (testSettings.moduleFormat) { |
| ModuleFormat.amd => |
| FrontendServerRequireStrategyProvider( |
| testSettings.reloadConfiguration, |
| assetReader, |
| packageUriMapper, |
| () async => {}, |
| buildSettings, |
| ).strategy, |
| ModuleFormat.ddc => |
| buildSettings.canaryFeatures |
| ? FrontendServerDdcLibraryBundleStrategyProvider( |
| testSettings.reloadConfiguration, |
| assetReader, |
| packageUriMapper, |
| () async => {}, |
| buildSettings, |
| ).strategy |
| : FrontendServerDdcStrategyProvider( |
| testSettings.reloadConfiguration, |
| assetReader, |
| packageUriMapper, |
| () async => {}, |
| buildSettings, |
| ).strategy, |
| _ => |
| throw Exception( |
| 'Unsupported DDC module format ${testSettings.moduleFormat.name}.', |
| ), |
| }; |
| buildResults = const Stream<BuildResults>.empty(); |
| } |
| break; |
| } |
| |
| final debugPort = await findUnusedPort(); |
| if (testSettings.launchChrome) { |
| // If the environment variable DWDS_DEBUG_CHROME is set to the string |
| // true then Chrome will be launched with a UI rather than headless. |
| // If the extension is enabled, then Chrome will be launched with a UI |
| // since headless Chrome does not support extensions. |
| final enableDebugExtension = debugSettings.enableDebugExtension; |
| final headless = |
| Platform.environment['DWDS_DEBUG_CHROME'] != 'true' && |
| !enableDebugExtension; |
| if (enableDebugExtension) { |
| await _buildDebugExtension(); |
| } |
| final capabilities = |
| Capabilities.chrome..addAll({ |
| Capabilities.chromeOptions: { |
| 'args': [ |
| // --disable-gpu speeds up the tests that use ChromeDriver when |
| // they are run on GitHub Actions. |
| '--disable-gpu', |
| 'remote-debugging-port=$debugPort', |
| if (enableDebugExtension) |
| '--load-extension=debug_extension/prod_build', |
| if (headless) '--headless', |
| ], |
| }, |
| }); |
| _webDriver = await createDriver( |
| spec: WebDriverSpec.JsonWire, |
| desired: capabilities, |
| uri: Uri.parse( |
| 'http://127.0.0.1:$chromeDriverPort/$chromeDriverUrlBase/', |
| ), |
| ); |
| } |
| |
| // The debugger tab must be enabled and connected before certain |
| // listeners in DWDS or `main` is run. |
| final tabConnectionCompleter = Completer(); |
| final appConnectionCompleter = Completer(); |
| final connection = ChromeConnection('localhost', debugPort); |
| |
| _testServer = await TestServer.start( |
| debugSettings: debugSettings.copyWith( |
| expressionCompiler: expressionCompiler, |
| ), |
| appMetadata: appMetadata, |
| port: port, |
| assetHandler: assetHandler, |
| assetReader: assetReader, |
| strategy: loadStrategy, |
| target: project.directoryToServe, |
| buildResults: buildResults, |
| chromeConnection: () async => connection, |
| ); |
| |
| _testServer!.dwds.connectedApps.listen((connection) async { |
| // Ensure that we've established a tab connection before running main. |
| await tabConnectionCompleter.future; |
| if (testSettings.autoRun) { |
| connection.runMain(); |
| } |
| |
| // We may reuse the app connection, so only save it the first time |
| // it's encountered. |
| if (!appConnectionCompleter.isCompleted) { |
| appConnection = connection; |
| appConnectionCompleter.complete(); |
| } |
| }); |
| |
| _appUrl = |
| basePath.isEmpty |
| ? 'http://localhost:$port/$filePathToServe' |
| : 'http://localhost:$port/$basePath/$filePathToServe'; |
| |
| if (testSettings.launchChrome) { |
| await _webDriver?.get(appUrl); |
| final tab = await connection.getTab((t) => t.url == appUrl); |
| if (tab != null) { |
| _tabConnection = await tab.connect(); |
| await tabConnection.runtime.enable(); |
| await tabConnection.debugger.enable().then( |
| (_) => tabConnectionCompleter.complete(), |
| ); |
| } else { |
| throw StateError('Unable to connect to tab.'); |
| } |
| |
| if (debugSettings.enableDebugExtension) { |
| final extensionTab = await _fetchDartDebugExtensionTab(connection); |
| extensionConnection = await extensionTab.connect(); |
| await extensionConnection.runtime.enable(); |
| } |
| |
| await appConnectionCompleter.future; |
| if (debugSettings.enableDebugging && !testSettings.waitToDebug) { |
| await startDebugging(); |
| } |
| } else { |
| // No tab needs to be dicovered, so fulfill the relevant completer. |
| tabConnectionCompleter.complete(); |
| } |
| } catch (e, s) { |
| _logger.severe('Failed to setup the service, $e:$s'); |
| await tearDown(); |
| rethrow; |
| } |
| } |
| |
| /// Creates a VM service connection connected to the debug URI. |
| /// |
| /// This can be used to test behavior that should be available to a client |
| /// connected to DWDS. |
| Future<VmService> connectFakeClient() async { |
| final fakeClient = await vmServiceConnectUri(debugConnection.uri); |
| |
| fakeClient.onEvent(EventStreams.kService).listen(_handleServiceEvent); |
| await fakeClient.streamListen(EventStreams.kService); |
| |
| return fakeClient; |
| } |
| |
| /// Returns the service extension method given the [extensionName]. |
| /// |
| /// The extension be called by a client created with [connectFakeClient]. |
| String? getRegisteredServiceExtension(String extensionName) { |
| if (_serviceNameToMethod.isEmpty) { |
| throw StateError(''' |
| No registered service extensions. Did you call connectFakeClient? |
| '''); |
| } |
| |
| return _serviceNameToMethod[extensionName]; |
| } |
| |
| void _handleServiceEvent(Event e) { |
| if (e.kind == EventKind.kServiceRegistered) { |
| final serviceName = e.service!; |
| _serviceNameToMethod[serviceName] = e.method; |
| } |
| } |
| |
| Future<void> startDebugging() async { |
| debugConnection = await testServer.dwds.debugConnection(appConnection); |
| _webkitDebugger = WebkitDebugger(WipDebugger(tabConnection)); |
| } |
| |
| Future<void> tearDown() async { |
| await _webDriver?.quit(closeSession: true); |
| _chromeDriver?.kill(); |
| DartUri.currentDirectory = p.current; |
| await _daemonClient?.close(); |
| await ddcService?.stop(); |
| await _webRunner?.stop(); |
| await _testServer?.stop(); |
| _client?.close(); |
| await _outputDir?.delete(recursive: true); |
| stopLogWriter(); |
| |
| // clear the state for next setup |
| _webDriver = null; |
| _chromeDriver = null; |
| _daemonClient = null; |
| ddcService = null; |
| _webRunner = null; |
| _testServer = null; |
| _client = null; |
| _outputDir = null; |
| } |
| |
| void makeEditToDartEntryFile({ |
| required String toReplace, |
| required String replaceWith, |
| }) { |
| final file = File(project.dartEntryFilePath); |
| final fileContents = file.readAsStringSync(); |
| file.writeAsStringSync(fileContents.replaceAll(toReplace, replaceWith)); |
| } |
| |
| void makeEditToDartLibFile({ |
| required String libFileName, |
| required String toReplace, |
| required String replaceWith, |
| }) { |
| final file = File(project.dartLibFilePath(libFileName)); |
| final fileContents = file.readAsStringSync(); |
| file.writeAsStringSync(fileContents.replaceAll(toReplace, replaceWith)); |
| } |
| |
| Future<void> waitForSuccessfulBuild({ |
| Duration? timeout, |
| bool propagateToBrowser = false, |
| }) async { |
| // Wait for the build until the timeout is reached: |
| await daemonClient.buildResults |
| .firstWhere( |
| (results) => results.results.any( |
| (result) => result.status == BuildStatus.succeeded, |
| ), |
| ) |
| .timeout(timeout ?? const Duration(seconds: 60)); |
| |
| if (propagateToBrowser) { |
| // Allow change to propagate to the browser. |
| // Windows, or at least Travis on Windows, seems to need more time. |
| final delay = |
| Platform.isWindows |
| ? const Duration(seconds: 5) |
| : const Duration(seconds: 2); |
| await Future.delayed(delay); |
| } |
| } |
| |
| Future<void> _buildDebugExtension() async { |
| final process = await Process.run( |
| 'tool/build_extension.sh', |
| ['prod'], |
| workingDirectory: absolutePath(pathFromDwds: 'debug_extension'), |
| ); |
| print(process.stdout); |
| } |
| |
| Future<ChromeTab> _fetchDartDebugExtensionTab( |
| ChromeConnection connection, |
| ) async { |
| final extensionTabs = (await connection.getTabs()).where((tab) { |
| return tab.isChromeExtension; |
| }); |
| for (final tab in extensionTabs) { |
| final tabConnection = await tab.connect(); |
| final response = await tabConnection.runtime.evaluate( |
| 'window.isDartDebugExtension', |
| ); |
| if (response.value == true) { |
| return tab; |
| } |
| } |
| throw StateError('No extension installed.'); |
| } |
| |
| /// Finds the line number in [scriptRef] matching [breakpointId]. |
| /// |
| /// A breakpoint ID is found by looking for a line that ends with a comment |
| /// of exactly this form: `// Breakpoint: <id>`. |
| /// |
| /// Throws if it can't find the matching line. |
| Future<int> findBreakpointLine( |
| String breakpointId, |
| String isolateId, |
| ScriptRef scriptRef, |
| ) async { |
| final script = |
| await debugConnection.vmService.getObject(isolateId, scriptRef.id!) |
| as Script; |
| final lines = LineSplitter.split(script.source!).toList(); |
| final lineNumber = lines.indexWhere( |
| (l) => l.endsWith('// Breakpoint: $breakpointId'), |
| ); |
| if (lineNumber == -1) { |
| throw StateError( |
| 'Unable to find breakpoint in ${scriptRef.uri} with id ' |
| '$breakpointId', |
| ); |
| } |
| return lineNumber + 1; |
| } |
| } |