| // Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file |
| // for details. All rights reserved. Use of this source code is governed by a |
| // BSD-style license that can be found in the LICENSE file. |
| |
| @Tags(['daily']) |
| @TestOn('vm') |
| @Timeout(Duration(minutes: 5)) |
| library; |
| |
| import 'dart:async'; |
| |
| import 'package:dwds/dwds.dart'; |
| import 'package:dwds/expression_compiler.dart'; |
| import 'package:test/test.dart'; |
| import 'package:test_common/logging.dart'; |
| import 'package:test_common/test_sdk_configuration.dart'; |
| import 'package:vm_service/vm_service.dart'; |
| |
| import '../fixtures/context.dart'; |
| import '../fixtures/project.dart'; |
| import '../fixtures/utilities.dart'; |
| |
| const originalString = 'Hello World!'; |
| const newString = 'Bonjour le monde!'; |
| |
| void runTests({ |
| required TestSdkConfigurationProvider provider, |
| required ModuleFormat moduleFormat, |
| required CompilationMode compilationMode, |
| required bool canaryFeatures, |
| required bool debug, |
| }) { |
| final context = TestContext(TestProject.testAppendBody, provider); |
| |
| tearDownAll(provider.dispose); |
| |
| Future<void> recompile({bool hasEdits = false}) async { |
| if (compilationMode == CompilationMode.frontendServer) { |
| await context.recompile(fullRestart: true); |
| } else { |
| assert(compilationMode == CompilationMode.buildDaemon); |
| if (hasEdits) { |
| // Only gets a new build if there were edits. |
| await context.waitForSuccessfulBuild(propagateToBrowser: true); |
| } |
| } |
| } |
| |
| Future<void> makeEditAndRecompile() async { |
| await context.makeEdits([ |
| ( |
| file: context.project.dartEntryFileName, |
| originalString: originalString, |
| newString: newString, |
| ), |
| ]); |
| await recompile(hasEdits: true); |
| } |
| |
| // Wait for `expectedStrings` to be printed to the console. |
| Future<void> waitForLogs(List<String> expectedStrings) async { |
| final expectations = List<String>.from(expectedStrings); |
| final completer = Completer<void>(); |
| final subscription = context.webkitDebugger.onConsoleAPICalled.listen((e) { |
| final value = e.args.first.value; |
| if (expectations.contains(value)) { |
| expectations.remove(value); |
| if (expectations.isEmpty) { |
| completer.complete(); |
| } |
| } |
| }); |
| await completer.future.timeout( |
| const Duration(minutes: 1), |
| onTimeout: () { |
| throw TimeoutException( |
| 'Failed to find logs: $expectedStrings in console.', |
| ); |
| }, |
| ); |
| await subscription.cancel(); |
| } |
| |
| group( |
| 'Injected client with live reload', |
| () { |
| group('and with debugging', () { |
| setUp(() async { |
| setCurrentLogWriter(debug: debug); |
| await context.setUp( |
| testSettings: TestSettings( |
| reloadConfiguration: ReloadConfiguration.liveReload, |
| compilationMode: compilationMode, |
| moduleFormat: provider.ddcModuleFormat, |
| canaryFeatures: provider.canaryFeatures, |
| ), |
| ); |
| }); |
| |
| tearDown(() async { |
| await context.tearDown(); |
| }); |
| |
| test('can live reload changes ', () async { |
| // A full reload should clear the state. |
| final logFuture = waitForLogs([newString]); |
| await makeEditAndRecompile(); |
| await logFuture; |
| }); |
| }); |
| |
| group('and without debugging', () { |
| setUp(() async { |
| setCurrentLogWriter(debug: debug); |
| await context.setUp( |
| testSettings: TestSettings( |
| reloadConfiguration: ReloadConfiguration.liveReload, |
| compilationMode: compilationMode, |
| moduleFormat: provider.ddcModuleFormat, |
| canaryFeatures: provider.canaryFeatures, |
| ), |
| debugSettings: TestDebugSettings.noDevTools().copyWith( |
| enableDebugging: false, |
| ), |
| ); |
| }); |
| |
| tearDown(() async { |
| await context.tearDown(); |
| }); |
| |
| test('can live reload changes ', () async { |
| // A full reload should clear the state. |
| final logFuture = waitForLogs([newString]); |
| await makeEditAndRecompile(); |
| await logFuture; |
| }); |
| }); |
| |
| group('and without debugging using WebSockets', () { |
| setUp(() async { |
| setCurrentLogWriter(debug: debug); |
| await context.setUp( |
| testSettings: TestSettings( |
| reloadConfiguration: ReloadConfiguration.liveReload, |
| compilationMode: compilationMode, |
| moduleFormat: provider.ddcModuleFormat, |
| canaryFeatures: provider.canaryFeatures, |
| ), |
| debugSettings: TestDebugSettings.noDevTools().copyWith( |
| enableDebugging: false, |
| useSse: false, |
| ), |
| ); |
| }); |
| |
| tearDown(() async { |
| await context.tearDown(); |
| }); |
| |
| test('can live reload changes ', () async { |
| // A full reload should clear the state. |
| final logFuture = waitForLogs([newString]); |
| await makeEditAndRecompile(); |
| await logFuture; |
| }); |
| }); |
| }, |
| // `BuildResult`s are only ever emitted when using the build daemon. |
| skip: compilationMode != CompilationMode.buildDaemon, |
| timeout: Timeout.factor(2), |
| ); |
| |
| group('Injected client', () { |
| late VmService fakeClient; |
| |
| setUp(() async { |
| setCurrentLogWriter(debug: debug); |
| await context.setUp( |
| testSettings: TestSettings( |
| enableExpressionEvaluation: true, |
| compilationMode: compilationMode, |
| moduleFormat: provider.ddcModuleFormat, |
| canaryFeatures: provider.canaryFeatures, |
| ), |
| ); |
| fakeClient = await context.connectFakeClient(); |
| }); |
| |
| tearDown(() async { |
| await context.tearDown(); |
| }); |
| |
| test('destroys and recreates the isolate during a hot restart', () async { |
| final client = context.debugConnection.vmService; |
| await client.streamListen('Isolate'); |
| await makeEditAndRecompile(); |
| |
| final eventsDone = expectLater( |
| client.onIsolateEvent, |
| emitsThrough( |
| emitsInOrder([ |
| _hasKind(EventKind.kIsolateExit), |
| _hasKind(EventKind.kIsolateStart), |
| _hasKind(EventKind.kIsolateRunnable), |
| ]), |
| ), |
| ); |
| |
| final hotRestart = context.getRegisteredServiceExtension('hotRestart'); |
| expect( |
| await fakeClient.callServiceExtension(hotRestart!), |
| const TypeMatcher<Success>(), |
| ); |
| |
| await eventsDone; |
| }); |
| |
| test('can execute simultaneous hot restarts', () async { |
| final client = context.debugConnection.vmService; |
| await client.streamListen('Isolate'); |
| await makeEditAndRecompile(); |
| |
| final eventsDone = expectLater( |
| client.onIsolateEvent, |
| emitsThrough( |
| emitsInOrder([ |
| _hasKind(EventKind.kIsolateExit), |
| _hasKind(EventKind.kIsolateStart), |
| _hasKind(EventKind.kIsolateRunnable), |
| ]), |
| ), |
| ); |
| |
| // Execute two hot restart calls in parallel. |
| final hotRestart = context.getRegisteredServiceExtension('hotRestart'); |
| final done = Future.wait([ |
| fakeClient.callServiceExtension(hotRestart!), |
| fakeClient.callServiceExtension(hotRestart), |
| ]); |
| expect(await done, [ |
| const TypeMatcher<Success>(), |
| const TypeMatcher<Success>(), |
| ]); |
| |
| // The debugger is still working. |
| final vm = await client.getVM(); |
| final isolateId = vm.isolates!.first.id!; |
| final isolate = await client.getIsolate(isolateId); |
| final library = isolate.rootLib!.uri!; |
| |
| final result = await client.evaluate(isolateId, library, 'true'); |
| expect( |
| result, |
| isA<InstanceRef>().having( |
| (instance) => instance.valueAsString, |
| 'valueAsString', |
| 'true', |
| ), |
| ); |
| |
| await eventsDone; |
| }); |
| |
| test('destroys and recreates the isolate during a page refresh', () async { |
| final client = context.debugConnection.vmService; |
| await client.streamListen('Isolate'); |
| await makeEditAndRecompile(); |
| |
| final eventsDone = expectLater( |
| client.onIsolateEvent, |
| emitsThrough( |
| emitsInOrder([ |
| _hasKind(EventKind.kIsolateExit), |
| _hasKind(EventKind.kIsolateStart), |
| _hasKind(EventKind.kIsolateRunnable), |
| ]), |
| ), |
| ); |
| |
| await context.webDriver.driver.refresh(); |
| |
| await eventsDone; |
| }); |
| |
| test('can hot restart via the service extension', () async { |
| final client = context.debugConnection.vmService; |
| await client.streamListen('Isolate'); |
| await makeEditAndRecompile(); |
| |
| final eventsDone = expectLater( |
| client.onIsolateEvent, |
| emitsThrough( |
| emitsInOrder([ |
| _hasKind(EventKind.kIsolateExit), |
| _hasKind(EventKind.kIsolateStart), |
| _hasKind(EventKind.kIsolateRunnable), |
| ]), |
| ), |
| ); |
| // Main is re-invoked which shouldn't clear the state. |
| final logFuture = waitForLogs(['$originalString $newString']); |
| final hotRestart = context.getRegisteredServiceExtension('hotRestart'); |
| expect( |
| await fakeClient.callServiceExtension(hotRestart!), |
| const TypeMatcher<Success>(), |
| ); |
| |
| await eventsDone; |
| await logFuture; |
| }); |
| |
| test('can send events before and after hot restart', () async { |
| final client = context.debugConnection.vmService; |
| await client.streamListen('Isolate'); |
| |
| // The event just before hot restart might never be received, |
| // but the injected client continues to work and send events |
| // after hot restart. |
| final eventsDone = expectLater( |
| client.onIsolateEvent, |
| emitsThrough( |
| _hasKind( |
| EventKind.kServiceExtensionAdded, |
| ).having((e) => e.extensionRPC, 'service', 'ext.bar'), |
| ), |
| ); |
| |
| var vm = await client.getVM(); |
| var isolateId = vm.isolates!.first.id!; |
| var isolate = await client.getIsolate(isolateId); |
| var library = isolate.rootLib!.uri!; |
| |
| final callback = '(_, __) async => ServiceExtensionResponse.result("")'; |
| |
| await client.evaluate( |
| isolateId, |
| library, |
| "registerExtension('ext.foo', $callback)", |
| ); |
| |
| await recompile(); |
| // Main is re-invoked which shouldn't clear the state. |
| final logFuture = waitForLogs(['$originalString $originalString']); |
| final hotRestart = context.getRegisteredServiceExtension('hotRestart'); |
| expect( |
| await fakeClient.callServiceExtension(hotRestart!), |
| const TypeMatcher<Success>(), |
| ); |
| |
| vm = await client.getVM(); |
| isolateId = vm.isolates!.first.id!; |
| isolate = await client.getIsolate(isolateId); |
| library = isolate.rootLib!.uri!; |
| |
| await client.evaluate( |
| isolateId, |
| library, |
| "registerExtension('ext.bar', $callback)", |
| ); |
| |
| await eventsDone; |
| await logFuture; |
| }); |
| |
| test('can refresh the page via the fullReload service extension', () async { |
| final client = context.debugConnection.vmService; |
| await client.streamListen('Isolate'); |
| await makeEditAndRecompile(); |
| |
| final eventsDone = expectLater( |
| client.onIsolateEvent, |
| emitsThrough( |
| emitsInOrder([ |
| _hasKind(EventKind.kIsolateExit), |
| _hasKind(EventKind.kIsolateStart), |
| _hasKind(EventKind.kIsolateRunnable), |
| ]), |
| ), |
| ); |
| // Should see only the new text. |
| final logFuture = waitForLogs([newString]); |
| final fullReload = context.getRegisteredServiceExtension('fullReload'); |
| expect( |
| await fakeClient.callServiceExtension(fullReload!), |
| isA<Success>(), |
| ); |
| |
| await eventsDone; |
| await logFuture; |
| }); |
| |
| test('can hot restart while paused', () async { |
| final client = context.debugConnection.vmService; |
| var vm = await client.getVM(); |
| var isolateId = vm.isolates!.first.id!; |
| await client.streamListen('Debug'); |
| final stream = client.onDebugEvent; |
| final scriptList = await client.getScripts(isolateId); |
| final main = scriptList.scripts!.firstWhere( |
| (script) => script.uri!.contains('main.dart'), |
| ); |
| final bpLine = await context.findBreakpointLine( |
| 'printCount', |
| isolateId, |
| main, |
| ); |
| await client.addBreakpoint(isolateId, main.id!, bpLine); |
| await stream.firstWhere( |
| (event) => event.kind == EventKind.kPauseBreakpoint, |
| ); |
| |
| await makeEditAndRecompile(); |
| // Main is re-invoked which shouldn't clear the state. |
| final logFuture = waitForLogs(['$originalString $newString']); |
| final hotRestart = context.getRegisteredServiceExtension('hotRestart'); |
| await fakeClient.callServiceExtension(hotRestart!); |
| await logFuture; |
| |
| vm = await client.getVM(); |
| isolateId = vm.isolates!.first.id!; |
| final isolate = await client.getIsolate(isolateId); |
| |
| // Previous breakpoint should be cleared. |
| expect(isolate.breakpoints!.isEmpty, isTrue); |
| }); |
| |
| test('can evaluate expressions after hot restart', () async { |
| final client = context.debugConnection.vmService; |
| |
| await recompile(); |
| final hotRestart = context.getRegisteredServiceExtension('hotRestart'); |
| await fakeClient.callServiceExtension(hotRestart!); |
| |
| final vm = await client.getVM(); |
| final isolateId = vm.isolates!.first.id!; |
| final isolate = await client.getIsolate(isolateId); |
| final library = isolate.rootLib!.uri!; |
| |
| // Expression evaluation while running should work. |
| final result = await client.evaluate(isolateId, library, 'true'); |
| expect( |
| result, |
| isA<InstanceRef>().having( |
| (instance) => instance.valueAsString, |
| 'valueAsString', |
| 'true', |
| ), |
| ); |
| }); |
| |
| test('can hot restart with no changes, hot restart with changes, and ' |
| 'hot restart again with no changes', () async { |
| // Empty hot restart. |
| var logFuture = waitForLogs(['$originalString $originalString']); |
| await recompile(); |
| final hotRestart = context.getRegisteredServiceExtension('hotRestart'); |
| await fakeClient.callServiceExtension(hotRestart!); |
| await logFuture; |
| |
| // Hot restart. |
| logFuture = waitForLogs(['$originalString $originalString $newString']); |
| await makeEditAndRecompile(); |
| await fakeClient.callServiceExtension(hotRestart); |
| await logFuture; |
| |
| // Empty hot restart. |
| logFuture = waitForLogs([ |
| '$originalString $originalString $newString $newString', |
| ]); |
| await recompile(); |
| await fakeClient.callServiceExtension(hotRestart); |
| await logFuture; |
| }); |
| }, timeout: Timeout.factor(2)); |
| |
| group( |
| 'Injected client with hot restart', |
| () { |
| group('and with debugging', () { |
| setUp(() async { |
| setCurrentLogWriter(debug: debug); |
| await context.setUp( |
| testSettings: TestSettings( |
| reloadConfiguration: ReloadConfiguration.hotRestart, |
| compilationMode: compilationMode, |
| moduleFormat: provider.ddcModuleFormat, |
| canaryFeatures: provider.canaryFeatures, |
| ), |
| ); |
| }); |
| |
| tearDown(() async { |
| await context.tearDown(); |
| }); |
| |
| test('can hot restart changes ', () async { |
| // Main is re-invoked which shouldn't clear the state. |
| final logFutures = waitForLogs([ |
| '$originalString $newString', |
| // The ext.flutter.disassemble callback is invoked and waited for. |
| 'start disassemble', |
| 'end disassemble', |
| ]); |
| await makeEditAndRecompile(); |
| await logFutures; |
| }); |
| |
| test( |
| 'fires isolate create/destroy events during hot restart', |
| () async { |
| final client = context.debugConnection.vmService; |
| await client.streamListen('Isolate'); |
| |
| final eventsDone = expectLater( |
| client.onIsolateEvent, |
| emitsThrough( |
| emitsInOrder([ |
| _hasKind(EventKind.kIsolateExit), |
| _hasKind(EventKind.kIsolateStart), |
| _hasKind(EventKind.kIsolateRunnable), |
| ]), |
| ), |
| ); |
| |
| await makeEditAndRecompile(); |
| |
| await eventsDone; |
| }, |
| ); |
| }); |
| |
| group('and without debugging', () { |
| setUp(() async { |
| setCurrentLogWriter(debug: debug); |
| await context.setUp( |
| testSettings: TestSettings( |
| reloadConfiguration: ReloadConfiguration.hotRestart, |
| compilationMode: compilationMode, |
| moduleFormat: provider.ddcModuleFormat, |
| canaryFeatures: provider.canaryFeatures, |
| ), |
| debugSettings: TestDebugSettings.noDevTools().copyWith( |
| enableDebugging: false, |
| ), |
| ); |
| }); |
| |
| tearDown(() async { |
| await context.tearDown(); |
| }); |
| |
| test('can hot restart changes ', () async { |
| // Main is re-invoked which shouldn't clear the state. |
| final logFutures = waitForLogs([ |
| '$originalString $newString', |
| // The ext.flutter.disassemble callback is invoked and waited for. |
| 'start disassemble', |
| 'end disassemble', |
| ]); |
| await makeEditAndRecompile(); |
| await logFutures; |
| }); |
| }); |
| }, |
| // `BuildResult`s are only ever emitted when using the build daemon. |
| skip: compilationMode != CompilationMode.buildDaemon, |
| timeout: Timeout.factor(2), |
| ); |
| |
| group('when isolates_paused_on_start is true', () { |
| late VmService client; |
| late VmService fakeClient; |
| |
| setUp(() async { |
| setCurrentLogWriter(debug: debug); |
| await context.setUp( |
| testSettings: TestSettings( |
| enableExpressionEvaluation: true, |
| compilationMode: compilationMode, |
| moduleFormat: provider.ddcModuleFormat, |
| canaryFeatures: provider.canaryFeatures, |
| ), |
| ); |
| client = context.debugConnection.vmService; |
| fakeClient = await context.connectFakeClient(); |
| await client.setFlag('pause_isolates_on_start', 'true'); |
| await client.streamListen('Isolate'); |
| }); |
| |
| tearDown(() async { |
| await context.tearDown(); |
| }); |
| |
| test( |
| 'after hot-restart, does not run app until there is a resume event', |
| () async { |
| await makeEditAndRecompile(); |
| |
| final eventsDone = expectLater( |
| client.onIsolateEvent, |
| emitsThrough( |
| emitsInOrder([ |
| _hasKind(EventKind.kIsolateExit), |
| _hasKind(EventKind.kIsolateStart), |
| _hasKind(EventKind.kIsolateRunnable), |
| ]), |
| ), |
| ); |
| |
| // Main is re-invoked which shouldn't clear the state. |
| final logFuture = waitForLogs(['$originalString $newString']); |
| final hotRestart = context.getRegisteredServiceExtension('hotRestart'); |
| expect( |
| await fakeClient.callServiceExtension(hotRestart!), |
| const TypeMatcher<Success>(), |
| ); |
| |
| await eventsDone; |
| |
| final vm = await client.getVM(); |
| final isolateId = vm.isolates!.first.id!; |
| await client.resume(isolateId); |
| |
| await logFuture; |
| }, |
| ); |
| |
| test( |
| 'after page refresh, does not run app until there is a resume event', |
| () async { |
| final logFuture = waitForLogs([newString]); |
| await makeEditAndRecompile(); |
| await context.webDriver.driver.refresh(); |
| |
| final eventsDone = expectLater( |
| client.onIsolateEvent, |
| emitsThrough( |
| emitsInOrder([ |
| _hasKind(EventKind.kIsolateExit), |
| _hasKind(EventKind.kIsolateStart), |
| _hasKind(EventKind.kIsolateRunnable), |
| ]), |
| ), |
| ); |
| |
| await eventsDone; |
| |
| final vm = await client.getVM(); |
| final isolateId = vm.isolates!.first.id!; |
| await client.resume(isolateId); |
| |
| await logFuture; |
| }, |
| ); |
| }); |
| } |
| |
| TypeMatcher<Event> _hasKind(String kind) => |
| isA<Event>().having((e) => e.kind, 'kind', kind); |