| // Copyright (c) 2021, 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 'dart:convert'; | |
| import 'dart:io' show Directory, File, Platform, FileSystemException; | |
| import 'dart:math'; | |
| import 'package:async/async.dart'; | |
| import 'package:browser_launcher/browser_launcher.dart' as browser; | |
| import 'package:dev_compiler/src/compiler/module_builder.dart'; | |
| import 'package:path/path.dart' as p; | |
| import 'package:source_maps/source_maps.dart'; | |
| import 'package:test/test.dart'; | |
| import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart' | |
| as wip; | |
| import '../shared_test_options.dart'; | |
| import 'test_compiler.dart'; | |
| class ExpressionEvaluationTestDriver { | |
| final browser.Chrome chrome; | |
| final Directory chromeDir; | |
| final wip.WipConnection connection; | |
| final wip.WipDebugger debugger; | |
| final wip.WipRuntime runtime; | |
| final ExecutionContext executionContext; | |
| late TestExpressionCompiler compiler; | |
| late Uri htmlBootstrapper; | |
| late Uri input; | |
| wip.WipScript? _bootstrapScript; | |
| wip.WipScript? _script; | |
| Uri? inputPart; | |
| late Uri output; | |
| late Uri packagesFile; | |
| late SetupCompilerOptions setup; | |
| late String source; | |
| String? partSource; | |
| late String bootstrapSource; | |
| late Directory testDir; | |
| late String dartSdkPath; | |
| final TimeoutTracker tracker = TimeoutTracker(); | |
| ExpressionEvaluationTestDriver._( | |
| this.chrome, | |
| this.chromeDir, | |
| this.connection, | |
| this.debugger, | |
| this.runtime, | |
| ) : executionContext = ExecutionContext(runtime); | |
| /// Initializes a Chrome browser instance, tab connection, and debugger. | |
| static Future<ExpressionEvaluationTestDriver> init() async { | |
| // Create a temporary directory for holding Chrome tests. | |
| var chromeDir = Directory.systemTemp.createTempSync('ddc_eval_test_anchor'); | |
| // Try to start Chrome on an empty page with a single empty tab. | |
| // TODO(#45713): Headless Chrome crashes the Windows bots, so run in | |
| // standard mode until it's fixed. | |
| browser.Chrome? chrome; | |
| var retries = 3; | |
| // It is possible for chrome to start and be ready while still printing | |
| // messages to stderr which results in a Dart exception being thrown. For | |
| // that reason, it is important to check `chrome == null` so we don't | |
| // accidentally start multiple instances. | |
| while (chrome == null && retries-- > 0) { | |
| try { | |
| chrome = await browser.Chrome.startWithDebugPort( | |
| ['about:blank'], | |
| userDataDir: chromeDir.uri.toFilePath(), | |
| headless: !Platform.isWindows, | |
| // Disabling to avoid issues with the AppArmor security features in | |
| // Ubuntu "Resolute". | |
| additionalArguments: ['--no-sandbox'], | |
| ); | |
| } catch (e) { | |
| if (retries == 0) rethrow; | |
| await Future.delayed(Duration(seconds: 5)); | |
| } | |
| } | |
| if (chrome == null) { | |
| throw Exception('Unable to launch Chrome.'); | |
| } | |
| // Connect to the first 'normal' tab. | |
| var tab = await chrome.chromeConnection.getTab( | |
| (tab) => !tab.isBackgroundPage && !tab.isChromeExtension, | |
| retryFor: Duration(seconds: 5), | |
| ); | |
| if (tab == null) { | |
| throw Exception('Unable to connect to Chrome tab'); | |
| } | |
| var connection = await tab.connect().timeout( | |
| Duration(seconds: 5), | |
| onTimeout: (() => throw Exception('Unable to connect to WIP tab')), | |
| ); | |
| await connection.page.enable().timeout( | |
| Duration(seconds: 5), | |
| onTimeout: (() => throw Exception('Unable to enable WIP tab page')), | |
| ); | |
| var runtime = connection.runtime; | |
| await runtime.enable().timeout( | |
| Duration(seconds: 5), | |
| onTimeout: (() => throw Exception('Unable to enable WIP runtime')), | |
| ); | |
| var debugger = connection.debugger; | |
| await debugger.enable().timeout( | |
| Duration(seconds: 5), | |
| onTimeout: (() => throw Exception('Unable to enable WIP debugger')), | |
| ); | |
| return ExpressionEvaluationTestDriver._( | |
| chrome, | |
| chromeDir, | |
| connection, | |
| debugger, | |
| runtime, | |
| ); | |
| } | |
| /// Must be called when testing a new Dart program. | |
| /// | |
| /// Depends on SDK artifacts (such as the dart_sdk.js files) generated from | |
| /// the 'ddc_stable_test' and 'ddc_canary_test' targets. | |
| Future<void> initSource( | |
| SetupCompilerOptions setup, | |
| String source, { | |
| Map<String, bool> experiments = const {}, | |
| String? partSource, | |
| }) => tracker._watch( | |
| 'init-source', | |
| () => _initSource( | |
| setup, | |
| source, | |
| experiments: experiments, | |
| partSource: partSource, | |
| ), | |
| ); | |
| Future<void> _initSource( | |
| SetupCompilerOptions setup, | |
| String source, { | |
| Map<String, bool> experiments = const {}, | |
| String? partSource, | |
| }) async { | |
| // Perform setup sanity checks. | |
| var summaryPath = setup.options.sdkSummary!.toFilePath(); | |
| if (!File(summaryPath).existsSync()) { | |
| throw StateError('Unable to find SDK summary at path: $summaryPath.'); | |
| } | |
| this.setup = setup; | |
| this.source = source; | |
| this.partSource = partSource; | |
| testDir = chromeDir.createTempSync('ddc_eval_test'); | |
| var scriptPath = Platform.script.normalizePath().toFilePath(); | |
| var ddcPath = p.dirname(p.dirname(p.dirname(scriptPath))); | |
| output = testDir.uri.resolve('test.js'); | |
| _script = null; | |
| input = testDir.uri.resolve('test.dart'); | |
| File(input.toFilePath()) | |
| ..createSync() | |
| ..writeAsStringSync(source); | |
| if (partSource != null) { | |
| inputPart = testDir.uri.resolve('part.dart'); | |
| File(inputPart!.toFilePath()) | |
| ..createSync() | |
| ..writeAsStringSync(partSource); | |
| } else { | |
| inputPart = null; | |
| } | |
| packagesFile = testDir.uri.resolve('package_config.json'); | |
| File(packagesFile.toFilePath()) | |
| ..createSync() | |
| ..writeAsStringSync(''' | |
| { | |
| "configVersion": 2, | |
| "packages": [ | |
| { | |
| "name": "eval_test", | |
| "rootUri": "./", | |
| "packageUri": "./" | |
| } | |
| ] | |
| } | |
| '''); | |
| // Initialize DDC and the incremental compiler, then perform a full compile. | |
| compiler = await TestExpressionCompiler.init( | |
| setup, | |
| input: input, | |
| output: output, | |
| packages: packagesFile, | |
| experiments: experiments, | |
| ); | |
| htmlBootstrapper = testDir.uri.resolve('bootstrapper.html'); | |
| var bootstrapFile = File(htmlBootstrapper.toFilePath())..createSync(); | |
| var moduleName = compiler.metadata!.name; | |
| var mainLibraryName = libraryUriToImportName( | |
| Uri.parse(compiler.metadataForLibraryUri(input).importUri), | |
| ); | |
| var appName = p.relative( | |
| p.withoutExtension(compiler.metadataForLibraryUri(input).importUri), | |
| ); | |
| switch (setup.moduleFormat) { | |
| case ModuleFormat.ddc: | |
| dartSdkPath = escaped( | |
| SetupCompilerOptions.buildRoot | |
| .resolve( | |
| p.join( | |
| 'gen', | |
| 'utils', | |
| 'ddc', | |
| setup.canaryFeatures ? 'canary' : 'stable', | |
| 'sdk', | |
| 'ddc', | |
| 'dart_sdk.js', | |
| ), | |
| ) | |
| .toFilePath(), | |
| ); | |
| if (!File(dartSdkPath).existsSync()) { | |
| throw Exception('Unable to find Dart SDK at $dartSdkPath'); | |
| } | |
| var dartLibraryPath = escaped( | |
| p.join(ddcPath, 'lib', 'js', 'ddc', 'ddc_module_loader.js'), | |
| ); | |
| var outputPath = output.toFilePath(); | |
| if (setup.emitLibraryBundle) { | |
| bootstrapFile.writeAsStringSync(''' | |
| <script src='$dartLibraryPath'></script> | |
| <script src='$dartSdkPath'></script> | |
| <script src='$outputPath'></script> | |
| <script> | |
| 'use strict'; | |
| let dartApplication = true; | |
| let sdkOptions = { nativeNonNullAsserts: true }; | |
| // Unlike the typical app bootstraper, we delay calling main until all | |
| // breakpoints are setup. | |
| let scheduleMain = () => { | |
| // Breakpoint: OnScheduleMain | |
| dartDevEmbedder.runMain('$appName.dart', sdkOptions); | |
| }; | |
| </script> | |
| '''); | |
| } else { | |
| // This is used in the DDC module system for multiapp workflows and is | |
| // stubbed here. | |
| var uuid = '00000000-0000-0000-0000-000000000000'; | |
| bootstrapFile.writeAsStringSync(''' | |
| <script src='$dartLibraryPath'></script> | |
| <script src='$dartSdkPath'></script> | |
| <script src='$outputPath'></script> | |
| <script> | |
| 'use strict'; | |
| let dartApplication = true; | |
| var sdk = dart_library.import('dart_sdk'); | |
| sdk.dart.nativeNonNullAsserts(true); | |
| // 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> | |
| '''); | |
| } | |
| case ModuleFormat.amd: | |
| var dartSdkPathNoExtension = escaped( | |
| SetupCompilerOptions.buildRoot | |
| .resolve( | |
| p.join( | |
| 'gen', | |
| 'utils', | |
| 'ddc', | |
| setup.canaryFeatures ? 'canary' : 'stable', | |
| 'sdk', | |
| 'amd', | |
| 'dart_sdk', | |
| ), | |
| ) | |
| .toFilePath(), | |
| ); | |
| dartSdkPath = '$dartSdkPathNoExtension.js'; | |
| if (!File(dartSdkPath).existsSync()) { | |
| throw Exception('Unable to find Dart SDK at $dartSdkPath'); | |
| } | |
| var requirePath = escaped( | |
| SetupCompilerOptions.buildRoot | |
| .resolve( | |
| p.join('dart-sdk', 'lib', 'dev_compiler', 'amd', 'require.js'), | |
| ) | |
| .toFilePath(), | |
| ); | |
| var outputPath = escaped(p.withoutExtension(output.toFilePath())); | |
| bootstrapFile.writeAsStringSync(''' | |
| <script src='$requirePath'></script> | |
| <script> | |
| require.config({ | |
| paths: { | |
| 'dart_sdk': '$dartSdkPathNoExtension', | |
| '$moduleName': '$outputPath' | |
| }, | |
| waitSeconds: 15 | |
| }); | |
| let dartApplication = true; | |
| let scheduleMainCalled = false; | |
| // Unlike the typical app bootstraper, we delay calling main until all | |
| // breakpoints are setup. | |
| // Because AMD runs the initialization asynchronously, this may be called | |
| // before require.js calls the initialization below. | |
| let scheduleMain = () => { | |
| scheduleMainCalled = true; | |
| }; | |
| require(['dart_sdk', '$moduleName'], | |
| function(sdk, app) { | |
| 'use strict'; | |
| sdk.dart.nativeNonNullAsserts(true); | |
| scheduleMain = () => { | |
| // Breakpoint: OnScheduleMain | |
| app.$mainLibraryName.main([]); | |
| }; | |
| // Call main if the test harness already requested it. | |
| if (scheduleMainCalled) scheduleMain(); | |
| }); | |
| </script> | |
| '''); | |
| default: | |
| throw Exception( | |
| 'Unsupported module format for SDK evaluation tests: ' | |
| '${setup.moduleFormat}', | |
| ); | |
| } | |
| bootstrapSource = bootstrapFile.readAsStringSync(); | |
| await setBreakpointsActive(debugger, true); | |
| } | |
| Future<void> finish() async { | |
| tracker._showReport(); | |
| await chrome.close(); | |
| // Attempt to clean up the temporary directory. | |
| // On windows sometimes the process has not released the directory yet so | |
| // retry with an exponential backoff. | |
| var deleteAttempts = 0; | |
| while (await chromeDir.exists()) { | |
| deleteAttempts++; | |
| try { | |
| await chromeDir.delete(recursive: true); | |
| } on FileSystemException catch (e) { | |
| print('Error trying to delete chromeDir: $e'); | |
| if (deleteAttempts > 3) return; | |
| var delayMs = pow(10, deleteAttempts).floor(); | |
| await Future.delayed(Duration(milliseconds: delayMs)); | |
| } | |
| } | |
| } | |
| Future<void> cleanupTest() async { | |
| await setBreakpointsActive(debugger, false); | |
| setup.diagnosticMessages.clear(); | |
| setup.errors.clear(); | |
| } | |
| Future<void> checkScope({ | |
| required String breakpointId, | |
| required Map<String, String> expectedScope, | |
| }) async { | |
| final actualScope = await getScope(breakpointId); | |
| actualScope.removeWhere( | |
| (key, value) => | |
| _ddcTemporaryVariableRegExp.hasMatch(key) || | |
| _ddcTemporaryTypeVariableRegExp.hasMatch(key), | |
| ); | |
| expect(actualScope, expectedScope); | |
| } | |
| /// Ensures the current [input] script is loaded. | |
| /// | |
| /// The first time an input is found, this will navigate to the bootstrap page | |
| /// set up by [initSource] and return the script corresponding to [input]. | |
| /// Any subsequent test that uses the same input will not trigger a new | |
| /// navigation, but reuse the existing script on the page. | |
| /// | |
| /// 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<void> _loadScript() => | |
| tracker._watch('load-script', () => _loadScriptHelper()); | |
| 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'), | |
| ); | |
| // Fail on exceptions in JS code. | |
| await debugger.setPauseOnExceptions(wip.PauseState.uncaught); | |
| final pauseSub = debugger.onPaused.listen((wip.DebuggerPausedEvent e) { | |
| if (e.reason == 'exception' || e.reason == 'assert') { | |
| scriptController.addError('Uncaught exception in JS code: ${e.data}'); | |
| throw Exception('Failed to load script.'); | |
| } | |
| }); | |
| final scriptSub = debugger.onScriptParsed.listen((event) { | |
| if (event.script.url == '$htmlBootstrapper') { | |
| bootstrapController.add(event); | |
| } else if (event.script.url == '$output') { | |
| scriptController.add(event); | |
| } | |
| }); | |
| try { | |
| // Navigate to the page that will load the application code. | |
| // Note: the bootstrapper does not invoke the application main, but | |
| // exposes a function that can be called to do so. | |
| await connection.page | |
| .navigate('$htmlBootstrapper') | |
| .timeout( | |
| Duration(seconds: 5), | |
| onTimeout: (() => throw Exception( | |
| 'Unable to navigate to page bootstrap script: $htmlBootstrapper', | |
| )), | |
| ); | |
| // 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 ' | |
| '$output in ${debugger.scripts}.', | |
| )), | |
| ), | |
| )).script; | |
| } finally { | |
| await scriptSub.cancel(); | |
| await consoleSub.cancel(); | |
| await bootstrapController.close(); | |
| await scriptController.close(); | |
| await pauseSub.cancel(); | |
| } | |
| } | |
| /// Uses the debugger API to trigger the execution of the app. | |
| Future<wip.RemoteObject> _scheduleMain() async { | |
| final context = await executionContext.id; | |
| return runtime.evaluate('scheduleMain()', contextId: context).catchError(( | |
| Object e, | |
| ) { | |
| printOnFailure( | |
| e is wip.ExceptionDetails | |
| ? 'Exception when calling scheduleMain: ${e.json}!' | |
| : 'Uncaught exception during scheduleMain: $e', | |
| ); | |
| throw e; | |
| }); | |
| } | |
| /// Load the script, invoke it's main method, and run [onPause] when the app | |
| /// pauses on [breakpointId]. | |
| /// | |
| /// Internally, this navigates to the bootstrapper page or ensures that the | |
| /// bootstrapper page has already been loaded. The page only loads code | |
| /// without running the DDC app main method. Once the resources are loaded we | |
| /// wait until after the breakpoint is registered before scheduling a call to | |
| /// the app's main method. | |
| Future<T> _onBreakpoint<T>( | |
| String breakpointId, { | |
| required Future<T> Function(wip.DebuggerPausedEvent) onPause, | |
| }) async { | |
| final consoleSub = debugger.connection.runtime.onConsoleAPICalled.listen( | |
| (e) => printOnFailure('$e'), | |
| ); | |
| // Used to reflect when [breakpointId] is hit. | |
| final breakpointCompleter = Completer<wip.DebuggerPausedEvent>(); | |
| final pauseSub = debugger.onPaused.listen((e) { | |
| if (e.reason == 'exception' || e.reason == 'assert') { | |
| breakpointCompleter.completeError( | |
| 'Uncaught exception in JS code: ${e.data}', | |
| ); | |
| throw Exception('Script failed while waiting for a breakpoint to hit.'); | |
| } | |
| breakpointCompleter.complete(e); | |
| }); | |
| await _loadScript(); | |
| // 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), | |
| ); | |
| final atBreakpoint = breakpointCompleter.future; | |
| try { | |
| // Now that the breakpoint is set, the application can start running. | |
| unawaited(_scheduleMain()); | |
| final event = await tracker._watch( | |
| 'pause-event-for-line', | |
| () => atBreakpoint.timeout( | |
| Duration(seconds: 10), | |
| onTimeout: () => throw Exception( | |
| 'Unable to find JS pause event corresponding to line ' | |
| '($line -> $location).', | |
| ), | |
| ), | |
| ); | |
| return await onPause(event); | |
| } finally { | |
| await pauseSub.cancel(); | |
| await consoleSub.cancel(); | |
| await debugger.removeBreakpoint(bp.breakpointId); | |
| // Resume execution to the end of the current script | |
| try { | |
| await debugger.resume(); | |
| } catch (_) { | |
| // Resume throws it the program is not paused, ignore. | |
| } | |
| } | |
| } | |
| /// Load the script and run the [body] while the app is running. | |
| Future<T> _whileRunning<T>({required Future<T> Function() body}) async { | |
| final consoleSub = debugger.connection.runtime.onConsoleAPICalled.listen( | |
| (e) => printOnFailure('$e'), | |
| ); | |
| await _loadScript(); | |
| try { | |
| await _scheduleMain(); | |
| return await body(); | |
| } finally { | |
| await consoleSub.cancel(); | |
| } | |
| } | |
| Future<Map<String, String>> getScope(String breakpointId) async { | |
| return await _onBreakpoint( | |
| breakpointId, | |
| onPause: (event) async { | |
| // Retrieve the call frame and its scope variables. | |
| var frame = event.getCallFrames().first; | |
| return await _collectScopeVariables(frame); | |
| }, | |
| ); | |
| } | |
| /// Evaluates a dart [expression] on a breakpoint. | |
| /// | |
| /// [breakpointId] is the ID of the breakpoint from the source. | |
| Future<String> evaluateDartExpressionInFrame({ | |
| required String breakpointId, | |
| required String expression, | |
| }) async { | |
| return await _onBreakpoint( | |
| breakpointId, | |
| onPause: (event) async { | |
| var result = await _evaluateDartExpressionInFrame(event, expression); | |
| return await stringifyRemoteObject(result); | |
| }, | |
| ); | |
| } | |
| /// Evaluates a dart [expression] while the app is running. | |
| Future<String> evaluateDartExpression({required String expression}) async { | |
| return await _whileRunning( | |
| body: () async { | |
| var result = await _evaluateDartExpression(expression); | |
| return await stringifyRemoteObject(result); | |
| }, | |
| ); | |
| } | |
| /// Given a [remoteObject] and only one of [expectedError] and | |
| /// [expectedResult], expects the object to be structurally equal to the | |
| /// expected object. | |
| /// | |
| /// 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 { | |
| var error = remoteObject.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${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. | |
| /// | |
| /// [breakpointId] is the ID of the breakpoint from the source. | |
| /// [expression] is a dart runtime method call, i.e. | |
| /// `dart.getLibraryMetadata(uri)`; | |
| /// [expectedResult] is the JSON for the returned remote object. | |
| /// | |
| /// Nested objects are not included in the result (they appear as `{}`), | |
| /// only primitive values, lists or maps, etc. | |
| /// | |
| /// TODO(annagrin): Add recursive check for nested objects. | |
| Future<void> checkRuntimeInFrame({ | |
| required String breakpointId, | |
| required String expression, | |
| dynamic expectedError, | |
| dynamic expectedResult, | |
| }) async { | |
| assert( | |
| expectedError == null || expectedResult == null, | |
| 'Cannot expect both an error and result.', | |
| ); | |
| return await _onBreakpoint( | |
| breakpointId, | |
| onPause: (event) async { | |
| await _matchRemoteObject( | |
| remoteObject: await _evaluateJsExpression(event, expression), | |
| expectedError: expectedError, | |
| expectedResult: expectedResult, | |
| ); | |
| }, | |
| ); | |
| } | |
| /// Evaluates a dart [expression] on a breakpoint and validates result. | |
| /// | |
| /// [breakpointId] is the ID of the breakpoint from the source. | |
| /// [expression] is a dart expression. | |
| /// [expectedResult] is the JSON for the returned remote object. | |
| /// [expectedError] is the error string if the error is expected. | |
| Future<void> checkInFrame({ | |
| required String breakpointId, | |
| required String expression, | |
| dynamic expectedError, | |
| dynamic expectedResult, | |
| }) => tracker._watch( | |
| 'check-in-frame', | |
| () => _checkInFrame( | |
| breakpointId: breakpointId, | |
| expression: expression, | |
| expectedError: expectedError, | |
| expectedResult: expectedResult, | |
| ), | |
| ); | |
| Future<void> _checkInFrame({ | |
| required String breakpointId, | |
| required String expression, | |
| dynamic expectedError, | |
| dynamic expectedResult, | |
| }) async { | |
| assert( | |
| expectedError == null || expectedResult == null, | |
| 'Cannot expect both an error and result.', | |
| ); | |
| return await _onBreakpoint( | |
| breakpointId, | |
| onPause: (event) async { | |
| await _matchRemoteObject( | |
| remoteObject: await _evaluateDartExpressionInFrame(event, expression), | |
| expectedError: expectedError, | |
| expectedResult: expectedResult, | |
| stringifyResult: true, | |
| ); | |
| }, | |
| ); | |
| } | |
| /// Evaluates a dart [expression] under the scope of [libraryUri] without | |
| /// a breakpoint and validates the result. | |
| /// | |
| /// When [libraryUri] is omitted, the expression is evaluated in the [input] | |
| /// library. | |
| /// | |
| /// [expectedResult] is the JSON for the returned remote object. | |
| /// [expectedError] is the error string if the error is expected. | |
| Future<void> check({ | |
| required String expression, | |
| Uri? libraryUri, | |
| dynamic expectedError, | |
| dynamic expectedResult, | |
| }) async { | |
| assert( | |
| expectedError == null || expectedResult == null, | |
| 'Cannot expect both an error and result.', | |
| ); | |
| return await _whileRunning( | |
| body: () async { | |
| var evalResult = await _evaluateDartExpression( | |
| expression, | |
| libraryUri: libraryUri, | |
| ); | |
| 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!)); | |
| } | |
| }, | |
| ); | |
| } | |
| Future<wip.RemoteObject> _evaluateJsExpression( | |
| wip.DebuggerPausedEvent event, | |
| String expression, { | |
| bool returnByValue = true, | |
| }) async { | |
| var frame = event.getCallFrames().first; | |
| String jsExpressionBody; | |
| if (setup.emitLibraryBundle) { | |
| jsExpressionBody = 'var dart = dartDevEmbedder.debugger;'; | |
| } else { | |
| var loadModule = setup.moduleFormat == ModuleFormat.amd | |
| ? 'require' | |
| : 'dart_library.import'; | |
| jsExpressionBody = | |
| ''' | |
| var sdk = $loadModule('dart_sdk'); | |
| var dart = sdk.dart; | |
| '''; | |
| } | |
| var jsExpression = | |
| ''' | |
| (function () { | |
| $jsExpressionBody | |
| return $expression; | |
| })() | |
| '''; | |
| try { | |
| return await debugger.evaluateOnCallFrame( | |
| frame.callFrameId, | |
| jsExpression, | |
| returnByValue: returnByValue, | |
| ); | |
| } on wip.ExceptionDetails catch (e) { | |
| return _createRuntimeError(e); | |
| } | |
| } | |
| Future<TestCompilationResult> _compileDartExpressionInFrame( | |
| wip.WipCallFrame frame, | |
| String expression, | |
| ) async { | |
| // Retrieve the call frame and its scope variables. | |
| var scope = await _collectScopeVariables(frame); | |
| var searchLine = frame.location.lineNumber; | |
| var searchColumn = frame.location.columnNumber; | |
| var inputSourceUrl = input.pathSegments.last; | |
| var inputPartSourceUrl = inputPart?.pathSegments.last; | |
| // package:dwds - which I think is what actually provides line and column | |
| // when debugging e.g. via flutter - basically finds the closest point | |
| // before or on the line/column, so we do the same here. | |
| // If there is no javascript column we pick the smallest column value on | |
| // that line. | |
| TargetEntry? best; | |
| for (var lineEntry in compiler.sourceMap.lines) { | |
| if (lineEntry.line != searchLine) continue; | |
| for (var entry in lineEntry.entries) { | |
| if (entry.sourceUrlId != null) { | |
| var sourceMapUrl = compiler.sourceMap.urls[entry.sourceUrlId!]; | |
| if (sourceMapUrl == inputSourceUrl || | |
| sourceMapUrl == inputPartSourceUrl) { | |
| if (best == null) { | |
| best = entry; | |
| } else if (searchColumn != null && | |
| entry.column > best.column && | |
| entry.column <= searchColumn) { | |
| best = entry; | |
| } else if (searchColumn == null && entry.column < best.column) { | |
| best = entry; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| if (best == null || best.sourceLine == null || best.sourceColumn == null) { | |
| throw StateError( | |
| 'Unable to find the matching dart line and column ' | |
| ' for where the javascript paused.', | |
| ); | |
| } | |
| final bestUrl = compiler.sourceMap.urls[best.sourceUrlId!]; | |
| var scriptUrl = input; | |
| if (bestUrl == inputPartSourceUrl) { | |
| scriptUrl = inputPart!; | |
| } | |
| // Convert from 0-indexed to 1-indexed. | |
| var dartLine = best.sourceLine! + 1; | |
| var dartColumn = best.sourceColumn! + 1; | |
| // Perform an incremental compile. | |
| return await compiler.compileExpression( | |
| libraryUri: input, | |
| scriptUri: scriptUrl, | |
| line: dartLine, | |
| column: dartColumn, | |
| scope: scope, | |
| expression: expression, | |
| ); | |
| } | |
| Future<TestCompilationResult> _compileDartExpression( | |
| String expression, | |
| Uri? libraryUri, | |
| ) async { | |
| // Perform an incremental compile. | |
| return await compiler.compileExpression( | |
| libraryUri: libraryUri ?? input, | |
| line: 1, | |
| column: 1, | |
| scope: {}, | |
| expression: expression, | |
| ); | |
| } | |
| Future<wip.RemoteObject> _evaluateDartExpressionInFrame( | |
| wip.DebuggerPausedEvent event, | |
| String expression, { | |
| bool returnByValue = false, | |
| }) async { | |
| var frame = event.getCallFrames().first; | |
| var result = await _compileDartExpressionInFrame(frame, expression); | |
| if (!result.isSuccess) { | |
| return _createCompilationError(result); | |
| } | |
| // Evaluate the compiled expression. | |
| try { | |
| return await debugger.evaluateOnCallFrame( | |
| frame.callFrameId, | |
| result.result!, | |
| returnByValue: returnByValue, | |
| ); | |
| } on wip.ExceptionDetails catch (e) { | |
| return _createRuntimeError(e); | |
| } | |
| } | |
| Future<wip.RemoteObject> _evaluateDartExpression( | |
| String expression, { | |
| Uri? libraryUri, | |
| bool returnByValue = false, | |
| }) async { | |
| var result = await _compileDartExpression(expression, libraryUri); | |
| if (!result.isSuccess) { | |
| return _createCompilationError(result); | |
| } | |
| // Find the execution context for the dart app. | |
| final context = await executionContext.id; | |
| // Evaluate the compiled expression. | |
| try { | |
| return await runtime.evaluate( | |
| result.result!, | |
| contextId: context, | |
| returnByValue: returnByValue, | |
| ); | |
| } on wip.ExceptionDetails catch (e) { | |
| return _createRuntimeError(e); | |
| } | |
| } | |
| wip.RemoteObject _createCompilationError(TestCompilationResult result) { | |
| setup.diagnosticMessages.clear(); | |
| setup.errors.clear(); | |
| return wip.RemoteObject({'error': result.result}); | |
| } | |
| wip.RemoteObject _createRuntimeError(wip.ExceptionDetails error) { | |
| return wip.RemoteObject({'error': error.exception!.description}); | |
| } | |
| /// Generate simple string representation of a RemoteObject that closely | |
| /// resembles Chrome's console output. | |
| /// | |
| /// Examples: | |
| /// Class: t.C.new {Symbol(C.field): 5, Symbol(_field): 7} | |
| /// Function: function main() { | |
| /// return test.foo(1, {y: 2}); | |
| /// } | |
| Future<String> stringifyRemoteObject(wip.RemoteObject obj) async { | |
| String str; | |
| final type = obj.json.containsKey('type') ? obj.type : null; | |
| switch (type) { | |
| case 'function': | |
| str = obj.description ?? ''; | |
| case 'object': | |
| if (obj.subtype == 'null') { | |
| return 'null'; | |
| } | |
| try { | |
| var properties = await connection.runtime.getProperties( | |
| obj, | |
| ownProperties: true, | |
| ); | |
| var filteredProps = <String, String?>{}; | |
| for (var prop in properties) { | |
| if (prop.value != null && prop.name != '__proto__') { | |
| filteredProps[prop.name] = await stringifyRemoteObject( | |
| prop.value!, | |
| ); | |
| } | |
| } | |
| str = '${obj.description} $filteredProps'; | |
| } catch (e, s) { | |
| throw StateError('Failed to stringify remote object $obj: $e:$s'); | |
| } | |
| default: | |
| str = '${obj.value}'; | |
| } | |
| return str; | |
| } | |
| /// Collects local JS variables visible at a breakpoint during evaluation. | |
| /// | |
| /// Adapted from webdev/dwds/lib/src/services/expression_evaluator.dart. | |
| Future<Map<String, String>> _collectScopeVariables( | |
| wip.WipCallFrame frame, | |
| ) async { | |
| var jsScope = <String, String>{}; | |
| for (var scope in filterScopes(frame)) { | |
| var response = await connection.runtime.getProperties( | |
| scope.object, | |
| ownProperties: true, | |
| ); | |
| for (var prop in response) { | |
| var propKey = prop.name; | |
| var propValue = '${prop.value?.value}'; | |
| if (prop.value?.type == 'string') { | |
| propValue = "'$propValue'"; | |
| } else if (propValue == 'null') { | |
| propValue = propKey; | |
| } | |
| jsScope[propKey] = propValue; | |
| } | |
| } | |
| return jsScope; | |
| } | |
| /// Used for matching error text emitted during expression evaluation. | |
| Matcher _matches(dynamic matcher) { | |
| if (matcher is Matcher) return matcher; | |
| if (matcher is! String) throw StateError('Unexpected matcher: $matcher'); | |
| var unindented = RegExp.escape(matcher).replaceAll(RegExp('[ ]+'), '[ ]*'); | |
| return matches(RegExp(unindented, multiLine: true)); | |
| } | |
| /// 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>`. | |
| /// | |
| /// Throws if it can't find a matching line. | |
| /// | |
| /// The returned map entry is the uri (key) and the 1-indexed line number of | |
| /// the comment (value). | |
| /// Note that we often put the comment on the line *before* where we actually | |
| /// want the breakpoint, and that the value can thus be seen as being that | |
| /// line but then being 0-indexed. | |
| /// | |
| /// Adapted from webdev/blob/master/dwds/test/fixtures/context.dart. | |
| MapEntry<Uri, int> _findBreakpointLine(String breakpointId) { | |
| var lineNumber = _findBreakpointLineImpl(breakpointId, source); | |
| if (lineNumber >= 0) { | |
| return MapEntry(input, lineNumber + 1); | |
| } | |
| if (partSource != null) { | |
| lineNumber = _findBreakpointLineImpl(breakpointId, partSource!); | |
| if (lineNumber >= 0) { | |
| 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', | |
| ); | |
| } | |
| /// Finds the 0-indexed line number in [source] for the given breakpoint id. | |
| static int _findBreakpointLineImpl(String breakpointId, String source) { | |
| var lines = LineSplitter.split(source).toList(); | |
| return lines.indexWhere((l) => l.endsWith('// Breakpoint: $breakpointId')); | |
| } | |
| /// 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> _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 line $line.', | |
| ); | |
| } | |
| } | |
| /// The execution context in which to do remote evaluations. | |
| /// | |
| /// Copied and simplified from webdev/dwds/lib/src/debugging/execution_context.dart. | |
| class ExecutionContext { | |
| static const _nextContextTimeoutDuration = Duration(milliseconds: 100); | |
| final wip.WipRuntime _runtime; | |
| /// Contexts that may contain a Dart application. | |
| late StreamQueue<int> _contexts; | |
| int? _id; | |
| Future<int> get id async { | |
| if (_id != null) return _id!; | |
| while (await _contexts.hasNext.timeout( | |
| _nextContextTimeoutDuration, | |
| onTimeout: () => false, | |
| )) { | |
| final context = await _contexts.next; | |
| printOnFailure('Trying context: $context'); | |
| try { | |
| // Confirm the context belongs to a dart application. | |
| final result = await _runtime.evaluate( | |
| 'dartApplication', | |
| contextId: context, | |
| returnByValue: true, | |
| ); | |
| if (result.value != null) { | |
| printOnFailure('Found dart app context: $context'); | |
| _id = context; | |
| break; | |
| } | |
| } catch (_) { | |
| printOnFailure('Failed context: $context, trying again...'); | |
| } | |
| } | |
| if (_id == null) { | |
| throw StateError('No context with the running Dart application.'); | |
| } | |
| return _id!; | |
| } | |
| ExecutionContext(this._runtime) { | |
| final contextController = StreamController<int>(); | |
| _runtime.onExecutionContextsCleared.listen((_) => _id = null); | |
| _runtime.onExecutionContextDestroyed.listen((_) => _id = null); | |
| _runtime.onExecutionContextCreated.listen( | |
| (e) => contextController.add(e.id), | |
| ); | |
| _contexts = StreamQueue(contextController.stream); | |
| } | |
| } | |
| /// Filters the provided frame scopes to those that are pertinent for Dart | |
| /// debugging. | |
| /// | |
| /// Copied from webdev/dwds/lib/src/debugging/dart_scope.dart. | |
| List<wip.WipScope> filterScopes(wip.WipCallFrame frame) { | |
| var scopes = frame.getScopeChain().toList(); | |
| // Remove outer scopes up to and including the Dart SDK. | |
| while (scopes.isNotEmpty && | |
| !(scopes.last.name?.startsWith('load__') ?? false)) { | |
| scopes.removeLast(); | |
| } | |
| if (scopes.isNotEmpty) scopes.removeLast(); | |
| return scopes; | |
| } | |
| String escaped(String path) => path.replaceAll('\\', '\\\\'); | |
| Future setBreakpointsActive(wip.WipDebugger debugger, bool active) async { | |
| await debugger | |
| .sendCommand('Debugger.setBreakpointsActive', params: {'active': active}) | |
| .timeout( | |
| Duration(seconds: 5), | |
| onTimeout: (() => throw Exception('Unable to set breakpoint activity')), | |
| ); | |
| } | |
| /// The regexes used in dwds to filter out temp variables. | |
| /// Needs to be kept in sync in both repos. | |
| /// | |
| /// TODO(annagrin) - use an alternative way to identify | |
| /// synthetic variables. | |
| /// Issue: https://github.com/dart-lang/sdk/issues/44262 | |
| final _ddcTemporaryVariableRegExp = RegExp(r'^t(\$[0-9]*)+\w*$'); | |
| final _ddcTemporaryTypeVariableRegExp = RegExp(r'^__t[\$\w*]+$'); | |
| /// Records timing statistics from the test driver. | |
| /// | |
| /// A few steps in the test driver need to wait for a response from the browser. | |
| /// These are set up with a timeout of usually 5 seconds, but the total time may | |
| /// vary by machine and architecture. Occasionally tests fail with flaky | |
| /// failures due to a timeout that is too short. | |
| /// | |
| /// We use this class to help log information from flaky failures that can | |
| /// inform us whether the timeout is accurate and how often we are approaching | |
| /// it. | |
| /// | |
| /// The driver logic only watches a couple tasks, focusing on big parts of the | |
| /// framework or tasks that have historically hit timeouts in the CI bots. | |
| class TimeoutTracker { | |
| /// Stores data for each key. | |
| final _data = <String, List<int>>{}; | |
| /// Track how long an asynchronous [task] takes and record it under [key]. | |
| Future<T> _watch<T>(String key, Future<T> Function() task) { | |
| final watch = Stopwatch()..start(); | |
| return task().then((v) { | |
| _addOneRecord(key, watch.elapsedMilliseconds); | |
| return v; | |
| }); | |
| } | |
| /// Record under [key] a single event that took [milliseconds]. | |
| /// | |
| /// This makes an incremental update to the aggregate average, max, and count | |
| /// values in [_data]. | |
| void _addOneRecord(String key, int milliseconds) { | |
| (_data[key] ??= []).add(milliseconds); | |
| } | |
| /// Prints to stdout a summary of the data tracked so far. | |
| void _showReport() { | |
| print('Fine-grain timeout data:'); | |
| _data.forEach((key, values) { | |
| values.sort(); | |
| final total = values.length; | |
| final sum = values.reduce((a, b) => a + b); | |
| final max = values.last; | |
| final p50 = values[(values.length * 0.5).toInt()]; | |
| final p90 = values[(values.length * 0.9).toInt()]; | |
| final average = sum ~/ total; | |
| print( | |
| '$key: ' | |
| '${average}ms (avg), ' | |
| '${p50}ms (p50), ' | |
| '${p90}ms (p90), ' | |
| '${max}ms (max), ' | |
| '$total (total)', | |
| ); | |
| }); | |
| } | |
| } |