[ddc] Initialize and link the necessary library in debugger APIs

Closes https://github.com/dart-lang/sdk/issues/60109

Both getClassMetadata and getClassesInLibrary can be called before
main is called. In order to support this, libraries should be
initialized so that these debugger APIs can inspect them. Similarly,
SDK libraries that are needed before any code can run should be
initialized. In order to support this, they are initialized on the
first initializeAndLinkLibrary call and reinitialized during a
hotRestart (since the libraries are recreated).

Tests are added to evaluate these methods before main is called.
The debugger test helpers are amended to:
- Support breakpoints within the bootstrap script. This is done by
caching the script and querying to see if it has the breakpoint if
the input sources do not.
- Refactor shared test expectation logic.
- Remove an unused method.

Change-Id: I5534d7008436a51243cf51dba01bb8ad06adca69
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/410602
Reviewed-by: Nicholas Shahan <nshahan@google.com>
Commit-Queue: Srujan Gaddam <srujzs@google.com>
diff --git a/pkg/dev_compiler/lib/js/ddc/ddc_module_loader.js b/pkg/dev_compiler/lib/js/ddc/ddc_module_loader.js
index c316a88..df9d4e6 100644
--- a/pkg/dev_compiler/lib/js/ddc/ddc_module_loader.js
+++ b/pkg/dev_compiler/lib/js/ddc/ddc_module_loader.js
@@ -1406,6 +1406,13 @@
     savedEntryPointLibraryName = null;
     savedDartSdkRuntimeOptions = null;
 
+    // Whether we've initialized the necessary SDK libraries before any code or
+    // debugging APIs can execute.
+    //
+    // This should be reset whenever we recreate `libraries`, like during a hot
+    // restart.
+    triggeredSDKLibrariesWithSideEffects = false;
+
     createEmptyLibrary() {
       return Object.create(null);
     }
