diff --git a/dev/bots/browser.dart b/dev/bots/browser.dart index 4e3add3c05..5d1a55c642 100644 --- a/dev/bots/browser.dart +++ b/dev/bots/browser.dart @@ -57,3 +57,57 @@ Future evalTestAppInChrome({ await server?.close(); } } + +typedef ServerRequestListener = void Function(Request); + +class AppServer { + AppServer._(this._server, this.chrome, this.onChromeError); + + static Future start({ + @required String appUrl, + @required String appDirectory, + @required String cacheControl, + int serverPort = 8080, + int browserDebugPort = 8081, + bool headless = true, + List additionalRequestHandlers, + }) async { + io.HttpServer server; + Chrome chrome; + server = await io.HttpServer.bind('localhost', serverPort); + final Handler staticHandler = createStaticHandler(appDirectory, defaultDocument: 'index.html'); + Cascade cascade = Cascade(); + if (additionalRequestHandlers != null) { + for (final Handler handler in additionalRequestHandlers) { + cascade = cascade.add(handler); + } + } + cascade = cascade.add((Request request) async { + final Response response = await staticHandler(request); + return response.change(headers: { + 'cache-control': cacheControl, + }); + }); + shelf_io.serveRequests(server, cascade.handler); + final io.Directory userDataDirectory = io.Directory.systemTemp.createTempSync('chrome_user_data_'); + final Completer chromeErrorCompleter = Completer(); + chrome = await Chrome.launch(ChromeOptions( + headless: headless, + debugPort: browserDebugPort, + url: appUrl, + userDataDirectory: userDataDirectory.path, + windowHeight: 1024, + windowWidth: 1024, + ), onError: chromeErrorCompleter.complete); + return AppServer._(server, chrome, chromeErrorCompleter.future); + } + + final Future onChromeError; + final io.HttpServer _server; + final Chrome chrome; + + Future stop() async { + chrome?.stop(); + await _server?.close(); + } +} diff --git a/dev/bots/service_worker_test.dart b/dev/bots/service_worker_test.dart index 5c5b30a00f..a03c1404e8 100644 --- a/dev/bots/service_worker_test.dart +++ b/dev/bots/service_worker_test.dart @@ -6,138 +6,262 @@ import 'dart:async'; import 'dart:io'; import 'package:path/path.dart' as path; -import 'package:flutter_devicelab/framework/browser.dart'; import 'package:meta/meta.dart'; import 'package:shelf/shelf.dart'; -import 'package:shelf_static/shelf_static.dart'; -import 'package:shelf/shelf_io.dart' as shelf_io; -final String bat = Platform.isWindows ? '.bat' : ''; -final String flutterRoot = path.dirname(path.dirname(path.dirname(path.fromUri(Platform.script)))); -final String flutter = path.join(flutterRoot, 'bin', 'flutter$bat'); +import 'browser.dart'; +import 'run_command.dart'; +import 'test/common.dart'; +final String _bat = Platform.isWindows ? '.bat' : ''; +final String _flutterRoot = path.dirname(path.dirname(path.dirname(path.fromUri(Platform.script)))); +final String _flutter = path.join(_flutterRoot, 'bin', 'flutter$_bat'); +final String _testAppDirectory = path.join(_flutterRoot, 'dev', 'integration_tests', 'web'); +final String _appBuildDirectory = path.join(_testAppDirectory, 'build', 'web'); +final String _target = path.join('lib', 'service_worker_test.dart'); +final String _targetPath = path.join(_testAppDirectory, _target); -// Run a web service worker test. The expectations are currently stored here -// instead of in the application. This is not run on CI due to the requirement -// of having a headful chrome instance. +// Run a web service worker test as a standalone Dart program. Future main() async { - await _runWebServiceWorkerTest('lib/service_worker_test.dart'); + await runWebServiceWorkerTest(headless: false); } -Future _runWebServiceWorkerTest(String target, { - List additionalArguments = const[], -}) async { - final String testAppDirectory = path.join(flutterRoot, 'dev', 'integration_tests', 'web'); - final String appBuildDirectory = path.join(testAppDirectory, 'build', 'web'); - - // Build the app. - await Process.run( - flutter, - [ 'clean' ], - workingDirectory: testAppDirectory, +Future _setAppVersion(int version) async { + final File targetFile = File(_targetPath); + await targetFile.writeAsString( + (await targetFile.readAsString()).replaceFirst( + RegExp(r'CLOSE\?version=\d+'), + 'CLOSE?version=$version', + ) ); - await Process.run( - flutter, - [ - 'build', - 'web', - '--release', - ...additionalArguments, - '-t', - target, - ], - workingDirectory: testAppDirectory, +} + +Future _rebuildApp({ @required int version }) async { + await _setAppVersion(version); + await runCommand( + _flutter, + [ 'clean' ], + workingDirectory: _testAppDirectory, + ); + await runCommand( + _flutter, + ['build', 'web', '--profile', '-t', _target], + workingDirectory: _testAppDirectory, environment: { 'FLUTTER_WEB': 'true', }, ); - final List requests = []; - final List> headers = >[]; - await runRecordingServer( - appUrl: 'http://localhost:8080/', - appDirectory: appBuildDirectory, - requests: requests, - headers: headers, - browserDebugPort: null, - ); - - final List requestedPaths = requests.map((Uri uri) => uri.toString()).toList(); - final List expectedPaths = [ - // Initial page load - '', - 'main.dart.js', - 'assets/FontManifest.json', - 'flutter_service_worker.js', - 'manifest.json', - 'favicon.ico', - // Service worker install. - 'main.dart.js', - 'index.html', - 'assets/LICENSE', - 'assets/AssetManifest.json', - 'assets/FontManifest.json', - '', - // Second page load all cached. - ]; - print('requests: $requestedPaths'); - // The exact order isn't important or deterministic. - for (final String path in requestedPaths) { - if (!expectedPaths.remove(path)) { - print('unexpected service worker request: $path'); - exit(1); - } - } - if (expectedPaths.isNotEmpty) { - print('Missing service worker requests from expected paths: $expectedPaths'); - exit(1); - } } -/// This server runs a release web application and verifies that the service worker -/// caches files correctly, by checking the request resources over HTTP. -/// -/// When it receives a request for `CLOSE` the server will be torn down. -/// -/// Expects a path to the `build/web` directory produced from `flutter build web`. -Future runRecordingServer({ - @required String appUrl, - @required String appDirectory, - @required List requests, - @required List> headers, - int serverPort = 8080, - int browserDebugPort = 8081, +Future runWebServiceWorkerTest({ + @required bool headless, }) async { - Chrome chrome; - HttpServer server; - final Completer completer = Completer(); - Directory userDataDirectory; - try { - server = await HttpServer.bind('localhost', serverPort); - final Cascade cascade = Cascade() - .add((Request request) async { - if (request.url.toString().contains('CLOSE')) { - completer.complete(); - return Response.notFound(''); - } - requests.add(request.url); - headers.add(request.headers); - return Response.notFound(''); - }) - .add(createStaticHandler(appDirectory, defaultDocument: 'index.html')); - shelf_io.serveRequests(server, cascade.handler); - userDataDirectory = Directory.systemTemp.createTempSync('chrome_user_data_'); - chrome = await Chrome.launch(ChromeOptions( - headless: false, - debugPort: browserDebugPort, - url: appUrl, - userDataDirectory: userDataDirectory.path, - windowHeight: 500, - windowWidth: 500, - ), onError: completer.completeError); - await completer.future; - } finally { - chrome?.stop(); - await server?.close(); - userDataDirectory.deleteSync(recursive: true); - } + test('flutter_service_worker.js', () async { + await _rebuildApp(version: 1); + + final Map requestedPathCounts = {}; + void expectRequestCounts(Map expectedCounts) { + expect(requestedPathCounts, expectedCounts); + requestedPathCounts.clear(); + } + + AppServer server; + Future waitForAppToLoad(Map waitForCounts) async { + print('Waiting for app to load $waitForCounts'); + await Future.any(>[ + () async { + while (!waitForCounts.entries.every((MapEntry entry) => (requestedPathCounts[entry.key] ?? 0) >= entry.value)) { + await Future.delayed(const Duration(milliseconds: 100)); + } + }(), + server.onChromeError.then((String error) { + throw Exception('Chrome error: $error'); + }), + ]); + } + + String reportedVersion; + + Future startAppServer({ + @required String cacheControl, + }) async { + server = await AppServer.start( + headless: headless, + cacheControl: cacheControl, + appUrl: 'http://localhost:8080/index.html', + appDirectory: _appBuildDirectory, + additionalRequestHandlers: [ + (Request request) { + final String requestedPath = request.url.path; + requestedPathCounts.putIfAbsent(requestedPath, () => 0); + requestedPathCounts[requestedPath] += 1; + if (requestedPath == 'CLOSE') { + reportedVersion = request.url.queryParameters['version']; + return Response.ok('OK'); + } + return Response.notFound(''); + }, + ], + ); + } + + try { + ////////////////////////////////////////////////////// + // Caching server + ////////////////////////////////////////////////////// + print('With cache: test first page load'); + await startAppServer(cacheControl: 'max-age=3600'); + await waitForAppToLoad({ + 'CLOSE': 1, + 'flutter_service_worker.js': 1, + }); + + expectRequestCounts({ + '': 1, + // Even though the server is caching index.html is downloaded twice, + // once by the initial page load, and once by the service worker. + // Other resources are loaded once only by the service worker. + 'index.html': 2, + 'main.dart.js': 1, + 'flutter_service_worker.js': 1, + 'assets/FontManifest.json': 1, + 'assets/NOTICES': 1, + 'assets/AssetManifest.json': 1, + 'CLOSE': 1, + // In headless mode Chrome does not load 'manifest.json' and 'favicon.ico'. + if (!headless) + ...{ + 'manifest.json': 1, + 'favicon.ico': 1, + } + }); + expect(reportedVersion, '1'); + reportedVersion = null; + + print('With cache: test page reload'); + await server.chrome.reloadPage(); + await waitForAppToLoad({ + 'CLOSE': 1, + 'flutter_service_worker.js': 1, + }); + + expectRequestCounts({ + 'flutter_service_worker.js': 1, + 'CLOSE': 1, + }); + expect(reportedVersion, '1'); + reportedVersion = null; + + print('With cache: test page reload after rebuild'); + await _rebuildApp(version: 2); + + // Since we're caching, we need to ignore cache when reloading the page. + await server.chrome.reloadPage(ignoreCache: true); + await waitForAppToLoad({ + 'CLOSE': 1, + 'flutter_service_worker.js': 2, + }); + expectRequestCounts({ + 'index.html': 2, + 'flutter_service_worker.js': 2, + '': 1, + 'main.dart.js': 1, + 'assets/NOTICES': 1, + 'assets/AssetManifest.json': 1, + 'assets/FontManifest.json': 1, + 'CLOSE': 1, + if (!headless) + 'favicon.ico': 1, + }); + + expect(reportedVersion, '2'); + reportedVersion = null; + await server.stop(); + + + ////////////////////////////////////////////////////// + // Non-caching server + ////////////////////////////////////////////////////// + print('No cache: test first page load'); + await _rebuildApp(version: 3); + await startAppServer(cacheControl: 'max-age=0'); + await waitForAppToLoad({ + 'CLOSE': 1, + 'flutter_service_worker.js': 1, + }); + + expectRequestCounts({ + '': 1, + 'index.html': 2, + // We still download some resources multiple times if the server is non-caching. + 'main.dart.js': 2, + 'assets/FontManifest.json': 2, + 'flutter_service_worker.js': 1, + 'assets/NOTICES': 1, + 'assets/AssetManifest.json': 1, + 'CLOSE': 1, + // In headless mode Chrome does not load 'manifest.json' and 'favicon.ico'. + if (!headless) + ...{ + 'manifest.json': 1, + 'favicon.ico': 1, + } + }); + + expect(reportedVersion, '3'); + reportedVersion = null; + + print('No cache: test page reload'); + await server.chrome.reloadPage(); + await waitForAppToLoad({ + 'CLOSE': 1, + 'flutter_service_worker.js': 1, + }); + + expectRequestCounts({ + 'flutter_service_worker.js': 1, + 'CLOSE': 1, + if (!headless) + 'manifest.json': 1, + }); + expect(reportedVersion, '3'); + reportedVersion = null; + + print('No cache: test page reload after rebuild'); + await _rebuildApp(version: 4); + + // TODO(yjbanov): when running Chrome with DevTools protocol, for some + // reason a hard refresh is still required. This works without a hard + // refresh when running Chrome manually as normal. At the time of writing + // this test I wasn't able to figure out what's wrong with the way we run + // Chrome from tests. + await server.chrome.reloadPage(ignoreCache: true); + await waitForAppToLoad({ + 'CLOSE': 1, + 'flutter_service_worker.js': 1, + }); + expectRequestCounts({ + '': 1, + 'index.html': 2, + 'flutter_service_worker.js': 2, + 'main.dart.js': 2, + 'assets/NOTICES': 1, + 'assets/AssetManifest.json': 1, + 'assets/FontManifest.json': 2, + 'CLOSE': 1, + if (!headless) + ...{ + 'manifest.json': 1, + 'favicon.ico': 1, + } + }); + + expect(reportedVersion, '4'); + reportedVersion = null; + } finally { + await _setAppVersion(1); + await server?.stop(); + } + // This is a long test. The default 30 seconds is not enough. + }, timeout: const Timeout(Duration(minutes: 10))); } diff --git a/dev/bots/test.dart b/dev/bots/test.dart index c99b2e9d74..429a64df62 100644 --- a/dev/bots/test.dart +++ b/dev/bots/test.dart @@ -15,6 +15,7 @@ import 'package:path/path.dart' as path; import 'browser.dart'; import 'flutter_compact_formatter.dart'; import 'run_command.dart'; +import 'service_worker_test.dart'; import 'utils.dart'; typedef ShardRunner = Future Function(); @@ -811,6 +812,7 @@ Future _runWebLongRunningTests() async { () => _runGalleryE2eWebTest('profile', canvasKit: true), () => _runGalleryE2eWebTest('release'), () => _runGalleryE2eWebTest('release', canvasKit: true), + () => runWebServiceWorkerTest(headless: true), ]; await _ensureChromeDriverIsRunning(); await _runShardRunnerIndexOfTotalSubshard(tests); diff --git a/dev/devicelab/lib/framework/browser.dart b/dev/devicelab/lib/framework/browser.dart index c10ca21c6a..8f641d99eb 100644 --- a/dev/devicelab/lib/framework/browser.dart +++ b/dev/devicelab/lib/framework/browser.dart @@ -196,6 +196,10 @@ class Chrome { return data; } + Future reloadPage({bool ignoreCache = false}) async { + await _debugConnection.page.reload(ignoreCache: ignoreCache); + } + /// Stops the Chrome process. void stop() { _isStopped = true; diff --git a/dev/integration_tests/flutter_gallery/web/index.html b/dev/integration_tests/flutter_gallery/web/index.html index 3bc6123c71..757134fa09 100644 --- a/dev/integration_tests/flutter_gallery/web/index.html +++ b/dev/integration_tests/flutter_gallery/web/index.html @@ -23,12 +23,68 @@ found in the LICENSE file. --> application. For more information, see: https://developers.google.com/web/fundamentals/primers/service-workers --> - diff --git a/dev/integration_tests/web/lib/service_worker_test.dart b/dev/integration_tests/web/lib/service_worker_test.dart index d1f90aa4d5..4d563489db 100644 --- a/dev/integration_tests/web/lib/service_worker_test.dart +++ b/dev/integration_tests/web/lib/service_worker_test.dart @@ -4,16 +4,8 @@ import 'dart:html' as html; Future main() async { - final html.ServiceWorkerRegistration worker = await html.window.navigator.serviceWorker.ready; - if (worker.active != null) { - await Future.delayed(const Duration(seconds: 5)); - await html.HttpRequest.getString('CLOSE'); - return; - } - worker.addEventListener('statechange', (event) async { - if (worker.active != null) { - await Future.delayed(const Duration(seconds: 5)); - await html.HttpRequest.getString('CLOSE'); - } - }); + await html.window.navigator.serviceWorker.ready; + final String response = 'CLOSE?version=1'; + await html.HttpRequest.getString(response); + html.document.body.appendHtml(response); } diff --git a/dev/integration_tests/web/web/index.html b/dev/integration_tests/web/web/index.html index fa7ef71069..05d4deecc1 100644 --- a/dev/integration_tests/web/web/index.html +++ b/dev/integration_tests/web/web/index.html @@ -8,6 +8,10 @@ found in the LICENSE file. --> Web Test + + + + @@ -15,12 +19,68 @@ found in the LICENSE file. --> application. For more information, see: https://developers.google.com/web/fundamentals/primers/service-workers --> - diff --git a/packages/flutter_tools/lib/src/build_system/targets/web.dart b/packages/flutter_tools/lib/src/build_system/targets/web.dart index c2eaaf228a..47f75dcf1e 100644 --- a/packages/flutter_tools/lib/src/build_system/targets/web.dart +++ b/packages/flutter_tools/lib/src/build_system/targets/web.dart @@ -356,6 +356,12 @@ class WebReleaseBundle extends Target { if (environment.fileSystem.path.basename(inputFile.path) == 'index.html') { final String randomHash = Random().nextInt(4294967296).toString(); final String resultString = inputFile.readAsStringSync() + .replaceFirst( + 'var serviceWorkerVersion = null', + "var serviceWorkerVersion = '$randomHash'", + ) + // This is for legacy index.html that still use the old service + // worker loading mechanism. .replaceFirst( "navigator.serviceWorker.register('flutter_service_worker.js')", "navigator.serviceWorker.register('flutter_service_worker.js?v=$randomHash')", @@ -492,7 +498,7 @@ self.addEventListener("install", (event) => { return event.waitUntil( caches.open(TEMP).then((cache) => { return cache.addAll( - CORE.map((value) => new Request(value + '?revision=' + RESOURCES[value], {'cache': 'reload'}))); + CORE.map((value) => new Request(value, {'cache': 'reload'}))); }) ); }); diff --git a/packages/flutter_tools/templates/app/web/index.html.tmpl b/packages/flutter_tools/templates/app/web/index.html.tmpl index 8f42214c65..db964b50d6 100644 --- a/packages/flutter_tools/templates/app/web/index.html.tmpl +++ b/packages/flutter_tools/templates/app/web/index.html.tmpl @@ -23,9 +23,6 @@ - - - {{projectName}} @@ -34,12 +31,68 @@ application. For more information, see: https://developers.google.com/web/fundamentals/primers/service-workers --> -