// 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); Future makeEditsAndRecompile(List edits) async { await context.makeEdits(edits); await context.recompile(fullRestart: true); } group('when pause_isolates_on_start is true', () { late VmService client; late VmServiceInterface service; late Stream stream; // Fetch the log statements that are sent to console. final consoleLogs = []; StreamSubscription? 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 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, emits(_hasKind(EventKind.kBreakpointAdded)), ); final breakpoint = await client.addBreakpointWithScriptUri( isolateId, scriptRef.uri!, bpLine, ); await breakpointAdded; return breakpoint; } Future 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 resumeAndWaitForLog(String expectedString) async { final completer = Completer(); 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 hotRestartAndHandlePausePost( List<({String file, String breakpointMarker, bool exists})> breakpoints, ) async { final isolateEvents = expectLater( client.onIsolateEvent, emitsInOrder([ _hasKind(EventKind.kIsolateExit), _hasKind(EventKind.kIsolateStart), _hasKind(EventKind.kIsolateRunnable), ]), ); final breakpointEvents = expectLater( stream, emitsInOrder([ for (final (:exists, breakpointMarker: _, file: _) in breakpoints) ...[ if (exists) _hasKind(EventKind.kBreakpointRemoved), ], _hasKind(EventKind.kResume), _hasKind(EventKind.kPausePostRequest), for (final _ in breakpoints) ...[ _hasKind(EventKind.kBreakpointAdded), ], ]), ); final waitForPausePost = expectLater( stream, emitsThrough(_hasKind(EventKind.kPausePostRequest)), ); final hotRestart = context.getRegisteredServiceExtension('hotRestart'); expect( await client.callServiceExtension(hotRestart!), const TypeMatcher(), ); await isolateEvents; // 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 (exists: _, :breakpointMarker, :file) in breakpoints) { await addBreakpoint(file: file, breakpointMarker: breakpointMarker); } await resume(); await breakpointEvents; } Future 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([ (exists: true, file: mainFile, breakpointMarker: callLogMarker), ]); // Should break at `callLog`. await breakpointFuture; await resumeAndWaitForLog(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 makeEditsAndRecompile([ (file: mainFile, originalString: oldLog, newString: newLog), ]); final breakpointFuture = waitForBreakpoint(); await hotRestartAndHandlePausePost([ (exists: true, file: mainFile, breakpointMarker: callLogMarker), ]); // Should break at `callLog`. await breakpointFuture; expect(consoleLogs.contains(newLog), false); await resumeAndWaitForLog(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 makeEditsAndRecompile([ (file: mainFile, originalString: oldString, newString: newString), ]); var breakpointFuture = waitForBreakpoint(); await hotRestartAndHandlePausePost([ (exists: true, file: mainFile, breakpointMarker: callLogMarker), ]); // Should break at `callLog`. await breakpointFuture; expect(consoleLogs.contains(extraLog), true); expect(consoleLogs.contains(genLog), false); await resumeAndWaitForLog(genLog); consoleLogs.clear(); // Remove the line we just added. await makeEditsAndRecompile([ (file: mainFile, originalString: newString, newString: oldString), ]); breakpointFuture = waitForBreakpoint(); await hotRestartAndHandlePausePost([ (exists: true, file: mainFile, breakpointMarker: callLogMarker), ]); // Should break at `callLog`. await breakpointFuture; expect(consoleLogs.contains(extraLog), false); expect(consoleLogs.contains(genLog), false); await resumeAndWaitForLog(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';"; final edits = [ (file: mainFile, originalString: oldImports, newString: newImports), ]; final oldLog = "log('$genLog');"; final newLog = "log('\$libraryValue');"; edits.add((file: mainFile, originalString: oldLog, newString: newLog)); await makeEditsAndRecompile(edits); var breakpointFuture = waitForBreakpoint(); await hotRestartAndHandlePausePost([ (exists: true, file: mainFile, breakpointMarker: callLogMarker), (exists: false, 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 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'; await addBreakpoint(file: mainFile, breakpointMarker: callLogMarker); // Add library files, import them, but only refer to the last one in main. final numFiles = 50; final edits = []; 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';"; edits.add(( file: mainFile, originalString: oldImports, newString: newImports, )); } final oldLog = "log('$genLog');"; final newLog = "log('\$libraryValue$numFiles');"; edits.add((file: mainFile, originalString: oldLog, newString: newLog)); await makeEditsAndRecompile(edits); var breakpointFuture = waitForBreakpoint(); await hotRestartAndHandlePausePost([ (exists: true, file: mainFile, breakpointMarker: callLogMarker), ( exists: false, 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 resumeAndWaitForLog(newGenLog); }); }); } TypeMatcher _hasKind(String kind) => isA().having((e) => e.kind, 'kind', kind);