@@ -1433,6 +1440,9 @@
      *   `importLibrary` for more details.
      */
     initializeAndLinkLibrary(libraryName, installFn) {
+      if (!this.triggeredSDKLibrariesWithSideEffects) {
+        this.triggerSDKLibrariesWithSideEffects();
+      }
       let currentLibrary = this.libraries[libraryName];
       if (currentLibrary == null) {
         currentLibrary = this.createEmptyLibrary();
@@ -1496,9 +1506,11 @@
      * (ex: dart:_interceptors) or observable from a carefully crafted user
      * program (ex: dart:html). In either case, the dependencies on the side
      * effects are not expressed through a Dart import so the libraries need
-     * to be loaded manually before the user program starts running.
+     * to be loaded manually before the user program starts running or before
+     * any debugging API is used.
      */
-    triggerSDKLibrariesSideEffects() {
+    triggerSDKLibrariesWithSideEffects() {
+      this.triggeredSDKLibrariesWithSideEffects = true;
       this.initializeAndLinkLibrary('dart:_runtime');
       this.initializeAndLinkLibrary('dart:_interceptors');
       this.initializeAndLinkLibrary('dart:_native_typed_data');
@@ -1510,7 +1522,6 @@
 
     // See docs on `DartDevEmbedder.runMain`.
     runMain(entryPointLibraryName, dartSdkRuntimeOptions) {
-      this.triggerSDKLibrariesSideEffects();
       this.setDartSDKRuntimeOptions(dartSdkRuntimeOptions);
       console.log('Starting application from main method in: ' + entryPointLibraryName + '.');
       let entryPointLibrary = this.initializeAndLinkLibrary(entryPointLibraryName);
@@ -1627,7 +1638,7 @@
       dart.hotRestart();
       // Clear all libraries.
       this.libraries = Object.create(null);
-      this.triggerSDKLibrariesSideEffects();
+      this.triggeredSDKLibrariesWithSideEffects = false;
       this.setDartSDKRuntimeOptions(this.savedDartSdkRuntimeOptions);
       let entryPointLibrary = this.initializeAndLinkLibrary(this.savedEntryPointLibraryName);
       // TODO(nshahan): Start sharing a single source of truth for the restart
@@ -1672,6 +1683,7 @@
      * @returns {Array<string>} Array containing the class names in the library.
      */
     getClassesInLibrary(libraryUri) {
+      libraryManager.initializeAndLinkLibrary(libraryUri);
       return dartRuntimeLibrary().getLibraryMetadata(libraryUri, libraryManager.libraries);
     }
 
@@ -1713,6 +1725,7 @@
      * above format.
      */
     getClassMetadata(libraryUri, name, objectInstance) {
+      libraryManager.initializeAndLinkLibrary(libraryUri);
       return dartRuntimeLibrary().getClassMetadata(libraryUri, name, objectInstance, libraryManager.libraries);
     }
 
diff --git a/pkg/dev_compiler/test/expression_compiler/expression_compiler_e2e_suite.dart b/pkg/dev_compiler/test/expression_compiler/expression_compiler_e2e_suite.dart
index c20d266..0f8147f 100644
--- a/pkg/dev_compiler/test/expression_compiler/expression_compiler_e2e_suite.dart
+++ b/pkg/dev_compiler/test/expression_compiler/expression_compiler_e2e_suite.dart
@@ -29,6 +29,7 @@
   late TestExpressionCompiler compiler;
   late Uri htmlBootstrapper;
   late Uri input;
+  wip.WipScript? _bootstrapScript;
   wip.WipScript? _script;
   Uri? inputPart;
   late Uri output;
@@ -36,6 +37,7 @@
   late SetupCompilerOptions setup;
   late String source;
   String? partSource;
+  late String bootstrapSource;
   late Directory testDir;
   late String dartSdkPath;
   final TimeoutTracker tracker = TimeoutTracker();
@@ -206,6 +208,7 @@
   // Unlike the typical app bootstraper, we delay calling main until all
   // breakpoints are setup.
   let scheduleMain = () => {
+    // Breakpoint: OnScheduleMain
     dartDevEmbedder.runMain('$appName.dart', sdkOptions);
   };
 </script>
@@ -226,6 +229,7 @@
   // Unlike the typical app bootstraper, we delay calling main until all
   // breakpoints are setup.
   let scheduleMain = () => {
+    // Breakpoint: OnScheduleMain
     dart_library.start('$appName', '$uuid', '$moduleName', '$mainLibraryName', false);
   };
 </script>
@@ -276,6 +280,7 @@
     'use strict';
     sdk.dart.nativeNonNullAsserts(true);
     scheduleMain = () => {
+      // Breakpoint: OnScheduleMain
       app.$mainLibraryName.main([]);
     };
     // Call main if the test harness already requested it.
@@ -288,7 +293,7 @@
         throw Exception('Unsupported module format for SDK evaluation tests: '
             '${setup.moduleFormat}');
     }
-
+    bootstrapSource = bootstrapFile.readAsStringSync();
     await setBreakpointsActive(debugger, true);
   }
 
@@ -339,11 +344,12 @@
   /// Reusing the script is possible because the bootstrap does not run `main`,
   /// but instead lets the test harness start main when it has prepared all
   /// breakpoints needed for the test.
-  Future<wip.WipScript> _loadScript() =>
+  Future<void> _loadScript() =>
       tracker._watch('load-script', () => _loadScriptHelper());
 
-  Future<wip.WipScript> _loadScriptHelper() async {
-    if (_script != null) return Future.value(_script);
+  Future<void> _loadScriptHelper() async {
+    if (_bootstrapScript != null && _script != null) return;
+    final bootstrapController = StreamController<wip.ScriptParsedEvent>();
     final scriptController = StreamController<wip.ScriptParsedEvent>();
     final consoleSub = debugger.connection.runtime.onConsoleAPICalled
         .listen((e) => printOnFailure('$e'));
@@ -358,7 +364,9 @@
     });
 
     final scriptSub = debugger.onScriptParsed.listen((event) {
-      if (event.script.url == '$output') {
+      if (event.script.url == '$htmlBootstrapper') {
+        bootstrapController.add(event);
+      } else if (event.script.url == '$output') {
         scriptController.add(event);
       }
     });
@@ -372,9 +380,17 @@
           onTimeout: (() => throw Exception(
               'Unable to navigate to page bootstrap script: $htmlBootstrapper')));
 
-      // Poll until the script is found, or timeout after a few seconds.
-      return _script = (await tracker._watch(
-              'find-script',
+      // Poll until both scripts are found, or timeout after a few seconds.
+      _bootstrapScript = (await tracker._watch(
+              'find-bootstrap-script',
+              () => bootstrapController.stream.first.timeout(
+                  Duration(seconds: 10),
+                  onTimeout: (() => throw Exception(
+                      'Unable to find bootstrap script corresponding to '
+                      '$htmlBootstrapper in ${debugger.scripts}.')))))
+          .script;
+      _script = (await tracker._watch(
+              'find-input-script',
               () => scriptController.stream.first.timeout(Duration(seconds: 10),
                   onTimeout: (() => throw Exception(
                       'Unable to find JS script corresponding to test file '
@@ -383,6 +399,7 @@
     } finally {
       await scriptSub.cancel();
       await consoleSub.cancel();
+      await bootstrapController.close();
       await scriptController.close();
       await pauseSub.cancel();
     }
@@ -425,12 +442,11 @@
       breakpointCompleter.complete(e);
     });
 
-    final script = await _loadScript();
+    await _loadScript();
 
-    // Breakpoint at the first WIP location mapped from its Dart line.
-    var dartLine = _findBreakpointLine(breakpointId);
-    var location =
-        await _jsLocationFromDartLine(script, dartLine.value, dartLine.key);
+    // Find the location associated with the breakpoint id and set a breakpoint.
+    var line = _findBreakpointLine(breakpointId);
+    var location = await _locationFromLine(line.value, line.key);
 
     var bp = await tracker._watch(
         'set-breakpoint', () => debugger.setBreakpoint(location));
@@ -444,7 +460,7 @@
           () => atBreakpoint.timeout(Duration(seconds: 10),
               onTimeout: () => throw Exception(
                   'Unable to find JS pause event corresponding to line '
-                  '($dartLine -> $location) in $output.')));
+                  '($line -> $location).')));
       return await onPause(event);
     } finally {
       await pauseSub.cancel();
@@ -506,20 +522,41 @@
     });
   }
 
