| // 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 'package:vm_service_interface/vm_service_interface.dart'; |
| import 'package:webkit_inspection_protocol/webkit_inspection_protocol.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.testHotRestartBreakpoints; |
| final context = TestContext(project, provider); |
| final mainFile = project.dartEntryFileName; |
| final callLogMarker = 'callLog'; |
| |
| tearDownAll(provider.dispose); |
| |
| void makeEdit(String file, String originalString, String newString) { |
| if (file == project.dartEntryFileName) { |
| context.makeEditToDartEntryFile( |
| toReplace: originalString, |
| replaceWith: newString, |
| ); |
| } else { |
| context.makeEditToDartLibFile( |
| libFileName: file, |
| toReplace: originalString, |
| replaceWith: newString, |
| ); |
| } |
| } |
| |
| Future<void> makeEditAndRecompile( |
| String file, |
| String originalString, |
| String newString, |
| ) async { |
| makeEdit(file, originalString, newString); |
| await context.recompile(fullRestart: true); |
| } |
| |
| group('when pause_isolates_on_start is true', () { |
| late VmService client; |
| late VmServiceInterface service; |
| late Stream<Event> stream; |
| // Fetch the log statements that are sent to console. |
| final consoleLogs = <String>[]; |
| late StreamSubscription<ConsoleAPIEvent> consoleSubscription; |
| |
| setUp(() async { |
| setCurrentLogWriter(debug: debug); |
| await context.setUp( |
| testSettings: TestSettings( |
| enableExpressionEvaluation: true, |
| compilationMode: CompilationMode.frontendServer, |
| moduleFormat: provider.ddcModuleFormat, |
| canaryFeatures: provider.canaryFeatures, |
| ), |
| ); |
| client = await context.connectFakeClient(); |
| service = context.service; |
| await client.setFlag('pause_isolates_on_start', 'true'); |
| await client.streamListen(EventStreams.kIsolate); |
| await client.streamListen(EventStreams.kDebug); |
| stream = client.onDebugEvent; |
| consoleSubscription = context.webkitDebugger.onConsoleAPICalled.listen( |
| (e) => consoleLogs.add(e.args.first.value as String), |
| ); |
| }); |
| |
| tearDown(() async { |
| await consoleSubscription.cancel(); |
| consoleLogs.clear(); |
| 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> resume() async { |
| final vm = await client.getVM(); |
| final isolate = await client.getIsolate(vm.isolates!.first.id!); |
| await client.resume(isolate.id!); |
| } |
| |
| // When the program is executing, we want to check that at some point it |
| // will execute code that will emit [expectedString]. |
| Future<void> resumeAndExpectLog(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; |
| await subscription.cancel(); |
| } |
| |
| Future<void> hotRestartAndHandlePausePost( |
| List<({String file, String breakpointMarker})> breakpoints, |
| ) async { |
| final eventsDone = expectLater( |
| client.onIsolateEvent, |
| emitsThrough( |
| emitsInOrder([ |
| _hasKind(EventKind.kIsolateExit), |
| _hasKind(EventKind.kIsolateStart), |
| _hasKind(EventKind.kIsolateRunnable), |
| ]), |
| ), |
| ); |
| |
| final waitForPausePost = expectLater( |
| stream, |
| emitsThrough(_hasKind(EventKind.kPausePostRequest)), |
| ); |
| |
| final hotRestart = context.getRegisteredServiceExtension('hotRestart'); |
| expect( |
| await client.callServiceExtension(hotRestart!), |
| const TypeMatcher<Success>(), |
| ); |
| |
| await eventsDone; |
| |
| // DWDS defers running main after a hot restart until the client (e.g. |
| // DAP) resumes. Client should listen for this event, remove breakpoints |
| // (we don't remove them here as DWDS already removes them), and |
| // reregister breakpoints (which will be registered in the new files), and |
| // resume. |
| await waitForPausePost; |
| // Verify DWDS has already removed the breakpoints at this point. |
| final vm = await client.getVM(); |
| final isolate = await service.getIsolate(vm.isolates!.first.id!); |
| expect(isolate.breakpoints, isEmpty); |
| for (final (:breakpointMarker, :file) in breakpoints) { |
| await addBreakpoint(file: file, breakpointMarker: breakpointMarker); |
| } |
| await resume(); |
| } |
| |
| Future<Event> waitForBreakpoint() => |
| stream.firstWhere((event) => event.kind == EventKind.kPauseBreakpoint); |
| |
| test('empty hot restart keeps breakpoints', () async { |
| final genString = 'main gen0'; |
| |
| await addBreakpoint(file: mainFile, breakpointMarker: callLogMarker); |
| |
| final breakpointFuture = waitForBreakpoint(); |
| |
| await context.recompile(fullRestart: false); |
| |
| await hotRestartAndHandlePausePost([ |
| (file: mainFile, breakpointMarker: callLogMarker), |
| ]); |
| |
| // Should break at `callLog`. |
| await breakpointFuture; |
| await resumeAndExpectLog(genString); |
| }); |
| |
| test('after edit and hot restart, breakpoint is in new file', () async { |
| final oldLog = 'main gen0'; |
| final newLog = 'main gen1'; |
| |
| await addBreakpoint(file: mainFile, breakpointMarker: callLogMarker); |
| |
| await makeEditAndRecompile(mainFile, oldLog, newLog); |
| |
| final breakpointFuture = waitForBreakpoint(); |
| |
| await hotRestartAndHandlePausePost([ |
| (file: mainFile, breakpointMarker: callLogMarker), |
| ]); |
| |
| // Should break at `callLog`. |
| await breakpointFuture; |
| expect(consoleLogs.contains(newLog), false); |
| await resumeAndExpectLog(newLog); |
| }); |
| |
| test('after adding line, hot restart, removing line, and hot restart, ' |
| 'breakpoint is correct across both hot restarts', () async { |
| final genLog = 'main gen0'; |
| |
| await addBreakpoint(file: mainFile, breakpointMarker: callLogMarker); |
| |
| // Add an extra log before the existing log. |
| final extraLog = 'hot reload'; |
| final oldString = "log('"; |
| final newString = "log('$extraLog');\n$oldString"; |
| await makeEditAndRecompile(mainFile, oldString, newString); |
| |
| var breakpointFuture = waitForBreakpoint(); |
| |
| await hotRestartAndHandlePausePost([ |
| (file: mainFile, breakpointMarker: callLogMarker), |
| ]); |
| |
| // Should break at `callLog`. |
| await breakpointFuture; |
| expect(consoleLogs.contains(extraLog), true); |
| expect(consoleLogs.contains(genLog), false); |
| await resumeAndExpectLog(genLog); |
| |
| consoleLogs.clear(); |
| |
| // Remove the line we just added. |
| await makeEditAndRecompile(mainFile, newString, oldString); |
| |
| breakpointFuture = waitForBreakpoint(); |
| |
| await hotRestartAndHandlePausePost([ |
| (file: mainFile, breakpointMarker: callLogMarker), |
| ]); |
| |
| // Should break at `callLog`. |
| await breakpointFuture; |
| expect(consoleLogs.contains(extraLog), false); |
| expect(consoleLogs.contains(genLog), false); |
| await resumeAndExpectLog(genLog); |
| }); |
| |
| test( |
| 'after adding file and putting breakpoint in it, breakpoint is correctly ' |
| 'registered', |
| () async { |
| final genLog = 'main gen0'; |
| |
| await addBreakpoint(file: mainFile, breakpointMarker: callLogMarker); |
| |
| // 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_restart_breakpoints/library.dart';"; |
| makeEdit(mainFile, oldImports, newImports); |
| final oldLog = "log('$genLog');"; |
| final newLog = "log('\$libraryValue');"; |
| await makeEditAndRecompile(mainFile, oldLog, newLog); |
| |
| var breakpointFuture = waitForBreakpoint(); |
| |
| await hotRestartAndHandlePausePost([ |
| (file: mainFile, breakpointMarker: callLogMarker), |
| (file: libFile, breakpointMarker: libValueMarker), |
| ]); |
| |
| // Should break at `callLog`. |
| await breakpointFuture; |
| expect(consoleLogs.contains(libGenLog), false); |
| |
| breakpointFuture = waitForBreakpoint(); |
| |
| await resume(); |
| // Should break at `libValue`. |
| await breakpointFuture; |
| expect(consoleLogs.contains(libGenLog), false); |
| await resumeAndExpectLog(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'; |
| |
| await addBreakpoint(file: mainFile, breakpointMarker: callLogMarker); |
| |
| // Add library files, import them, but only refer to the last one in main. |
| final numFiles = 50; |
| 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_restart_breakpoints/$libFile';"; |
| makeEdit(mainFile, oldImports, newImports); |
| } |
| final oldLog = "log('$genLog');"; |
| final newLog = "log('\$libraryValue$numFiles');"; |
| await makeEditAndRecompile(mainFile, oldLog, newLog); |
| |
| var breakpointFuture = waitForBreakpoint(); |
| |
| await hotRestartAndHandlePausePost([ |
| (file: mainFile, breakpointMarker: callLogMarker), |
| (file: 'library$numFiles.dart', breakpointMarker: 'libValue$numFiles'), |
| ]); |
| |
| final newGenLog = 'library$numFiles gen1'; |
| |
| // Should break at `callLog`. |
| await breakpointFuture; |
| expect(consoleLogs.contains(newGenLog), false); |
| |
| breakpointFuture = waitForBreakpoint(); |
| |
| await resume(); |
| // Should break at the breakpoint in the last file. |
| await breakpointFuture; |
| expect(consoleLogs.contains(newGenLog), false); |
| await resumeAndExpectLog(newGenLog); |
| }); |
| }); |
| } |
| |
| TypeMatcher<Event> _hasKind(String kind) => |
| isA<Event>().having((e) => e.kind, 'kind', kind); |