| // 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/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'; |
| |
| void main() { |
| // Enable verbose logging for debugging. |
| const debug = false; |
| final provider = TestSdkConfigurationProvider( |
| verbose: debug, |
| canaryFeatures: true, |
| ddcModuleFormat: ModuleFormat.ddc, |
| ); |
| final project = TestProject.testHotReloadBreakpoints; |
| final context = TestContext(project, provider); |
| final mainFile = project.dartEntryFileName; |
| final callLogMarker = 'callLog'; |
| final capturedStringMarker = 'capturedString'; |
| |
| tearDownAll(provider.dispose); |
| |
| Future<void> makeEditsAndRecompile(List<Edit> edits) async { |
| await context.makeEdits(edits); |
| await context.recompile(fullRestart: true); |
| } |
| |
| group('when pause_isolates_on_start is true', () { |
| late VmService client; |
| late Stream<Event> stream; |
| |
| setUp(() async { |
| setCurrentLogWriter(debug: debug); |
| await context.setUp( |
| testSettings: TestSettings( |
| enableExpressionEvaluation: true, |
| compilationMode: CompilationMode.frontendServer, |
| moduleFormat: ModuleFormat.ddc, |
| canaryFeatures: true, |
| ), |
| ); |
| client = await context.connectFakeClient(); |
| await client.setFlag('pause_isolates_on_start', 'true'); |
| await client.streamListen(EventStreams.kDebug); |
| stream = client.onDebugEvent; |
| }); |
| |
| tearDown(() async { |
| await context.tearDown(); |
| }); |
| |
| Future<Breakpoint> addBreakpoint({ |
| required String file, |
| required String breakpointMarker, |
| }) async { |
| final vm = await client.getVM(); |
| final isolateId = vm.isolates!.first.id!; |
| final scriptList = await client.getScripts(isolateId); |
| final scriptRef = scriptList.scripts!.firstWhere( |
| (script) => script.uri!.contains(file), |
| ); |
| final bpLine = await context.findBreakpointLine( |
| breakpointMarker, |
| isolateId, |
| scriptRef, |
| ); |
| final breakpointAdded = expectLater( |
| stream, |
| emitsThrough(_hasKind(EventKind.kBreakpointAdded)), |
| ); |
| final breakpoint = await client.addBreakpointWithScriptUri( |
| isolateId, |
| scriptRef.uri!, |
| bpLine, |
| ); |
| await breakpointAdded; |
| return breakpoint; |
| } |
| |
| Future<void> removeBreakpoint(Breakpoint bp) async { |
| final vm = await client.getVM(); |
| final isolateId = vm.isolates!.first.id!; |
| final breakpointRemoved = expectLater( |
| stream, |
| emitsThrough(_hasKind(EventKind.kBreakpointRemoved)), |
| ); |
| await client.removeBreakpoint(isolateId, bp.id!); |
| await breakpointRemoved; |
| } |
| |
| Future<void> resume() async { |
| final vm = await client.getVM(); |
| final isolate = await client.getIsolate(vm.isolates!.first.id!); |
| await client.resume(isolate.id!); |
| } |
| |
| // Resume the program, and check that at some point it will execute code |
| // that will print `expectedString` to the console. |
| Future<void> resumeAndWaitForLog(String expectedString) async { |
| final completer = Completer<void>(); |
| final subscription = context.webkitDebugger.onConsoleAPICalled.listen(( |
| e, |
| ) { |
| if (e.args.first.value == expectedString) { |
| completer.complete(); |
| } |
| }); |
| await resume(); |
| await completer.future.timeout( |
| const Duration(minutes: 1), |
| onTimeout: () { |
| throw TimeoutException( |
| "Failed to find log: '$expectedString' in console.", |
| ); |
| }, |
| ); |
| await subscription.cancel(); |
| } |
| |
| Future<List<Breakpoint>> hotReloadAndHandlePausePost( |
| List<({String file, String breakpointMarker, Breakpoint? bp})> |
| breakpoints, |
| ) async { |
| final waitForPausePost = expectLater( |
| stream, |
| emitsThrough(_hasKind(EventKind.kPausePostRequest)), |
| ); |
| |
| // Initiate the hot reload by loading the sources into the page. |
| final vm = await client.getVM(); |
| final isolate = await client.getIsolate(vm.isolates!.first.id!); |
| final report = await client.reloadSources(isolate.id!); |
| expect(report.success, true); |
| |
| // Client (e.g. DAP) should listen for this event, remove old breakpoints, |
| // reregister breakpoints, and then resume. The following lines imitate |
| // what the client should do. |
| await waitForPausePost; |
| final newBreakpoints = <Breakpoint>[]; |
| for (final (:bp, :breakpointMarker, :file) in breakpoints) { |
| // This could be a new file, so there's no existing breakpoint to |
| // remove. |
| if (bp != null) await removeBreakpoint(bp); |
| newBreakpoints.add( |
| await addBreakpoint(file: file, breakpointMarker: breakpointMarker), |
| ); |
| } |
| // The resume should complete hot reload and resume the program. |
| await resume(); |
| return newBreakpoints; |
| } |
| |
| Future<void> callEvaluate() async { |
| final vm = await client.getVM(); |
| final isolate = await client.getIsolate(vm.isolates!.first.id!); |
| final rootLib = isolate.rootLib; |
| await client.evaluate(isolate.id!, rootLib!.id!, 'evaluate()'); |
| } |
| |
| // Call the method `evaluate` in the program and wait for `expectedString` |
| // to be printed to the console. |
| Future<void> callEvaluateAndWaitForLog(String expectedString) async { |
| final completer = Completer<void>(); |
| final subscription = context.webkitDebugger.onConsoleAPICalled.listen(( |
| e, |
| ) { |
| if (e.args.first.value == expectedString) { |
| completer.complete(); |
| } |
| }); |
| final vm = await client.getVM(); |
| final isolate = await client.getIsolate(vm.isolates!.first.id!); |
| final rootLib = isolate.rootLib; |
| await client.evaluate(isolate.id!, rootLib!.id!, 'evaluate()'); |
| await completer.future.timeout( |
| const Duration(minutes: 1), |
| onTimeout: () { |
| throw TimeoutException( |
| "Failed to find log: '$expectedString' in console.", |
| ); |
| }, |
| ); |
| await subscription.cancel(); |
| } |
| |
| Future<void> waitForBreakpoint() => |
| expectLater(stream, emitsThrough(_hasKind(EventKind.kPauseBreakpoint))); |
| |
| test('empty hot reload keeps breakpoints', () async { |
| final genString = 'main gen0'; |
| |
| final bp = await addBreakpoint( |
| file: mainFile, |
| breakpointMarker: callLogMarker, |
| ); |
| |
| var breakpointFuture = waitForBreakpoint(); |
| |
| await callEvaluate(); |
| |
| // Should break at `callLog`. |
| await breakpointFuture; |
| await resumeAndWaitForLog(genString); |
| |
| await context.recompile(fullRestart: false); |
| |
| await hotReloadAndHandlePausePost([ |
| (file: mainFile, breakpointMarker: callLogMarker, bp: bp), |
| ]); |
| |
| breakpointFuture = waitForBreakpoint(); |
| |
| await callEvaluate(); |
| |
| // Should break at `callLog`. |
| await breakpointFuture; |
| await resumeAndWaitForLog(genString); |
| }); |
| |
| test('after edit and hot reload, breakpoint is in new file', () async { |
| final oldString = 'main gen0'; |
| final newString = 'main gen1'; |
| |
| final bp = await addBreakpoint( |
| file: mainFile, |
| breakpointMarker: callLogMarker, |
| ); |
| |
| var breakpointFuture = waitForBreakpoint(); |
| |
| await callEvaluate(); |
| |
| // Should break at `callLog`. |
| await breakpointFuture; |
| await resumeAndWaitForLog(oldString); |
| |
| // Modify the string that gets printed. |
| await makeEditsAndRecompile([ |
| (file: mainFile, originalString: oldString, newString: newString), |
| ]); |
| |
| await hotReloadAndHandlePausePost([ |
| (file: mainFile, breakpointMarker: callLogMarker, bp: bp), |
| ]); |
| |
| breakpointFuture = waitForBreakpoint(); |
| |
| await callEvaluate(); |
| |
| // Should break at `callLog`. |
| await breakpointFuture; |
| await resumeAndWaitForLog(newString); |
| }); |
| |
| test('after adding line, hot reload, removing line, and hot reload, ' |
| 'breakpoint is correct across both hot reloads', () async { |
| final genLog = 'main gen0'; |
| |
| var bp = await addBreakpoint( |
| file: mainFile, |
| breakpointMarker: callLogMarker, |
| ); |
| |
| var breakpointFuture = waitForBreakpoint(); |
| |
| await callEvaluate(); |
| |
| // Should break at `callLog`. |
| await breakpointFuture; |
| await resumeAndWaitForLog(genLog); |
| |
| // Add an extra log before the existing log. |
| final extraLog = 'hot reload'; |
| final oldString = "log('"; |
| final newString = "log('$extraLog');\n$oldString"; |
| await makeEditsAndRecompile([ |
| (file: mainFile, originalString: oldString, newString: newString), |
| ]); |
| |
| bp = |
| (await hotReloadAndHandlePausePost([ |
| (file: mainFile, breakpointMarker: callLogMarker, bp: bp), |
| ])).first; |
| |
| breakpointFuture = waitForBreakpoint(); |
| |
| await callEvaluateAndWaitForLog(extraLog); |
| |
| // Should break at `callLog`. |
| await breakpointFuture; |
| await resumeAndWaitForLog(genLog); |
| |
| // Remove the line we just added. |
| await makeEditsAndRecompile([ |
| (file: mainFile, originalString: newString, newString: oldString), |
| ]); |
| |
| await hotReloadAndHandlePausePost([ |
| (file: mainFile, breakpointMarker: callLogMarker, bp: bp), |
| ]); |
| |
| breakpointFuture = waitForBreakpoint(); |
| |
| final consoleLogs = <String>[]; |
| final consoleSubscription = context.webkitDebugger.onConsoleAPICalled |
| .listen((e) { |
| consoleLogs.add(e.args.first.value as String); |
| }); |
| |
| await callEvaluate(); |
| |
| // Should break at `callLog`. |
| await breakpointFuture; |
| expect(consoleLogs.contains(extraLog), false); |
| await resumeAndWaitForLog(genLog); |
| await consoleSubscription.cancel(); |
| }); |
| |
| test( |
| 'after adding file and putting breakpoint in it, breakpoint is correctly ' |
| 'registered', |
| () async { |
| final genLog = 'main gen0'; |
| |
| final bp = await addBreakpoint( |
| file: mainFile, |
| breakpointMarker: callLogMarker, |
| ); |
| |
| var breakpointFuture = waitForBreakpoint(); |
| |
| await callEvaluate(); |
| |
| // Should break at `callLog`. |
| await breakpointFuture; |
| await resumeAndWaitForLog(genLog); |
| |
| // Add a library file, import it, and then refer to it in the log. |
| final libFile = 'library.dart'; |
| final libGenLog = 'library gen0'; |
| final libValueMarker = 'libValue'; |
| context.addLibraryFile( |
| libFileName: libFile, |
| contents: '''String get libraryValue { |
| return '$libGenLog'; // Breakpoint: $libValueMarker |
| }''', |
| ); |
| final oldImports = "import 'dart:js_interop';"; |
| final newImports = |
| '$oldImports\n' |
| "import 'package:_test_hot_reload_breakpoints/library.dart';"; |
| final edits = [ |
| (file: mainFile, originalString: oldImports, newString: newImports), |
| ]; |
| final oldLog = "log('\$mainValue');"; |
| final newLog = "log('\$libraryValue');"; |
| edits.add((file: mainFile, originalString: oldLog, newString: newLog)); |
| await makeEditsAndRecompile(edits); |
| |
| await hotReloadAndHandlePausePost([ |
| (file: mainFile, breakpointMarker: callLogMarker, bp: bp), |
| (file: libFile, breakpointMarker: libValueMarker, bp: null), |
| ]); |
| |
| breakpointFuture = waitForBreakpoint(); |
| |
| await callEvaluate(); |
| |
| // Should break at `callLog`. |
| await breakpointFuture; |
| |
| breakpointFuture = waitForBreakpoint(); |
| |
| await resume(); |
| // Should break at `libValue`. |
| await breakpointFuture; |
| await resumeAndWaitForLog(libGenLog); |
| }, |
| ); |
| |
| // Test that we wait for all scripts to be parsed first before computing |
| // location metadata. |
| test('after adding many files and putting breakpoint in the last one,' |
| 'breakpoint is correctly registered', () async { |
| final genLog = 'main gen0'; |
| |
| final bp = await addBreakpoint( |
| file: mainFile, |
| breakpointMarker: callLogMarker, |
| ); |
| |
| var breakpointFuture = waitForBreakpoint(); |
| |
| await callEvaluate(); |
| |
| // Should break at `callLog`. |
| await breakpointFuture; |
| await resumeAndWaitForLog(genLog); |
| |
| // Add library files, import them, but only refer to the last one in main. |
| final numFiles = 50; |
| final edits = <Edit>[]; |
| for (var i = 1; i <= numFiles; i++) { |
| final libFile = 'library$i.dart'; |
| context.addLibraryFile( |
| libFileName: libFile, |
| contents: '''String get libraryValue$i { |
| return 'library$i gen1'; // Breakpoint: libValue$i |
| }''', |
| ); |
| final oldImports = "import 'dart:js_interop';"; |
| final newImports = |
| '$oldImports\n' |
| "import 'package:_test_hot_reload_breakpoints/$libFile';"; |
| edits.add(( |
| file: mainFile, |
| originalString: oldImports, |
| newString: newImports, |
| )); |
| } |
| final oldLog = "log('\$mainValue');"; |
| final newLog = "log('\$libraryValue$numFiles');"; |
| edits.add((file: mainFile, originalString: oldLog, newString: newLog)); |
| await makeEditsAndRecompile(edits); |
| |
| await hotReloadAndHandlePausePost([ |
| (file: mainFile, breakpointMarker: callLogMarker, bp: bp), |
| ( |
| file: 'library$numFiles.dart', |
| breakpointMarker: 'libValue$numFiles', |
| bp: null, |
| ), |
| ]); |
| |
| breakpointFuture = waitForBreakpoint(); |
| |
| await callEvaluate(); |
| |
| // Should break at `callLog`. |
| await breakpointFuture; |
| |
| breakpointFuture = waitForBreakpoint(); |
| |
| await resume(); |
| // Should break at the breakpoint in the last file. |
| await breakpointFuture; |
| await resumeAndWaitForLog('library$numFiles gen1'); |
| }); |
| |
| test('breakpoint in captured code is deleted', () async { |
| var bp = await addBreakpoint( |
| file: mainFile, |
| breakpointMarker: capturedStringMarker, |
| ); |
| |
| final oldLog = "log('\$mainValue');"; |
| final newLog = "log('\${closure()}');"; |
| await makeEditsAndRecompile([ |
| (file: mainFile, originalString: oldLog, newString: newLog), |
| ]); |
| |
| bp = |
| (await hotReloadAndHandlePausePost([ |
| (file: mainFile, breakpointMarker: capturedStringMarker, bp: bp), |
| ])).first; |
| |
| final breakpointFuture = waitForBreakpoint(); |
| |
| await callEvaluate(); |
| |
| // Should break at `capturedString`. |
| await breakpointFuture; |
| final oldCapturedString = 'captured closure gen0'; |
| // Closure gets evaluated for the first time. |
| await resumeAndWaitForLog(oldCapturedString); |
| |
| final newCapturedString = 'captured closure gen1'; |
| await makeEditsAndRecompile([ |
| ( |
| file: mainFile, |
| originalString: oldCapturedString, |
| newString: newCapturedString, |
| ), |
| ]); |
| |
| await hotReloadAndHandlePausePost([ |
| (file: mainFile, breakpointMarker: capturedStringMarker, bp: bp), |
| ]); |
| |
| // Breakpoint should not be hit as it's now deleted. We should also see |
| // the old string still as the closure has not been reevaluated. |
| await callEvaluateAndWaitForLog(oldCapturedString); |
| }); |
| }, timeout: Timeout.factor(2)); |
| |
| group('when pause_isolates_on_start is false', () { |
| late VmService client; |
| |
| setUp(() async { |
| setCurrentLogWriter(debug: debug); |
| await context.setUp( |
| testSettings: TestSettings( |
| enableExpressionEvaluation: true, |
| compilationMode: CompilationMode.frontendServer, |
| moduleFormat: ModuleFormat.ddc, |
| canaryFeatures: true, |
| ), |
| ); |
| client = await context.connectFakeClient(); |
| await client.setFlag('pause_isolates_on_start', 'false'); |
| }); |
| |
| tearDown(() async { |
| await context.tearDown(); |
| }); |
| |
| // Call the method `evaluate` in the program and wait for `expectedString` |
| // to be printed to the console. |
| Future<void> callEvaluateAndWaitForLog(String expectedString) async { |
| final completer = Completer<void>(); |
| final subscription = context.webkitDebugger.onConsoleAPICalled.listen(( |
| e, |
| ) { |
| if (e.args.first.value == expectedString) { |
| completer.complete(); |
| } |
| }); |
| final vm = await client.getVM(); |
| final isolate = await client.getIsolate(vm.isolates!.first.id!); |
| final rootLib = isolate.rootLib; |
| await client.evaluate(isolate.id!, rootLib!.id!, 'evaluate()'); |
| await completer.future.timeout( |
| const Duration(minutes: 1), |
| onTimeout: () { |
| throw TimeoutException( |
| "Failed to find log: '$expectedString' in console.", |
| ); |
| }, |
| ); |
| await subscription.cancel(); |
| } |
| |
| test('no pause when calling reloadSources', () async { |
| final oldString = 'main gen0'; |
| final newString = 'main gen1'; |
| |
| await callEvaluateAndWaitForLog(oldString); |
| |
| // Modify the string that gets printed and hot reload. |
| await makeEditsAndRecompile([ |
| (file: mainFile, originalString: oldString, newString: newString), |
| ]); |
| final vm = await client.getVM(); |
| final isolate = await client.getIsolate(vm.isolates!.first.id!); |
| final report = await client.reloadSources(isolate.id!); |
| expect(report.success, true); |
| |
| // Program should not be paused, so this should execute. |
| await callEvaluateAndWaitForLog(newString); |
| }); |
| }, timeout: Timeout.factor(2)); |
| } |
| |
| TypeMatcher<Event> _hasKind(String kind) => |
| isA<Event>().having((e) => e.kind, 'kind', kind); |