-  /// Evaluates a js [expression] on a breakpoint.
+  /// Given a [remoteObject] and only one of [expectedError] and
+  /// [expectedResult], expects the object to be structurally equal to the
+  /// expected object.
   ///
-  /// [breakpointId] is the ID of the breakpoint from the source.
-  Future<String> evaluateJsExpression({
-    required String breakpointId,
-    required String expression,
+  /// If [expectedError] is passed, compares against the `error` property value.
+  /// If [stringifyResult] is true, stringifies the remote object before
+  /// comparing and doesn't do a structural comparison.
+  Future<void> _matchRemoteObject({
+    required wip.RemoteObject remoteObject,
+    dynamic expectedError,
+    dynamic expectedResult,
+    bool stringifyResult = false,
   }) async {
-    return await _onBreakpoint(breakpointId, onPause: (event) async {
-      var result = await _evaluateJsExpression(
-        event,
-        expression,
+    var error = remoteObject.json['error'];
+    if (error != null) {
+      expect(
+        expectedError,
+        isNotNull,
+        reason: 'Unexpected expression evaluation failure:\n$error',
       );
-      return await stringifyRemoteObject(result);
-    });
+      expect(error, _matches(expectedError!));
+    } else {
+      expect(
+        expectedResult,
+        isNotNull,
+        reason:
+            'Unexpected expression evaluation success:\n${remoteObject.json}',
+      );
+      if (stringifyResult) {
+        expect(await stringifyRemoteObject(remoteObject),
+            _matches(expectedResult!));
+      } else {
+        expect(remoteObject.value, _matches(equals(expectedResult!)));
+      }
+    }
   }
 
   /// Evaluates a JavaScript [expression] on a breakpoint and validates result.
@@ -543,26 +580,10 @@
         'Cannot expect both an error and result.');
 
     return await _onBreakpoint(breakpointId, onPause: (event) async {
-      var evalResult = await _evaluateJsExpression(event, expression);
-
-      var error = evalResult.json['error'];
-      if (error != null) {
-        expect(
-          expectedError,
-          isNotNull,
-          reason: 'Unexpected expression evaluation failure:\n$error',
-        );
-        expect(error, _matches(expectedError!));
-      } else {
-        expect(
-          expectedResult,
-          isNotNull,
-          reason:
-              'Unexpected expression evaluation success:\n${evalResult.json}',
-        );
-        var actual = evalResult.value;
-        expect(actual, _matches(equals(expectedResult!)));
-      }
+      await _matchRemoteObject(
+          remoteObject: await _evaluateJsExpression(event, expression),
+          expectedError: expectedError,
+          expectedResult: expectedResult);
     });
   }
 
@@ -594,29 +615,14 @@
         'Cannot expect both an error and result.');
 
     return await _onBreakpoint(breakpointId, onPause: (event) async {
-      var evalResult = await _evaluateDartExpressionInFrame(
-        event,
-        expression,
-      );
-
-      var error = evalResult.json['error'];
-      if (error != null) {
-        expect(
-          expectedError,
-          isNotNull,
-          reason: 'Unexpected expression evaluation failure:\n$error',
-        );
-        expect(error, _matches(expectedError!));
-      } else {
-        expect(
-          expectedResult,
-          isNotNull,
-          reason:
-              'Unexpected expression evaluation success:\n${evalResult.json}',
-        );
-        var actual = await stringifyRemoteObject(evalResult);
-        expect(actual, _matches(expectedResult!));
-      }
+      await _matchRemoteObject(
+          remoteObject: await _evaluateDartExpressionInFrame(
+            event,
+            expression,
+          ),
+          expectedError: expectedError,
+          expectedResult: expectedResult,
+          stringifyResult: true);
     });
   }
 
