blob: f2671b1c0c25ee34ec42ea978d8089f752a1baa2 [file] [edit]
// 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 'dart:isolate' as isolate show Isolate;
import 'package:dwds/asset_reader.dart';
import 'package:dwds/dart_web_debug_service.dart';
import 'package:dwds/data/build_result.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/strategy.dart';
import 'package:dwds/src/services/chrome/chrome_proxy_service.dart';
import 'package:dwds/src/services/expression_compiler.dart';
import 'package:dwds/src/utilities/dart_uri.dart';
import 'package:dwds/src/utilities/server.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' as shelf;
import 'package:shelf/shelf.dart';
import 'package:test/test.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 '../logging.dart';
import '../test_sdk_configuration.dart';
import '../utilities.dart';
import 'project.dart';
import 'server.dart';
import 'utilities.dart';
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(
(RPCError e) => e.message,
'message',
contains(message),
);
Matcher throwsRPCErrorWithMessage(String message) =>
throwsA(isRPCErrorWithMessage(message));
Matcher isRPCErrorWithCode(int code) =>
isA<RPCError>().having((RPCError e) => e.code, 'code', equals(code));
Matcher throwsRPCErrorWithCode(int code) => throwsA(isRPCErrorWithCode(code));
typedef TestContextFactory =
TestContext Function(TestProject, TestSdkConfigurationProvider);
abstract class TestContext {
static const reloadedSourcesFileName = 'reloaded_sources.json';
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;
WebDriver get webDriver => _webDriver!;
WebDriver? _webDriver;
Process get chromeDriver => _chromeDriver!;
Process? _chromeDriver;
WebkitDebugger get webkitDebugger => _webkitDebugger!;
late WebkitDebugger? _webkitDebugger;
Client get client => _client!;
Client? _client;
int get port => _port!;
late int? _port;
Directory get outputDir => _outputDir!;
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.protected(this.project, this.sdkConfigurationProvider);
bool get usesFrontendServer => false;
bool get usesBuildDaemon => false;
bool get usesDdcModulesOnly => false;
// Abstract members:
AssetReader get assetReader;
Handler get assetHandler;
LoadStrategy get loadStrategy;
Stream<BuildResult> get buildResults;
ExpressionCompiler? get expressionCompiler;
String get appUrlPath;
String get basePath => '';
Future<void> modeSetUp(
TestSettings testSettings,
TestDebugSettings debugSettings,
TestAppMetadata appMetadata,
Uri reloadedSourcesUri,
);
Future<void> modeTearDown();
String get chromeDriverExecutable => _resolveChromeDriverExecutable();
String get chromeExecutable => _resolveChromeExecutable();
Future<void> setUp({
TestSettings testSettings = const TestSettings(),
TestAppMetadata appMetadata = const TestAppMetadata.externalApp(),
TestDebugSettings debugSettings =
const TestDebugSettings.noDevToolsLaunch(),
}) async {
try {
// Make sure configuration was created correctly.
final sdkLayout = sdkConfigurationProvider.sdkLayout;
final configuration = await sdkConfigurationProvider.configuration;
configuration.validate();
await project.setUp();
DartUri.currentDirectory = project.absolutePackageDirectory;
_logger.info(
'Serving: ${project.directoryToServe}/${project.filePathToServe}',
);
_logger.info('Project: ${project.absolutePackageDirectory}');
_logger.info('Packages: ${project.packageConfigFile}');
_logger.info('Entry: ${project.dartEntryFilePath}');
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 sharedChromeDriverPort =
Platform.environment['DWDS_CHROMEDRIVER_PORT'];
final chromeDriverPort = sharedChromeDriverPort != null
? int.parse(sharedChromeDriverPort)
: await findUnusedPort();
final chromeDriverUrlBase = 'wd/hub';
if (sharedChromeDriverPort == null) {
try {
_chromeDriver = await Process.start(chromeDriverExecutable, [
'--port=$chromeDriverPort',
'--url-base=$chromeDriverUrlBase',
]);
final stdOutLines = chromeDriver.stdout
.transform(utf8.decoder)
.transform(const LineSplitter())
.asBroadcastStream();
final stdErrLines = chromeDriver.stderr
.transform(utf8.decoder)
.transform(const LineSplitter())
.asBroadcastStream();
final chromeDriverStartup = Completer<void>();
stdOutLines.listen((line) {
if (!chromeDriverStartup.isCompleted &&
line.contains('was started successfully')) {
chromeDriverStartup.complete();
}
_logger.finest('ChromeDriver stdout: $line');
});
stdErrLines.listen(
(line) => _logger.warning('ChromeDriver stderr: $line'),
);
await chromeDriverStartup.future;
} catch (e) {
throw StateError(
'Could not start ChromeDriver. Is it installed?\nError: $e',
);
}
}
final packageConfig = File(
p.join(
project.absolutePackageDirectory,
'.dart_tool',
'package_config.json',
),
);
if (!packageConfig.existsSync()) {
await Process.run(sdkLayout.dartPath, [
'pub',
'upgrade',
], workingDirectory: project.absolutePackageDirectory);
}
// Start the HTTP server and save its used port.
final httpServer = await startHttpServer('localhost');
_port = httpServer.port;
final reloadedSourcesUri = Uri.parse(
'http://localhost:$_port/$reloadedSourcesFileName',
);
await modeSetUp(
testSettings,
debugSettings,
appMetadata,
reloadedSourcesUri,
);
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',
// When the DevTools has focus we don't want to slow down the
// application.
'--disable-background-timer-throttling',
'--disable-blink-features=TimerThrottlingForBackgroundTabs',
'--disable-features=IntensiveWakeUpThrottling',
// Since we are using a temp profile, disable features that slow
// the Chrome launch.
'--disable-extensions',
'--disable-popup-blocking',
'--bwsi',
'--no-first-run',
'--no-default-browser-check',
'--disable-default-apps',
'--disable-translate',
'--start-maximized',
// When running on MacOS, Chrome may open system dialogs
// requesting credentials. This uses a mock keychain to avoid
// that dialog from blocking.
'--use-mock-keychain',
// Prevent warnings for using flags that are not recommended for
// general browsing but are applicable for use in dev-focused
// workflows.
'--test-type',
// Dev runs of the browser should be considered independent of
// one another, don't announce when the previous session
// crashed.
'--disable-session-crashed-bubble',
'--no-sandbox',
],
'binary': chromeExecutable,
},
});
_webDriver = await _createDriverWithRetry(
capabilities: 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<void>();
final appConnectionCompleter = Completer<void>();
final connection = ChromeConnection('localhost', debugPort);
_testServer = await TestServer.start(
debugSettings: debugSettings.copyWith(
expressionCompiler: expressionCompiler,
),
appMetadata: appMetadata,
port: port,
assetHandler: assetHandler,
assetReader: assetReader,
strategy: loadStrategy,
buildResults: buildResults,
chromeConnection: () async => connection,
httpServer: httpServer,
);
_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/$appUrlPath'
: 'http://localhost:$port/$basePath/$appUrlPath';
if (testSettings.launchChrome) {
await _webDriver?.get(appUrl).timeout(const Duration(seconds: 30));
_tabConnection = await _getTabConnection(
connection,
appUrl,
).timeout(const Duration(seconds: 30));
tabConnectionCompleter.complete();
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();
}
_webkitDebugger = WebkitDebugger(WipDebugger(tabConnection));
} else {
// No tab needs to be discovered, 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);
}
Future<void> tearDown() async {
await modeTearDown();
await _webDriver?.quit(closeSession: true);
_chromeDriver?.kill();
DartUri.currentDirectory = p.current;
await _testServer?.stop();
_client?.close();
await _outputDir?.delete(recursive: true);
stopLogWriter();
await project.tearDown();
// clear the state for next setup
_webDriver = null;
_chromeDriver = null;
_testServer = null;
_client = null;
_outputDir = null;
}
/// Given a list of edits, use file IO to write them to the file system.
///
/// If `file` has the same name as the project's entry file name, that file
/// will be edited. Otherwise, it's assumed to be a library file.
// TODO(srujzs): It's possible we may want a library file with the same name
// as the entry file, but this function doesn't allow that. Potentially
// support that.
Future<void> makeEdits(List<Edit> edits) async {
// `dart:io`'s `stat` on Windows does not have millisecond precision so we
// need to make sure we wait long enough that modifications result in a
// timestamp that is guaranteed to be after the previous compile.
// TODO(https://github.com/dart-lang/sdk/issues/51937): Remove once this bug
// is fixed.
if (Platform.isWindows) {
await Future<void>.delayed(const Duration(seconds: 1));
}
_reloadedSources.clear();
for (var (:file, :originalString, :newString) in edits) {
if (file == project.dartEntryFileName) {
file = project.dartEntryFilePath;
} else {
file = project.dartLibFilePath(file);
}
final f = File(file);
final fileContents = f.readAsStringSync();
f.writeAsStringSync(fileContents.replaceAll(originalString, newString));
_updateReloadedSources(file);
}
}
/// Updates the reloaded_sources.json manifest file for a running test.
///
/// This logic essentially replicates the build system's naming conventions
/// for DDC's generated code. DWDS itself uses the metadata file, but this
/// isn't available for our test fixtures.
/// Rules:
/// - Entrypoints (served): web/main.dart -> main
/// - Entrypoints (nested): test/hello_world/main.dart -> hello_world/main
/// - Library files: lib/path/to/some_file.dart
/// -> packages/`package`/path/to/some_file
void _updateReloadedSources(String absolutePath) {
final relativePath = p.relative(
absolutePath,
from: project.absolutePackageDirectory,
);
final relativeUrl = p.toUri(relativePath).path;
String moduleName;
String libUri;
String srcPath;
if (relativeUrl.startsWith('lib/')) {
final pathInLib = relativeUrl.substring(4);
moduleName =
'packages/${project.packageName}/${p.withoutExtension(pathInLib)}';
libUri = 'package:${project.packageName}/$pathInLib';
srcPath = moduleName;
} else if (absolutePath == project.dartEntryFilePath) {
moduleName = p.withoutExtension(relativeUrl);
libUri = project.dartEntryFilePackageUri.toString();
final servePath = '${project.directoryToServe}/';
if (relativeUrl.startsWith(servePath)) {
// e.g. web/main.dart -> main
srcPath = p.withoutExtension(relativeUrl.substring(servePath.length));
} else {
// e.g. example/hello_world/main.dart -> example/hello_world/main
srcPath = moduleName;
}
} else {
throw StateError(
"Unhandled file path in test context's reloaded_sources.json: "
" $absolutePath. Only entrypoints and files in 'lib/' are supported.",
);
}
_reloadedSources.add({
'src': '/$srcPath.ddc.js',
'module': moduleName,
'libraries': [libUri],
});
}
/// Contains contents of the reloaded_sources.json manifest file.
///
/// Used by the DDC Library Bundle module system to record changed files for
/// hot restart/reload.
final _reloadedSources = <Map<String, Object>>[];
void addLibraryFile({required String libFileName, required String contents}) {
final file = File(project.dartLibFilePath(libFileName));
// Library folder may not exist yet, so create it.
file.createSync(recursive: true);
file.writeAsStringSync(contents);
_updateReloadedSources(file.path);
}
/// Wraps a handler to serve the reloaded_sources.json file for
/// reloads/restarts in the DDC Library Bundle module system.
Handler handleReloadedSources(Handler proxy) {
return (request) {
final path = request.url.path;
if (path.endsWith(reloadedSourcesFileName)) {
return shelf.Response.ok(jsonEncode(_reloadedSources));
}
return proxy(request);
};
}
Future<void> recompile({required bool fullRestart}) => throw UnsupportedError(
'recompile is only supported in Frontend Server mode',
);
Future<void> waitForSuccessfulBuild({
Duration? timeout,
bool propagateToBrowser = false,
}) => throw UnsupportedError(
'waitForSuccessfulBuild is only supported in Build Daemon mode',
);
Future<void> _buildDebugExtension() async {
final process = await Process.run('tool/build_extension.sh', [
'prod',
], workingDirectory: absolutePath(pathFromDwds: 'debug_extension'));
print(process.stdout);
}
Future<WipConnection> _getTabConnection(
ChromeConnection connection,
String appUrl,
) async {
final tab = await connection.getTab(
(t) => t.url == appUrl,
retryFor: const Duration(seconds: 5),
);
if (tab == null) {
throw StateError(
'Unable to connect to tab after retrying for 5 seconds.',
);
}
final tabConnection = await tab.connect().timeout(
const Duration(seconds: 30),
);
await tabConnection.runtime.enable();
await tabConnection.debugger.enable();
return tabConnection;
}
Future<ChromeTab> _fetchDartDebugExtensionTab(
ChromeConnection connection,
) async {
const retries = 5;
for (var i = 0; i < retries; i++) {
if (i > 0) {
await Future<void>.delayed(const Duration(milliseconds: 500));
}
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;
}
}
typedef Edit = ({String file, String originalString, String newString});
Future<WebDriver> _createDriverWithRetry({
required Map<String, dynamic> capabilities,
required Uri uri,
int maxAttempts = 3,
Duration timeout = const Duration(seconds: 30),
}) async {
for (var attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await createDriver(
spec: WebDriverSpec.JsonWire,
desired: capabilities,
uri: uri,
).timeout(timeout);
} catch (e) {
if (attempt == maxAttempts) rethrow;
await Future<void>.delayed(Duration(seconds: attempt));
}
}
throw StateError('Unreachable');
}
final _sdkRoot = isolate.Isolate.resolvePackageUriSync(
Uri.parse('package:dwds_test_common/fixtures/context.dart'),
)!.resolve('../../../../');
final _chromeDriverName = Platform.isWindows
? 'chromedriver.exe'
: 'chromedriver';
final _chromeExecutableName = Platform.isWindows
? 'Application\\chrome.exe'
: 'google-chrome';
String _resolveExecutable({
required List<String> environmentKeys,
required String sdkRelativePath,
required String fallbackName,
}) {
for (final env in environmentKeys) {
if (Platform.environment.containsKey(env)) {
return Platform.environment[env]!;
}
}
final sdkPath = _sdkRoot.resolve(sdkRelativePath).toFilePath();
if (File(sdkPath).existsSync()) {
return sdkPath;
}
return fallbackName;
}
String _resolveChromeDriverExecutable() => _resolveExecutable(
environmentKeys: const ['CHROMEDRIVER_PATH'],
sdkRelativePath: 'third_party/webdriver/chrome/$_chromeDriverName',
fallbackName: _chromeDriverName,
);
String _resolveChromeExecutable() => _resolveExecutable(
environmentKeys: const ['CHROME_EXECUTABLE', 'CHROME_PATH'],
sdkRelativePath: 'third_party/browsers/chrome/chrome/$_chromeExecutableName',
fallbackName: _chromeExecutableName,
);