blob: 9b3a67beb7691c4fd266280363c4fbc292eead3b [file] [log] [blame]
// Copyright 2018 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
@TestOn('vm')
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:devtools_shared/devtools_test_utils.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart'
show ConsoleAPIEvent, RemoteObject;
const bool verboseTesting = false;
late WebBuildFixture webBuildFixture;
late BrowserManager browserManager;
class DevtoolsManager {
DevtoolsManager(this.tabInstance, this.baseUri);
final BrowserTabInstance tabInstance;
final Uri baseUri;
Future<void> start(
AppFixture appFixture, {
Uri? overrideUri,
bool waitForConnection = true,
}) async {
final Uri baseAppUri = baseUri.resolve(
'index.html?uri=${Uri.encodeQueryComponent(appFixture.serviceUri.toString())}',
);
await tabInstance.tab.navigate('${overrideUri ?? baseAppUri}');
// wait for app initialization
await Future.wait([
waitForConnection
? tabInstance.onEvent
.firstWhere((msg) => msg.event == 'app.devToolsReady')
: Future.value(),
tabInstance.getBrowserChannel(),
]);
}
Future<void> switchPage(String page) async {
await tabInstance.send('switchPage', page);
}
Future<String?> currentPageId() async {
final AppResponse response = await tabInstance.send('currentPageId');
return response.result as String?;
}
}
class BrowserManager {
BrowserManager._(this.chromeProcess, this.tab);
static Future<BrowserManager> create() async {
final Chrome? chrome = Chrome.locate();
if (chrome == null) {
throw 'unable to locate Chrome';
}
final ChromeProcess chromeProcess = await chrome.start();
final ChromeTab tab = (await chromeProcess.getFirstTab())!;
await tab.connect();
return BrowserManager._(chromeProcess, tab);
}
final ChromeProcess chromeProcess;
final ChromeTab tab;
Future<BrowserTabInstance> createNewTab() async {
final String targetId = await this.tab.createNewTarget();
await delay();
final ChromeTab tab =
(await chromeProcess.connectToTabId('localhost', targetId))!;
await tab.connect(verbose: true);
await delay();
await tab.wipConnection!.target.activateTarget(targetId);
await delay();
return BrowserTabInstance(tab);
}
Future<void> teardown() async {
chromeProcess.kill();
}
}
class BrowserTabInstance {
BrowserTabInstance(this.tab) {
tab.onConsoleAPICalled
.where((ConsoleAPIEvent event) => event.type == 'log')
.listen((ConsoleAPIEvent event) {
if (event.args.isNotEmpty) {
final RemoteObject message = event.args.first;
final String value = '${message.value}';
if (value.startsWith('[') && value.endsWith(']')) {
try {
final dynamic msg =
jsonDecode(value.substring(1, value.length - 1));
if (msg is Map) {
_handleBrowserMessage(msg);
}
} catch (_) {
// ignore
}
}
}
});
}
final ChromeTab tab;
RemoteObject? _remote;
Future<RemoteObject> getBrowserChannel() async {
final DateTime start = DateTime.now();
final DateTime end = start.add(const Duration(seconds: 30));
while (true) {
try {
return await _getAppChannelObject();
} catch (e) {
if (end.isBefore(DateTime.now())) {
final Duration duration = DateTime.now().difference(start);
print('timeout getting the browser channel object ($duration)');
rethrow;
}
}
await Future<void>.delayed(const Duration(milliseconds: 25));
}
}
Future<RemoteObject> _getAppChannelObject() {
return tab.wipConnection!.runtime.evaluate('devtools');
}
int _nextId = 1;
final Map<int, Completer<AppResponse>> _completers =
<int, Completer<AppResponse>>{};
final StreamController<AppEvent> _eventStream =
StreamController<AppEvent>.broadcast();
Stream<AppEvent> get onEvent => _eventStream.stream;
Future<AppResponse> send(String method, [dynamic params]) async {
_remote ??= await _getAppChannelObject();
final int id = _nextId++;
final Completer<AppResponse> completer = Completer<AppResponse>();
_completers[id] = completer;
try {
await tab.wipConnection!.runtime.callFunctionOn(
"function (method, id, params) { return window['devtools'].send(method, id, params); }",
objectId: _remote!.objectId,
arguments: <dynamic>[method, id, params],
);
return completer.future;
} catch (e, st) {
_completers.remove(id);
completer.completeError(e, st);
rethrow;
}
}
Future<void> close() async {
// In Headless Chrome, we get Inspector.detached when we close the last
// target rather than a response.
await Future.any(<Future<Object>>[
tab.wipConnection!.onNotification
.firstWhere((n) => n.method == 'Inspector.detached'),
tab.wipConnection!.target.closeTarget(tab.wipTab.id),
]);
}
void _handleBrowserMessage(Map<dynamic, dynamic> message) {
if (verboseTesting) {
print(message);
}
if (message.containsKey('id')) {
// handle a response: {id: 1}
final AppResponse response = AppResponse(message);
final Completer<AppResponse> completer = _completers.remove(response.id)!;
if (response.hasError) {
completer.completeError(response.error);
} else {
completer.complete(response);
}
} else {
// handle an event: {event: app.echo, params: foo}
_eventStream.add(AppEvent(message));
}
}
}
class AppEvent {
AppEvent(this.json);
final Map<dynamic, dynamic> json;
String? get event => json['event'];
dynamic get params => json['params'];
@override
String toString() => '$event ${params ?? ''}';
}
class AppResponse {
AppResponse(this.json);
final Map<dynamic, dynamic> json;
int? get id => json['id'];
dynamic get result => json['result'];
bool get hasError => json.containsKey('error');
AppError get error => AppError(json['error']);
@override
String toString() {
return hasError ? error.toString() : result.toString();
}
}
class AppError {
AppError(this.json);
final Map<dynamic, dynamic> json;
String? get message => json['message'];
String? get stackTrace => json['stackTrace'];
@override
String toString() => '$message\n$stackTrace';
}
class WebBuildFixture {
WebBuildFixture._(this.process, this.url, this.verbose);
static Future<WebBuildFixture> serve({
bool release = false,
bool verbose = false,
}) async {
final List<String> cliArgs = [
'pub',
'run',
'build_runner',
'serve',
'web',
'--delete-conflicting-outputs'
];
if (release) {
cliArgs.add('--release');
}
final process = await _runFlutter(cliArgs);
final Completer<String> hasUrl = Completer<String>();
_toLines(process.stderr).listen((String line) {
if (verbose || hasUrl.isCompleted) {
print(
'pub run build_runner serve • ${process.pid}'
' • STDERR • ${line.trim()}',
);
}
final err = 'error starting webdev: $line';
if (!hasUrl.isCompleted) {
hasUrl.completeError(err);
} else {
print('Ignoring stderr output because already completed');
}
});
_toLines(process.stdout).listen((String line) {
if (verbose) {
print('pub run build_runner serve • ${process.pid} • ${line.trim()}');
}
// Serving `web` on http://localhost:8080
if (line.contains('Serving `web`')) {
if (!hasUrl.isCompleted) {
final String url = line.substring(line.indexOf('http://'));
hasUrl.complete(url);
} else {
print('Ignoring "Serving..." notification because already completed');
}
}
});
final String url = await hasUrl.future;
await delay();
return WebBuildFixture._(process, url, verbose);
}
static Future<void> build({
bool verbose = false,
}) async {
final clean = await _runFlutter(['clean']);
expect(await clean.exitCode, 0);
final pubGet = await _runFlutter(['pub', 'get']);
expect(await pubGet.exitCode, 0);
final List<String> cliArgs = [];
String commandName;
commandName = 'flutter build web';
cliArgs.addAll([
'build',
'web',
'--pwa-strategy=none',
'--dart-define=FLUTTER_WEB_USE_SKIA=true',
'--no-tree-shake-icons'
]);
final process = await _runFlutter(cliArgs, verbose: verbose);
final Completer<void> buildFinished = Completer<void>();
_toLines(process.stderr).listen((String line) {
// TODO(https://github.com/flutter/devtools/issues/2477): this is a
// work around for an expected warning that would otherwise fail the test.
if (line.toLowerCase().contains('warning')) {
return;
}
if (line.toLowerCase().contains(' from path ../devtools_')) {
return;
}
final err = 'error building flutter: $line';
if (!buildFinished.isCompleted) {
buildFinished.completeError(err);
} else {
print(err);
}
});
_toLines(process.stdout).listen((String line) {
if (verbose) {
print('$commandName • ${line.trim()}');
}
if (!buildFinished.isCompleted) {
if (line.contains('[INFO] Succeeded')) {
buildFinished.complete();
} else if (line.contains('[SEVERE]')) {
buildFinished.completeError(line);
}
}
});
unawaited(
process.exitCode.then((code) {
if (!buildFinished.isCompleted) {
if (code == 0) {
buildFinished.complete();
} else {
buildFinished.completeError('Exited with code $code');
}
}
}),
);
await buildFinished.future.catchError((e) {
fail('Build failed: $e');
});
await process.exitCode;
}
final Process process;
final String url;
final bool verbose;
Uri get baseUri => Uri.parse(url);
Future<void> teardown() async {
process.kill();
final exitCode = await process.exitCode;
if (verbose) {
print('flutter exited with code $exitCode');
}
}
static Future<Process> _runFlutter(
List<String> buildArgs, {
bool verbose = false,
}) async {
// Remove the DART_VM_OPTIONS env variable from the child process, so the
// Dart VM doesn't try and open a service protocol port if
// 'DART_VM_OPTIONS: --enable-vm-service:63990' was passed in.
final Map<String, String> environment =
Map<String, String>.from(Platform.environment);
if (environment.containsKey('DART_VM_OPTIONS')) {
environment['DART_VM_OPTIONS'] = '';
}
// TODO(https://github.com/flutter/devtools/issues/1145): The pub-based
// version of this code would run a pub snapshot instead of starting pub
// directly to prevent Windows-based test runs getting killed but leaving
// the pub process behind. Something similar might be needed here.
// See here for more information:
// https://github.com/flutter/flutter/wiki/The-flutter-tool#debugging-the-flutter-command-line-tool
final executable = Platform.isWindows ? 'flutter.bat' : 'flutter';
if (verbose) {
print(
'Running "$executable" with args: ${buildArgs.join(' ')} from ${Directory.current.path}',
);
}
return Process.start(
executable,
buildArgs,
environment: environment,
workingDirectory: Directory.current.path,
);
}
static Stream<String> _toLines(Stream<List<int>> stream) =>
stream.transform(utf8.decoder).transform(const LineSplitter());
}