@@ -904,8 +910,8 @@
     return matches(RegExp(unindented, multiLine: true));
   }
 
-  /// Finds the first line number in [source] or [partSource] matching
-  /// [breakpointId].
+  /// Finds the first line number in [source], [partSource], or
+  /// [bootstrapSource] matching [breakpointId].
   ///
   /// A breakpoint ID is found by looking for a line that ends with a comment
   /// of exactly this form: `// Breakpoint: <id>`.
@@ -930,6 +936,10 @@
         return MapEntry(inputPart!, lineNumber + 1);
       }
     }
+    lineNumber = _findBreakpointLineImpl(breakpointId, bootstrapSource);
+    if (lineNumber >= 0) {
+      return MapEntry(htmlBootstrapper, lineNumber + 1);
+    }
     throw StateError(
         'Unable to find breakpoint in $input with id: $breakpointId');
   }
@@ -940,24 +950,30 @@
     return lines.indexWhere((l) => l.endsWith('// Breakpoint: $breakpointId'));
   }
 
-  /// Finds the corresponding JS WipLocation for a given line in Dart.
-  /// The input [dartLine] is 1-indexed, but really refers to the following line
+  /// Finds the corresponding JS WipLocation for a given line.
+  ///
+  /// The input [line] is 1-indexed, but really refers to the following line
   /// meaning that it talks about the following line in a 0-indexed manner.
