Unverified Commit e5d6d924 authored by Jonah Williams's avatar Jonah Williams Committed by GitHub

Enable usage of experimental incremental compiler for web (#43576)

parent 99f2b940
......@@ -6,5 +6,5 @@ import 'package:flutter_devicelab/framework/framework.dart';
import 'package:flutter_devicelab/tasks/web_dev_mode_tests.dart';
Future<void> main() async {
await task(createWebDevModeTest());
await task(createWebDevModeTest(WebDevice.webServer, false));
}
......@@ -6,5 +6,5 @@ import 'package:flutter_devicelab/framework/framework.dart';
import 'package:flutter_devicelab/tasks/web_dev_mode_tests.dart';
Future<void> main() async {
await task(createWebDevModeTest());
await task(createWebDevModeTest(WebDevice.webServer, false));
}
// Copyright 2019 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.
import 'package:flutter_devicelab/framework/framework.dart';
import 'package:flutter_devicelab/tasks/web_dev_mode_tests.dart';
Future<void> main() async {
await task(createWebDevModeTest(WebDevice.chrome, true));
}
......@@ -6,5 +6,5 @@ import 'package:flutter_devicelab/framework/framework.dart';
import 'package:flutter_devicelab/tasks/web_dev_mode_tests.dart';
Future<void> main() async {
await task(createWebDevModeTest());
await task(createWebDevModeTest(WebDevice.webServer, false));
}
......@@ -20,12 +20,21 @@ const String kFirstRecompileTime = 'FirstRecompileTime';
const String kSecondStartupTime = 'SecondStartupTime';
const String kSecondRestartTime = 'SecondRestartTime';
TaskFunction createWebDevModeTest() {
abstract class WebDevice {
static const String chrome = 'chrome';
static const String webServer = 'web-server';
}
TaskFunction createWebDevModeTest(String webDevice, bool enableIncrementalCompiler) {
return () async {
final List<String> options = <String>[
'--hot', '-d', 'web-server', '--verbose', '--resident', '--target=lib/main.dart',
'--hot', '-d', webDevice, '--verbose', '--resident', '--target=lib/main.dart',
];
int hotRestartCount = 0;
final String expectedMessage = webDevice == WebDevice.webServer
? 'Recompile complete'
: 'Reloaded application';
final Map<String, int> measurements = <String, int>{};
await inDirectory<void>(flutterDirectory, () async {
rmTree(_editedFlutterGalleryDir);
......@@ -38,6 +47,8 @@ TaskFunction createWebDevModeTest() {
<String>['packages', 'get'],
environment: <String, String>{
'FLUTTER_WEB': 'true',
if (enableIncrementalCompiler)
'WEB_INCREMENTAL_COMPILER': 'true',
},
);
await packagesGet.exitCode;
......@@ -46,16 +57,26 @@ TaskFunction createWebDevModeTest() {
flutterCommandArgs('run', options),
environment: <String, String>{
'FLUTTER_WEB': 'true',
if (enableIncrementalCompiler)
'WEB_INCREMENTAL_COMPILER': 'true',
},
);
final Completer<void> stdoutDone = Completer<void>();
final Completer<void> stderrDone = Completer<void>();
final Stopwatch sw = Stopwatch()..start();
bool restarted = false;
process.stdout
.transform<String>(utf8.decoder)
.transform<String>(const LineSplitter())
.listen((String line) {
// TODO(jonahwilliams): non-dwds builds do not know when the browser is loaded.
if (line.contains('Ignoring terminal input')) {
Future<void>.delayed(const Duration(seconds: 1)).then((void _) {
process.stdin.write(restarted ? 'q' : 'r');
});
return;
}
if (line.contains('To hot restart')) {
// measure clean start-up time.
sw.stop();
......@@ -63,9 +84,10 @@ TaskFunction createWebDevModeTest() {
sw
..reset()
..start();
process.stdin.write('R');
process.stdin.write('r');
return;
}
if (line.contains('Recompile complete')) {
if (line.contains(expectedMessage)) {
if (hotRestartCount == 0) {
measurements[kFirstRestartTime] = sw.elapsedMilliseconds;
// Update the file and reload again.
......@@ -80,9 +102,10 @@ TaskFunction createWebDevModeTest() {
sw
..reset()
..start();
process.stdin.writeln('R');
process.stdin.writeln('r');
++hotRestartCount;
} else {
restarted = true;
measurements[kFirstRecompileTime] = sw.elapsedMilliseconds;
// Quit after second hot restart.
process.stdin.writeln('q');
......@@ -119,24 +142,35 @@ TaskFunction createWebDevModeTest() {
flutterCommandArgs('run', options),
environment: <String, String>{
'FLUTTER_WEB': 'true',
if (enableIncrementalCompiler)
'WEB_INCREMENTAL_COMPILER': 'true',
},
);
final Completer<void> stdoutDone = Completer<void>();
final Completer<void> stderrDone = Completer<void>();
bool restarted = false;
process.stdout
.transform<String>(utf8.decoder)
.transform<String>(const LineSplitter())
.listen((String line) {
// TODO(jonahwilliams): non-dwds builds do not know when the browser is loaded.
if (line.contains('Ignoring terminal input')) {
Future<void>.delayed(const Duration(seconds: 1)).then((void _) {
process.stdin.write(restarted ? 'q' : 'r');
});
return;
}
if (line.contains('To hot restart')) {
measurements[kSecondStartupTime] = sw.elapsedMilliseconds;
sw
..reset()
..start();
process.stdin.write('R');
process.stdin.write('r');
return;
}
if (line.contains('Recompile complete')) {
measurements[kSecondRestartTime] = sw.elapsedMilliseconds;
if (line.contains(expectedMessage)) {
restarted = true;
measurements[kSecondRestartTime] = sw.elapsedMilliseconds;
process.stdin.writeln('q');
}
print('stdout: $line');
......
......@@ -112,6 +112,13 @@ tasks:
stage: devicelab
required_agent_capabilities: ["linux/android"]
web_incremental_test:
description: >
Verify that the experimental frontend server support is functional.
stage: devicelab
required_agent_capabilities: ["linux/android"]
flaky: true
flutter_gallery_ios__compile:
description: >
Collects various performance metrics of compiling the Flutter
......
......@@ -28,7 +28,7 @@ enum Artifact {
platformLibrariesJson,
flutterPatchedSdkPath,
frontendServerSnapshotForEngineDartSdk,
/// The root directory of the dartk SDK.
/// The root directory of the dart SDK.
engineDartSdkPath,
/// The dart binary used to execute any of the required snapshots.
engineDartBinary,
......
......@@ -430,7 +430,7 @@ class AppDomain extends Domain {
if (await device.targetPlatform == TargetPlatform.web_javascript) {
runner = webRunnerFactory.createWebRunner(
device,
flutterDevice,
flutterProject: flutterProject,
target: target,
debuggingOptions: options,
......
......@@ -453,7 +453,7 @@ class RunCommand extends RunCommandBase {
);
} else if (webMode) {
runner = webRunnerFactory.createWebRunner(
devices.single,
flutterDevices.single,
target: targetFile,
flutterProject: flutterProject,
ipv6: ipv6,
......
......@@ -201,7 +201,6 @@ class PackageUriMapper {
PackageUriMapper(String scriptPath, String packagesPath, String fileSystemScheme, List<String> fileSystemRoots) {
final Map<String, Uri> packageMap = PackageMap(fs.path.absolute(packagesPath)).map;
final String scriptUri = Uri.file(scriptPath, windows: platform.isWindows).toString();
for (String packageName in packageMap.keys) {
final String prefix = packageMap[packageName].toString();
// Only perform a multi-root mapping if there are multiple roots.
......
......@@ -349,12 +349,19 @@ class UpdateFSReport {
int get invalidatedSourcesCount => _invalidatedSourcesCount;
int get syncedBytes => _syncedBytes;
/// JavaScript modules produced by the incremental compiler in `dartdevc`
/// mode.
///
/// Only used for JavaScript compilation.
List<String> invalidatedModules;
void incorporateResults(UpdateFSReport report) {
if (!report._success) {
_success = false;
}
_invalidatedSourcesCount += report._invalidatedSourcesCount;
_syncedBytes += report._syncedBytes;
invalidatedModules ??= report.invalidatedModules;
}
bool _success;
......
......@@ -151,10 +151,15 @@ const Feature flutterAndroidEmbeddingV2Feature = Feature(
const Feature flutterWebIncrementalCompiler = Feature(
name: 'Enable the incremental compiler for web builds',
configSetting: 'enable-web-incremental-compiler',
environmentOverride: 'WEB_INCREMENTAL_COMPILER',
master: FeatureChannelSetting(
available: true,
enabledByDefault: false,
),
dev: FeatureChannelSetting(
available: true,
enabledByDefault: false,
),
);
/// A [Feature] is a process for conditionally enabling tool features.
......
......@@ -75,21 +75,25 @@ define("main_module", ["$entrypoint", "dart_sdk"], function(app, dart_sdk) {
dart_sdk.dart.setStartAsyncSynchronously(true);
dart_sdk._isolate_helper.startRootIsolate(() => {}, []);
dart_sdk._debugger.registerDevtoolsFormatter();
dart_sdk.ui.webOnlyInitializePlatform();
let voidToNull = () => (voidToNull = dart_sdk.dart.constFn(dart_sdk.dart.fnType(dart_sdk.core.Null, [dart_sdk.dart.void])))();
// Attach the main entrypoint and hot reload functionality to the window.
window.\$mainEntrypoint = app.main.main;
if (window.\$hotReload == null) {
window.\$hotReload = function(cb) {
dart_sdk.developer.invokeExtension("ext.flutter.disassemble", "{}");
dart_sdk.dart.hotRestart();
window.\$mainEntrypoint();
if (cb != null) {
cb();
}
dart_sdk.developer.invokeExtension("ext.flutter.disassemble", "{}").then((_) => {
dart_sdk.dart.hotRestart();
dart_sdk.ui.webOnlyInitializePlatform().then(dart_sdk.core.Null, dart_sdk.dart.fn(_ => {
window.\$mainEntrypoint();
window.requestAnimationFrame(cb);
}, voidToNull()));
});
}
}
app.main.main();
dart_sdk.ui.webOnlyInitializePlatform().then(dart_sdk.core.Null, dart_sdk.dart.fn(_ => {
app.main.main();
}, voidToNull()));
});
// Require JS configuration.
......
......@@ -71,6 +71,11 @@ void resetChromeForTesting() {
ChromeLauncher._currentCompleter = Completer<Chrome>();
}
@visibleForTesting
void launchChromeInstance(Chrome chrome) {
ChromeLauncher._currentCompleter.complete(chrome);
}
/// Responsible for launching chrome with devtools configured.
class ChromeLauncher {
const ChromeLauncher();
......
......@@ -7,12 +7,18 @@ import 'dart:typed_data';
import 'package:meta/meta.dart';
import 'package:mime/mime.dart' as mime;
import '../artifacts.dart';
import '../asset.dart';
import '../base/common.dart';
import '../base/file_system.dart';
import '../base/io.dart';
import '../build_info.dart';
import '../bundle.dart';
import '../compile.dart';
import '../convert.dart';
import '../devfs.dart';
import '../globals.dart';
import 'bootstrap.dart';
/// A web server which handles serving JavaScript and assets.
///
......@@ -49,7 +55,10 @@ class WebAssetServer {
}
final HttpServer _httpServer;
// If holding these in memory is too much overhead, this can be switched to a
// RandomAccessFile and read on demand.
final Map<String, Uint8List> _files = <String, Uint8List>{};
final Map<String, Uint8List> _sourcemaps = <String, Uint8List>{};
// handle requests for JavaScript source, dart sources maps, or asset files.
Future<void> _handleRequest(HttpRequest request) async {
......@@ -71,8 +80,7 @@ class WebAssetServer {
}
// If this is a JavaScript file, it must be in the in-memory cache.
// Attempt to look up the file by URI, returning a 404 if it is not
// found.
// Attempt to look up the file by URI.
if (_files.containsKey(request.uri.path)) {
final List<int> bytes = _files[request.uri.path];
response.headers
......@@ -82,6 +90,18 @@ class WebAssetServer {
await response.close();
return;
}
// If this is a sourcemap file, then it might be in the in-memory cache.
// Attempt to lookup the file by URI.
if (_sourcemaps.containsKey(request.uri.path)) {
final List<int> bytes = _sourcemaps[request.uri.path];
response.headers
..add('Content-Length', bytes.length)
..add('Content-Type', 'application/json');
response.add(bytes);
await response.close();
return;
}
// If this is a dart file, it must be on the local file system and is
// likely coming from a source map request. Attempt to look in the
// local filesystem for it, and return a 404 if it is not found. The tool
......@@ -95,6 +115,18 @@ class WebAssetServer {
file = fs.file(fs.path.join(getAssetBuildDirectory(), fs.path.relative(assetPath)));
}
// If it isn't a project source or an asset, it must be a dart SDK source.
// or a flutter web SDK source.
if (!file.existsSync()) {
final Directory dartSdkParent = fs.directory(artifacts.getArtifactPath(Artifact.engineDartSdkPath)).parent;
file = fs.file(fs.path.joinAll(<String>[dartSdkParent.path, ...request.uri.pathSegments]));
}
if (!file.existsSync()) {
final String flutterWebSdk = artifacts.getArtifactPath(Artifact.flutterWebSdk);
file = fs.file(fs.path.joinAll(<String>[flutterWebSdk, ...request.uri.pathSegments]));
}
if (!file.existsSync()) {
response.statusCode = HttpStatus.notFound;
await response.close();
......@@ -131,30 +163,186 @@ class WebAssetServer {
/// Update the in-memory asset server with the provided source and manifest files.
///
/// Returns a list of updated modules.
List<String> write(File sourceFile, File manifestFile) {
List<String> write(File codeFile, File manifestFile, File sourcemapFile) {
final List<String> modules = <String>[];
final Uint8List bytes = sourceFile.readAsBytesSync();
final Uint8List codeBytes = codeFile.readAsBytesSync();
final Uint8List sourcemapBytes = sourcemapFile.readAsBytesSync();
final Map<String, Object> manifest = json.decode(manifestFile.readAsStringSync());
for (String filePath in manifest.keys) {
if (filePath == null) {
printTrace('Invalid manfiest file: $filePath');
continue;
}
final List<Object> offsets = manifest[filePath];
if (offsets.length != 2) {
final Map<String, Object> offsets = manifest[filePath];
final List<Object> codeOffsets = offsets['code'];
final List<Object> sourcemapOffsets = offsets['sourcemap'];
if (codeOffsets.length != 2 || sourcemapOffsets.length != 2) {
printTrace('Invalid manifest byte offsets: $offsets');
continue;
}
final int start = offsets[0];
final int end = offsets[1];
if (start < 0 || end > bytes.lengthInBytes) {
printTrace('Invalid byte index: [$start, $end]');
final int codeStart = codeOffsets[0];
final int codeEnd = codeOffsets[1];
if (codeStart < 0 || codeEnd > codeBytes.lengthInBytes) {
printTrace('Invalid byte index: [$codeStart, $codeEnd]');
continue;
}
final Uint8List byteView = Uint8List.view(bytes.buffer, start, end - start);
final Uint8List byteView = Uint8List.view(
codeBytes.buffer,
codeStart,
codeEnd - codeStart,
);
_files[filePath] = byteView;
final int sourcemapStart = sourcemapOffsets[0];
final int sourcemapEnd = sourcemapOffsets[1];
if (sourcemapStart < 0 || sourcemapEnd > sourcemapBytes.lengthInBytes) {
printTrace('Invalid byte index: [$sourcemapStart, $sourcemapEnd]');
continue;
}
final Uint8List sourcemapView = Uint8List.view(
sourcemapBytes.buffer,
sourcemapStart,
sourcemapEnd - sourcemapStart ,
);
_sourcemaps['$filePath.map'] = sourcemapView;
modules.add(filePath);
}
return modules;
}
}
class WebDevFS implements DevFS {
WebDevFS(this.hostname, this.port, this._packagesFilePath);
final String hostname;
final int port;
final String _packagesFilePath;
WebAssetServer _webAssetServer;
@override
List<Uri> sources = <Uri>[];
@override
DateTime lastCompiled;
// We do not evict assets on the web.
@override
Set<String> get assetPathsToEvict => const <String>{};
@override
Uri get baseUri => null;
@override
Future<Uri> create() async {
_webAssetServer = await WebAssetServer.start(hostname, port);
return Uri.base;
}
@override
Future<void> destroy() async {
await _webAssetServer.dispose();
}
@override
Uri deviceUriToHostUri(Uri deviceUri) {
return deviceUri;
}
@override
String get fsName => 'web_asset';
@override
Directory get rootDirectory => null;
@override
Future<UpdateFSReport> update({
String mainPath,
String target,
AssetBundle bundle,
DateTime firstBuildTime,
bool bundleFirstUpload = false,
@required ResidentCompiler generator,
String dillOutputPath,
@required bool trackWidgetCreation,
bool fullRestart = false,
String projectRootPath,
String pathToReload,
List<Uri> invalidatedFiles,
}) async {
assert(trackWidgetCreation != null);
assert(generator != null);
if (bundleFirstUpload) {
final File requireJS = fs.file(fs.path.join(
artifacts.getArtifactPath(Artifact.engineDartSdkPath),
'lib',
'dev_compiler',
'kernel',
'amd',
'require.js',
));
final File dartSdk = fs.file(fs.path.join(
artifacts.getArtifactPath(Artifact.flutterWebSdk),
'kernel',
'amd',
'dart_sdk.js',
));
final File dartSdkSourcemap = fs.file(fs.path.join(
artifacts.getArtifactPath(Artifact.flutterWebSdk),
'kernel',
'amd',
'dart_sdk.js.map',
));
final File stackTraceMapper = fs.file(fs.path.join(
artifacts.getArtifactPath(Artifact.engineDartSdkPath),
'lib',
'dev_compiler',
'web',
'dart_stack_trace_mapper.js',
));
_webAssetServer.writeFile('/main.dart.js', generateBootstrapScript(
requireUrl: requireJS.path,
mapperUrl: stackTraceMapper.path,
entrypoint: '$mainPath.js',
));
_webAssetServer.writeFile('/main_module.js', generateMainModule(
entrypoint: '$mainPath.js',
));
_webAssetServer.writeFile('/dart_sdk.js', dartSdk.readAsStringSync());
_webAssetServer.writeFile('/dart_sdk.js.map', dartSdkSourcemap.readAsStringSync());
}
final DateTime candidateCompileTime = DateTime.now();
if (fullRestart) {
generator.reset();
}
final CompilerOutput compilerOutput = await generator.recompile(
mainPath,
invalidatedFiles,
outputPath: dillOutputPath ?? getDefaultApplicationKernelPath(trackWidgetCreation: trackWidgetCreation),
packagesFilePath : _packagesFilePath,
);
if (compilerOutput == null || compilerOutput.errorCount > 0) {
return UpdateFSReport(success: false);
}
// Only update the last compiled time if we successfully compiled.
lastCompiled = candidateCompileTime;
// list of sources that needs to be monitored are in [compilerOutput.sources]
sources = compilerOutput.sources;
File codeFile;
File manifestFile;
File sourcemapFile;
List<String> modules;
try {
codeFile = fs.file('${compilerOutput.outputFilename}.sources');
manifestFile = fs.file('${compilerOutput.outputFilename}.json');
sourcemapFile = fs.file('${compilerOutput.outputFilename}.map');
modules = _webAssetServer.write(codeFile, manifestFile, sourcemapFile);
} on FileSystemException catch (err) {
throwToolExit('Failed to load recompiled sources:\n$err');
}
return UpdateFSReport(success: true, syncedBytes: codeFile.lengthSync(),
invalidatedSourcesCount: invalidatedFiles.length)
..invalidatedModules = modules;
}
}
......@@ -17,7 +17,7 @@ abstract class WebRunnerFactory {
/// Create a [ResidentRunner] for the web.
ResidentRunner createWebRunner(
Device device, {
FlutterDevice device, {
String target,
@required bool stayResident,
@required FlutterProject flutterProject,
......
......@@ -63,11 +63,12 @@ void main() {
test('Refuses to build using runner when missing index.html', () => testbed.run(() async {
fs.file(fs.path.join('web', 'index.html')).deleteSync();
final ResidentWebRunner runner = ResidentWebRunner(
final ResidentWebRunner runner = DwdsWebRunnerFactory().createWebRunner(
null,
flutterProject: FlutterProject.current(),
ipv6: false,
debuggingOptions: DebuggingOptions.enabled(BuildInfo.debug),
stayResident: true,
);
expect(await runner.run(), 1);
}));
......
......@@ -26,17 +26,21 @@ void main() {
Testbed testbed;
MockFlutterWebFs mockWebFs;
ResidentWebRunner residentWebRunner;
MockFlutterDevice mockFlutterDevice;
setUp(() {
mockWebFs = MockFlutterWebFs();
final MockWebDevice mockWebDevice = MockWebDevice();
mockFlutterDevice = MockFlutterDevice();
when(mockFlutterDevice.device).thenReturn(mockWebDevice);
testbed = Testbed(
setup: () {
residentWebRunner = ResidentWebRunner(
mockWebDevice,
residentWebRunner = residentWebRunner = DwdsWebRunnerFactory().createWebRunner(
mockFlutterDevice,
flutterProject: FlutterProject.current(),
debuggingOptions: DebuggingOptions.disabled(BuildInfo.release),
ipv6: true,
stayResident: true,
);
},
overrides: <Type, Generator>{
......@@ -118,4 +122,4 @@ class MockFlutterWebFs extends Mock implements WebFs {}
class MockDebugConnection extends Mock implements DebugConnection {}
class MockVmService extends Mock implements VmService {}
class MockStatus extends Mock implements Status {}
class MockFlutterDevice extends Mock implements FlutterDevice {}
......@@ -89,28 +89,36 @@ void main() {
test('Handles against malformed manifest', () => testbed.run(() async {
final File source = fs.file('source')
..writeAsStringSync('main() {}');
final File sourcemap = fs.file('sourcemap')
..writeAsStringSync('{}');
// Missing ending offset.
final File manifestMissingOffset = fs.file('manifestA')
..writeAsStringSync(json.encode(<String, Object>{'/foo.js': <int>[0]}));
// Non-file URI.
final File manifestNonFileScheme = fs.file('manifestA')
..writeAsStringSync(json.encode(<String, Object>{'/foo.js': <int>[0, 10]}));
..writeAsStringSync(json.encode(<String, Object>{'/foo.js': <String, Object>{
'code': <int>[0],
'sourcemap': <int>[0],
}}));
final File manifestOutOfBounds = fs.file('manifest')
..writeAsStringSync(json.encode(<String, Object>{'/foo.js': <int>[0, 100]}));
..writeAsStringSync(json.encode(<String, Object>{'/foo.js': <String, Object>{
'code': <int>[0, 100],
'sourcemap': <int>[0],
}}));
expect(webAssetServer.write(source, manifestMissingOffset), isEmpty);
expect(webAssetServer.write(source, manifestNonFileScheme), isEmpty);
expect(webAssetServer.write(source, manifestOutOfBounds), isEmpty);
expect(webAssetServer.write(source, manifestMissingOffset, sourcemap), isEmpty);
expect(webAssetServer.write(source, manifestOutOfBounds, sourcemap), isEmpty);
}));
test('serves JavaScript files from in memory cache', () => testbed.run(() async {
final File source = fs.file('source')
..writeAsStringSync('main() {}');
final File sourcemap = fs.file('sourcemap')
..writeAsStringSync('{}');
final File manifest = fs.file('manifest')
..writeAsStringSync(json.encode(<String, Object>{'/foo.js': <int>[0, source.lengthSync()]}));
webAssetServer.write(source, manifest);
..writeAsStringSync(json.encode(<String, Object>{'/foo.js': <String, Object>{
'code': <int>[0, source.lengthSync()],
'sourcemap': <int>[0, 2],
}}));
webAssetServer.write(source, manifest, sourcemap);
when(request.uri).thenReturn(Uri.parse('http://foobar/foo.js'));
requestController.add(request);
......@@ -136,9 +144,14 @@ void main() {
test('handles missing JavaScript files from in memory cache', () => testbed.run(() async {
final File source = fs.file('source')
..writeAsStringSync('main() {}');
final File sourcemap = fs.file('sourcemap')
..writeAsStringSync('{}');
final File manifest = fs.file('manifest')
..writeAsStringSync(json.encode(<String, Object>{'/foo.js': <int>[0, source.lengthSync()]}));
webAssetServer.write(source, manifest);
..writeAsStringSync(json.encode(<String, Object>{'/foo.js': <String, Object>{
'code': <int>[0, source.lengthSync()],
'sourcemap': <int>[0, 2],
}}));
webAssetServer.write(source, manifest, sourcemap);
when(request.uri).thenReturn(Uri.parse('http://foobar/bar.js'));
requestController.add(request);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment