blob: 289af8eb70a270cb8188d370dfba7b129fd8927f [file]
// 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.
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,
);
tearDownAll(provider.dispose);
group('Frontend Server', () {
runTests(
provider: provider,
compilationMode: CompilationMode.frontendServer,
);
});
group('Build Daemon', () {
runTests(provider: provider, compilationMode: CompilationMode.buildDaemon);
});
}
void runTests({
required TestSdkConfigurationProvider provider,
required CompilationMode compilationMode,
}) {
final project = TestProject.testHotRestartBreakpoints;
final context = TestContext(project, provider);
final mainFile = project.dartEntryFileName;
final callLogMarker = 'callLog';
Future<void> makeEditsAndRecompile(List<Edit> edits) async {
await context.makeEdits(edits);
if (compilationMode == CompilationMode.frontendServer) {
await context.recompile(fullRestart: true);
} else {
await context.waitForSuccessfulBuild();
}
}
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>[];
StreamSubscription<ConsoleAPIEvent>? consoleSubscription;
setUp(() async {
setCurrentLogWriter(debug: provider.verbose);
await context.setUp(
testSettings: TestSettings(
enableExpressionEvaluation: true,
compilationMode: compilationMode,
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,
emits(_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!);
}
// 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<void> 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<Success>(),
);
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<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();
if (compilationMode == CompilationMode.frontendServer) {
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));
// Include library file in edits to ensure it's added to reloaded_sources.json
edits.add((
file: libFile,
originalString: 'String get libraryValue',
newString: 'String get libraryValue',
));
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 = <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_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));
// Include library files in edits to ensure they are added to reloaded_sources.json
for (var i = 1; i <= numFiles; i++) {
edits.add((
file: 'library$i.dart',
originalString: 'String get libraryValue$i',
newString: 'String get libraryValue$i',
));
}
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<Event> _hasKind(String kind) =>
isA<Event>().having((e) => e.kind, 'kind', kind);