-  Future<wip.WipLocation> _jsLocationFromDartLine(
-      wip.WipScript script, int dartLine, Uri lineIn) async {
-    var inputSourceUrl = lineIn.pathSegments.last;
-    for (var lineEntry in compiler.sourceMap.lines) {
-      for (var entry in lineEntry.entries) {
-        if (entry.sourceUrlId != null &&
-            entry.sourceLine == dartLine &&
-            compiler.sourceMap.urls[entry.sourceUrlId!] == inputSourceUrl) {
-          return wip.WipLocation.fromValues(script.scriptId, lineEntry.line);
+  Future<wip.WipLocation> _locationFromLine(int line, Uri lineIn) async {
+    final wip.WipScript script;
+    if (lineIn == htmlBootstrapper) {
+      script = _bootstrapScript!;
+      return wip.WipLocation.fromValues(script.scriptId, line);
+    } else {
+      script = _script!;
+      var inputSourceUrl = lineIn.pathSegments.last;
+      for (var lineEntry in compiler.sourceMap.lines) {
+        for (var entry in lineEntry.entries) {
+          if (entry.sourceUrlId != null &&
+              entry.sourceLine == line &&
+              compiler.sourceMap.urls[entry.sourceUrlId!] == inputSourceUrl) {
+            return wip.WipLocation.fromValues(script.scriptId, lineEntry.line);
+          }
         }
       }
     }
     throw StateError(
-        'Unable to extract WIP Location from ${script.url} for Dart line '
-        '$dartLine.');
+        'Unable to extract WIP Location from ${script.url} for line $line.');
   }
 }
 
diff --git a/pkg/dev_compiler/test/expression_compiler/runtime_debugger_api_test.dart b/pkg/dev_compiler/test/expression_compiler/runtime_debugger_api_test.dart
index 9d1b623..8d1b6f9 100644
--- a/pkg/dev_compiler/test/expression_compiler/runtime_debugger_api_test.dart
+++ b/pkg/dev_compiler/test/expression_compiler/runtime_debugger_api_test.dart
@@ -159,6 +159,72 @@
 
 void runSharedTests(
     SetupCompilerOptions setup, ExpressionEvaluationTestDriver driver) {
+  group('Runtime debugging API after loading sources but before running main |',
+      () {
+    var source = simpleClassSource;
+
+    // Set up and tear down after every test so that the bootstrapper script is
+    // re-run every time, therefore triggering the `OnScheduleMain` breakpoint.
+    setUp(() async {
+      await driver.initSource(setup, source);
+    });
+
+    tearDown(() async {
+      await driver.cleanupTest();
+    });
+
+    test('getClassesInLibrary', () async {
+      var getClasses = setup.emitLibraryBundle
+          ? 'getClassesInLibrary'
+          : 'getLibraryMetadata';
+      await driver.checkRuntimeInFrame(
+        breakpointId: 'OnScheduleMain',
+        // Query a user definition to test if it's initialized.
+        expression: 'dart.$getClasses("package:eval_test/test.dart")',
+        expectedResult: ['BaseClass', 'DerivedClass', 'AnotherClass'],
+      );
+    });
+
+    test('getClassMetadata in non-SDK library', () async {
+      await driver.checkRuntimeInFrame(
+          breakpointId: 'OnScheduleMain',
+          // Query a user definition to test if it's initialized.
+          expression:
+              'dart.getClassMetadata("package:eval_test/test.dart", "DerivedClass")',
+          expectedResult: {
+            'className': 'DerivedClass',
+            'superClassName': 'BaseClass',
+            'superClassLibraryId': 'package:eval_test/test.dart',
+            'fields': {
+              'newPublicField': {
+                'isFinal': true,
+                'className': 'int',
+                'classLibraryId': 'dart:core',
+              },
+              '_newPrivateField': {
+                'isFinal': true,
+                'className': 'int',
+                'classLibraryId': 'dart:core',
+              },
+              '_newStaticConstPrivateField': {'isStatic': true},
+            },
+            'methods': {
+              'stringLength': {},
+              'lateFinalField': {'isGetter': true},
+              'getter': {'isGetter': true},
+              '_privateGetter': {'isGetter': true},
+              'factory': {'isStatic': true},
+              'staticMethod': {'isStatic': true},
+              'extensionTypeGetter': {'isGetter': true},
+              '_privateExtensionTypeGetter': {'isGetter': true},
+            },
+          });
+    });
+  },
+      // DDC module format doesn't guarantee that libraries are initialized when
+      // using debugging APIs before main is started.
+      skip: setup.moduleFormat == ModuleFormat.ddc && !setup.canaryFeatures);
+
   group('Runtime debugging API |', () {
     var source = simpleClassSource;