| // 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:core' hide print; |
| import 'dart:io'; |
| |
| import 'package:path/path.dart' as path; |
| import 'package:shelf/shelf.dart'; |
| |
| import 'browser.dart'; |
| import 'run_command.dart'; |
| import 'test/common.dart'; |
| import 'utils.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 Set<String> _requestedPaths = <String>{}; |
| |
| Future<void> main() async { |
| await runServiceWorkerCleanupTest(headless: false); |
| |
| if (hasError) { |
| reportErrorsAndExit('${bold}Cleanup test FAILED.$reset'); |
| } |
| reportSuccessAndExit('${bold}Cleanup test PASSED successfully.$reset'); |
| } |
| |
| Future<void> runServiceWorkerCleanupTest({required bool headless}) async { |
| print('${bold}BEGIN: Service Worker Cleanup Verification Test$reset'); |
| |
| AppServer? server; |
| |
| const String oldCachingWorkerContent = ''' |
| 'use strict'; |
| const CACHE_NAME = 'flutter-app-cache'; |
| self.addEventListener('install', (event) => { |
| self.skipWaiting(); |
| event.waitUntil( |
| caches.open(CACHE_NAME).then((cache) => { |
| return cache.addAll(['/', 'index.html', 'main.dart.js']); |
| }).then(() => self.skipWaiting()) |
| ); |
| }); |
| self.addEventListener('activate', (event) => { |
| event.waitUntil(self.clients.claim()); |
| }); |
| self.addEventListener('fetch', (event) => { |
| event.respondWith( |
| caches.open(CACHE_NAME).then((cache) => { |
| return cache.match(event.request).then((response) => { |
| return response || fetch(event.request).then((response) => { |
| if (response && Boolean(response.ok)) { |
| cache.put(event.request, response.clone()); |
| } |
| return response; |
| }); |
| }) |
| }) |
| ); |
| }); |
| '''; |
| |
| final File serviceWorkerBuildFile = File( |
| path.join(_appBuildDirectory, 'flutter_service_worker.js'), |
| ); |
| |
| try { |
| await runCommand(_flutter, <String>['clean'], workingDirectory: _testAppDirectory); |
| await runCommand( |
| _flutter, |
| <String>['build', 'web', '--no-web-resources-cdn', '--profile', '-t', _target], |
| workingDirectory: _testAppDirectory, |
| environment: <String, String>{'FLUTTER_WEB': 'true'}, |
| ); |
| print('\n${yellow}Phase 1: Installing dummy caching worker and verifying it caches...$reset'); |
| final String cleanupWorkerContent = serviceWorkerBuildFile.readAsStringSync(); |
| serviceWorkerBuildFile.writeAsStringSync(oldCachingWorkerContent); |
| |
| server = await _startServer(headless: headless); |
| await _waitForAppToRequest(server, 'flutter_service_worker.js'); |
| await _waitForAppToRequest(server, 'main.dart.js'); |
| _requestedPaths.clear(); |
| print('== RELOADING PAGE =='); |
| await server.chrome.reloadPage(); |
| await _waitForAppToRequest(server, 'CLOSE'); |
| |
| expect( |
| _requestedPaths, |
| isNot(contains('main.dart.js')), |
| reason: |
| 'On a simple reload, main.dart.js should have been served from the cache, so no network request was expected.', |
| ); |
| print('${green}Verification successful: Old caching worker is active.$reset'); |
| await server.stop(); |
| |
| print('\n${yellow}Phase 2: Deploying cleanup worker and verifying cache is removed...$reset'); |
| serviceWorkerBuildFile.writeAsStringSync(cleanupWorkerContent); |
| |
| _requestedPaths.clear(); |
| server = await _startServer(headless: headless); |
| await _waitForAppToRequest(server, 'flutter_service_worker.js'); |
| await _waitForAppToRequest(server, 'main.dart.js'); |
| _requestedPaths.clear(); |
| print('== RELOADING PAGE =='); |
| await server.chrome.reloadPage(); |
| await _waitForAppToRequest(server, 'main.dart.js'); |
| |
| expect( |
| _requestedPaths, |
| contains('main.dart.js'), |
| reason: |
| 'After cleanup, main.dart.js should be requested from the network because the caching worker is gone.', |
| ); |
| print( |
| '${green}Verification successful: Cleanup worker has removed the old caching behavior.$reset', |
| ); |
| } finally { |
| await server?.stop(); |
| print('\n${bold}END: Service Worker Cleanup Verification Test$reset'); |
| } |
| } |
| |
| Future<AppServer> _startServer({required bool headless}) async { |
| final int serverPort = await findAvailablePortAndPossiblyCauseFlakyTests(); |
| final int browserDebugPort = await findAvailablePortAndPossiblyCauseFlakyTests(); |
| return AppServer.start( |
| headless: headless, |
| appDirectory: _appBuildDirectory, |
| serverPort: serverPort, |
| browserDebugPort: browserDebugPort, |
| appUrl: 'http://localhost:$serverPort/index.html', |
| cacheControl: 'max-age=0', |
| additionalRequestHandlers: <Handler>[ |
| (Request request) { |
| final String path = request.url.path.split('/').last; |
| print('(requested path: $path)'); |
| _requestedPaths.add(path); |
| return Response.notFound(''); |
| }, |
| ], |
| ); |
| } |
| |
| Future<void> _waitForAppToRequest(AppServer server, String file) async { |
| print('Waiting for app to request "$file"'); |
| await Future.any(<Future<Object?>>[ |
| () async { |
| int tries = 1; |
| while (!_requestedPaths.contains(file)) { |
| if (tries++ % 40 == 0) { |
| print('-- Still waiting for app to request "$file". Requested so far: $_requestedPaths'); |
| } |
| await Future<void>.delayed(const Duration(milliseconds: 100)); |
| } |
| print('++ App has requested "$file"'); |
| }(), |
| server.onChromeError.then((String error) { |
| throw Exception('Chrome error: $error'); |
| }), |
| ]); |
| } |
| |
| /// A drop-in replacement for `package:test`'s `expect` that can run |
| /// outside the standard test runner environment. |
| void expect(Object? actual, Object? expected, {String? reason}) { |
| final Matcher matcher = wrapMatcher(expected); |
| final Map<Object?, Object?> matchState = <Object?, Object?>{}; |
| if (matcher.matches(actual, matchState)) { |
| return; |
| } |
| final StringDescription mismatchDescription = StringDescription(); |
| matcher.describeMismatch(actual, mismatchDescription, matchState, true); |
| |
| final String which = mismatchDescription.toString(); |
| final StringBuffer buffer = StringBuffer(); |
| buffer.writeln(_indent(_prettyPrint(expected), first: 'Expected: ')); |
| buffer.writeln(_indent(_prettyPrint(actual), first: ' Actual: ')); |
| if (which.isNotEmpty) { |
| buffer.writeln(_indent(which, first: ' Which: ')); |
| } |
| if (reason != null) { |
| buffer.writeln(_indent(reason, first: ' Reason: ')); |
| } |
| foundError(<String>[buffer.toString(), StackTrace.current.toString()]); |
| } |
| |
| /// Returns a pretty-printed representation of [value]. |
| String _prettyPrint(Object? value) => StringDescription().addDescriptionOf(value).toString(); |
| |
| /// Indents each line of a [text] string. |
| String _indent(String text, {required String first}) { |
| final String prefix = ' ' * first.length; |
| final List<String> lines = text.split('\n'); |
| if (lines.length == 1) { |
| return '$first$text'; |
| } |
| final StringBuffer buffer = StringBuffer('$first${lines.first}\n'); |
| for (final String line in lines.skip(1).take(lines.length - 2)) { |
| buffer.writeln('$prefix$line'); |
| } |
| buffer.write('$prefix${lines.last}'); |
| return buffer.toString(); |
| } |