diff --git a/dev/bots/service_worker_test.dart b/dev/bots/service_worker_test.dart new file mode 100644 index 0000000000..5c5b30a00f --- /dev/null +++ b/dev/bots/service_worker_test.dart @@ -0,0 +1,143 @@ +// Copyright 2014 The Flutter 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 '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'); + + +// 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. +Future main() async { + await _runWebServiceWorkerTest('lib/service_worker_test.dart'); +} + +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, + ); + await Process.run( + flutter, + [ + 'build', + 'web', + '--release', + ...additionalArguments, + '-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, +}) 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); + } +} diff --git a/dev/devicelab/lib/framework/browser.dart b/dev/devicelab/lib/framework/browser.dart index 7e0def9fbb..b62c4d74c8 100644 --- a/dev/devicelab/lib/framework/browser.dart +++ b/dev/devicelab/lib/framework/browser.dart @@ -7,6 +7,7 @@ import 'dart:convert' show json, utf8, LineSplitter, JsonEncoder; import 'dart:io' as io; import 'dart:math' as math; +import 'package:path/path.dart' as path; import 'package:meta/meta.dart'; import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart'; @@ -76,8 +77,12 @@ class Chrome { /// process encounters an error. In particular, [onError] is called when the /// Chrome process exits prematurely, i.e. before [stop] is called. static Future launch(ChromeOptions options, { String workingDirectory, @required ChromeErrorCallback onError }) async { - final io.ProcessResult versionResult = io.Process.runSync(_findSystemChromeExecutable(), const ['--version']); - print('Launching ${versionResult.stdout}'); + if (!io.Platform.isWindows) { + final io.ProcessResult versionResult = io.Process.runSync(_findSystemChromeExecutable(), const ['--version']); + print('Launching ${versionResult.stdout}'); + } else { + print('Launching Chrome...'); + } final bool withDebugging = options.debugPort != null; final List args = [ @@ -217,8 +222,23 @@ String _findSystemChromeExecutable() { return (which.stdout as String).trim(); } else if (io.Platform.isMacOS) { return '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; + } else if (io.Platform.isWindows) { + const String kWindowsExecutable = r'Google\Chrome\Application\chrome.exe'; + final List kWindowsPrefixes = [ + io.Platform.environment['LOCALAPPDATA'], + io.Platform.environment['PROGRAMFILES'], + io.Platform.environment['PROGRAMFILES(X86)'], + ]; + final String windowsPrefix = kWindowsPrefixes.firstWhere((String prefix) { + if (prefix == null) { + return false; + } + final String expectedPath = path.join(prefix, kWindowsExecutable); + return io.File(expectedPath).existsSync(); + }, orElse: () => '.'); + return path.join(windowsPrefix, kWindowsExecutable); } else { - throw Exception('Web benchmarks cannot run on ${io.Platform.operatingSystem} yet.'); + throw Exception('Web benchmarks cannot run on ${io.Platform.operatingSystem}.'); } } diff --git a/dev/integration_tests/web/lib/service_worker_test.dart b/dev/integration_tests/web/lib/service_worker_test.dart new file mode 100644 index 0000000000..d1f90aa4d5 --- /dev/null +++ b/dev/integration_tests/web/lib/service_worker_test.dart @@ -0,0 +1,19 @@ +// Copyright 2014 The Flutter 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 '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'); + } + }); +} diff --git a/dev/integration_tests/web/pubspec.yaml b/dev/integration_tests/web/pubspec.yaml index eae11e0821..f7f63a5475 100644 --- a/dev/integration_tests/web/pubspec.yaml +++ b/dev/integration_tests/web/pubspec.yaml @@ -5,6 +5,11 @@ environment: # The pub client defaults to an <2.0.0 sdk constraint which we need to explicitly overwrite. sdk: ">=2.6.0 <3.0.0" +flutter: + assets: + - lib/a.dart + - lib/b.dart + dependencies: flutter: sdk: flutter diff --git a/dev/integration_tests/web/web/index.html b/dev/integration_tests/web/web/index.html index b7944c9e9e..fa7ef71069 100644 --- a/dev/integration_tests/web/web/index.html +++ b/dev/integration_tests/web/web/index.html @@ -2,12 +2,25 @@ - + - web_integration - + + + Web Test + + + + diff --git a/dev/integration_tests/web/web/manifest.json b/dev/integration_tests/web/web/manifest.json new file mode 100644 index 0000000000..79c2cea0a9 --- /dev/null +++ b/dev/integration_tests/web/web/manifest.json @@ -0,0 +1,12 @@ +{ + "name": "web_integration_test", + "short_name": "web_integration_test", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [] +} 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 e640ed2b46..be58660935 100644 --- a/packages/flutter_tools/lib/src/build_system/targets/web.dart +++ b/packages/flutter_tools/lib/src/build_system/targets/web.dart @@ -378,11 +378,12 @@ class WebServiceWorker extends Target { .childFile('flutter_service_worker.js'); final Depfile depfile = Depfile(contents, [serviceWorkerFile]); final String serviceWorker = generateServiceWorker(urlToHash, [ - 'main.dart.js', '/', + 'main.dart.js', 'index.html', 'assets/LICENSE', - 'assets/AssetManifest.json', + if (urlToHash.containsKey('assets/AssetManifest.json')) + 'assets/AssetManifest.json', if (urlToHash.containsKey('assets/FontManifest.json')) 'assets/FontManifest.json', ]); @@ -424,7 +425,8 @@ const CORE = [ self.addEventListener("install", (event) => { return event.waitUntil( caches.open(TEMP).then((cache) => { - return cache.addAll(CORE); + // Provide a no-cache param to ensure the latest version is downloaded. + return cache.addAll(CORE.map((value) => new Request(value, {'cache': 'no-cache'}))); }) ); }); @@ -443,6 +445,7 @@ self.addEventListener("activate", function(event) { // When there is no prior manifest, clear the entire cache. if (!manifest) { await caches.delete(CACHE_NAME); + contentCache = await caches.open(CACHE_NAME); for (var request of await tempCache.keys()) { var response = await tempCache.match(request); await contentCache.put(request, response); @@ -492,6 +495,10 @@ self.addEventListener("activate", function(event) { self.addEventListener("fetch", (event) => { var origin = self.location.origin; var key = event.request.url.substring(origin.length + 1); + // Redirect URLs to the index.html + if (event.request.url == origin || event.request.url.startsWith(origin + '/#')) { + key = '/'; + } // If the URL is not the the RESOURCE list, skip the cache. if (!RESOURCES[key]) { return event.respondWith(fetch(event.request)); @@ -500,8 +507,10 @@ self.addEventListener("fetch", (event) => { .then((cache) => { return cache.match(event.request).then((response) => { // Either respond with the cached resource, or perform a fetch and - // lazily populate the cache. - return response || fetch(event.request).then((response) => { + // lazily populate the cache. Ensure the resources are not cached + // by the browser for longer than the service worker expects. + var modifiedRequest = new Request(event.request, {'cache': 'no-cache'}); + return response || fetch(modifiedRequest).then((response) => { cache.put(event.request, response.clone()); return response; }); @@ -510,5 +519,37 @@ self.addEventListener("fetch", (event) => { ); }); +self.addEventListener('message', (event) => { + // SkipWaiting can be used to immediately activate a waiting service worker. + // This will also require a page refresh triggered by the main worker. + if (event.message == 'skipWaiting') { + return self.skipWaiting(); + } + + if (event.message = 'downloadOffline') { + downloadOffline(); + } +}); + +// Download offline will check the RESOURCES for all files not in the cache +// and populate them. +async function downloadOffline() { + var resources = []; + var contentCache = await caches.open(CACHE_NAME); + var currentContent = {}; + for (var request of await contentCache.keys()) { + var key = request.url.substring(origin.length + 1); + if (key == "") { + key = "/"; + } + currentContent[key] = true; + } + for (var resourceKey in Object.keys(RESOURCES)) { + if (!currentContent[resourceKey]) { + resources.add(resourceKey); + } + } + return Cache.addAll(resources); +} '''; }