// 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: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 _testAppWebDirectory = path.join(_testAppDirectory, '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); enum ServiceWorkerTestType { withoutFlutterJs, withFlutterJs, withFlutterJsShort, } // Run a web service worker test as a standalone Dart program. Future main() async { await runWebServiceWorkerTest(headless: false, testType: ServiceWorkerTestType.withFlutterJs); await runWebServiceWorkerTest(headless: false, testType: ServiceWorkerTestType.withoutFlutterJs); await runWebServiceWorkerTest(headless: false, testType: ServiceWorkerTestType.withFlutterJsShort); } 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', ) ); } String _testTypeToIndexFile(ServiceWorkerTestType type) { late String indexFile; switch (type) { case ServiceWorkerTestType.withFlutterJs: indexFile = 'index_with_flutterjs.html'; break; case ServiceWorkerTestType.withoutFlutterJs: indexFile = 'index_without_flutterjs.html'; break; case ServiceWorkerTestType.withFlutterJsShort: indexFile = 'index_with_flutterjs_short.html'; break; } return indexFile; } Future _rebuildApp({ required int version, required ServiceWorkerTestType testType }) async { await _setAppVersion(version); await runCommand( _flutter, [ 'clean' ], workingDirectory: _testAppDirectory, ); await runCommand( 'cp', [ _testTypeToIndexFile(testType), 'index.html', ], workingDirectory: _testAppWebDirectory, ); await runCommand( _flutter, ['build', 'web', '--profile', '-t', _target], workingDirectory: _testAppDirectory, environment: { 'FLUTTER_WEB': 'true', }, ); } /// A drop-in replacement for `package:test` expect that can run outside the /// test zone. void expect(Object? actual, Object? expected) { final Matcher matcher = wrapMatcher(expected); // matchState needs to be of type , see https://github.com/flutter/flutter/issues/99522 final Map matchState = {}; if (matcher.matches(actual, matchState)) { return; } final StringDescription mismatchDescription = StringDescription(); matcher.describeMismatch(actual, mismatchDescription, matchState, true); throw TestFailure(mismatchDescription.toString()); } Future runWebServiceWorkerTest({ required bool headless, required ServiceWorkerTestType testType, }) async { 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 { final int serverPort = await findAvailablePort(); final int browserDebugPort = await findAvailablePort(); server = await AppServer.start( headless: headless, cacheControl: cacheControl, // TODO(yjbanov): use a better port disambiguation strategy than trying // to guess what ports other tests use. appUrl: 'http://localhost:$serverPort/index.html', serverPort: serverPort, browserDebugPort: browserDebugPort, appDirectory: _appBuildDirectory, additionalRequestHandlers: [ (Request request) { final String requestedPath = request.url.path; requestedPathCounts.putIfAbsent(requestedPath, () => 0); requestedPathCounts[requestedPath] = requestedPathCounts[requestedPath]! + 1; if (requestedPath == 'CLOSE') { reportedVersion = request.url.queryParameters['version']; return Response.ok('OK'); } return Response.notFound(''); }, ], ); } // Preserve old index.html as index_og.html so we can restore it later for other tests await runCommand( 'mv', [ 'index.html', 'index_og.html', ], workingDirectory: _testAppWebDirectory, ); final bool shouldExpectFlutterJs = testType != ServiceWorkerTestType.withoutFlutterJs; print('BEGIN runWebServiceWorkerTest(headless: $headless, testType: $testType)\n'); try { ///// // Attempt to load a different version of the service worker! ///// await _rebuildApp(version: 1, testType: testType); print('Call update() on the current web worker'); await startAppServer(cacheControl: 'max-age=0'); await waitForAppToLoad( { if (shouldExpectFlutterJs) 'flutter.js': 1, 'CLOSE': 1, }); expect(reportedVersion, '1'); reportedVersion = null; await server!.chrome.reloadPage(ignoreCache: true); await waitForAppToLoad( { if (shouldExpectFlutterJs) 'flutter.js': 2, 'CLOSE': 2, }); expect(reportedVersion, '1'); reportedVersion = null; await _rebuildApp(version: 2, testType: testType); await server!.chrome.reloadPage(ignoreCache: true); await waitForAppToLoad({ if (shouldExpectFlutterJs) 'flutter.js': 3, 'CLOSE': 3, }); expect(reportedVersion, '2'); reportedVersion = null; requestedPathCounts.clear(); await server!.stop(); ////////////////////////////////////////////////////// // Caching server ////////////////////////////////////////////////////// await _rebuildApp(version: 1, testType: testType); print('With cache: test first page load'); await startAppServer(cacheControl: 'max-age=3600'); await waitForAppToLoad({ 'CLOSE': 1, 'flutter_service_worker.js': 1, }); expectRequestCounts({ // 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, if (shouldExpectFlutterJs) 'flutter.js': 1, '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, testType: testType); // 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, if (shouldExpectFlutterJs) 'flutter.js': 1, 'flutter_service_worker.js': 2, '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, testType: testType); await startAppServer(cacheControl: 'max-age=0'); await waitForAppToLoad({ 'CLOSE': 1, 'flutter_service_worker.js': 1, }); expectRequestCounts({ 'index.html': 2, if (shouldExpectFlutterJs) 'flutter.js': 1, // 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, if (shouldExpectFlutterJs) 'flutter.js': 1, 'flutter_service_worker.js': 1, }); expectRequestCounts({ if (shouldExpectFlutterJs) 'flutter.js': 1, '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, testType: testType); // 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({ 'index.html': 2, if (shouldExpectFlutterJs) 'flutter.js': 1, '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 runCommand( 'mv', [ 'index_og.html', 'index.html', ], workingDirectory: _testAppWebDirectory, ); await _setAppVersion(1); await server?.stop(); } print('END runWebServiceWorkerTest(headless: $headless, testType: $testType)\n'); }