blob: d822c86a77379c157c538b9322feacd0e06e4b98 [file] [log] [blame]
// 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? _script;
Uri? inputPart;
late Uri output;
late Uri packagesFile;
late SetupCompilerOptions setup;
late String source;
String? partSource;
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);
} 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 sound and unsound 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 = compiler.metadataForLibraryUri(input).name;
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'}'
'${setup.soundNullSafety ? '' : '_unsound'}',
'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;
var sound = ${setup.soundNullSafety};
let sdkOptions = {};
if (sound) {
sdkOptions['nativeNonNullAsserts'] = true;
} else {
sdkOptions['weakNullSafetyWarnings'] = false;
sdkOptions['weakNullSafetyErrors'] = false;
sdkOptions['nonNullAsserts'] = true;
}
// Unlike the typical app bootstraper, we delay calling main until all
// breakpoints are setup.
let scheduleMain = () => {
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 sound = ${setup.soundNullSafety};
var sdk = dart_library.import('dart_sdk');
if (sound) {
sdk.dart.nativeNonNullAsserts(true);
} else {
sdk.dart.weakNullSafetyWarnings(false);
sdk.dart.weakNullSafetyErrors(false);
sdk.dart.nonNullAsserts(true);
}
// Unlike the typical app bootstraper, we delay calling main until all
// breakpoints are setup.
let scheduleMain = () => {
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'}'
'${setup.soundNullSafety ? '' : '_unsound'}',
'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;
};
var sound = ${setup.soundNullSafety};
require(['dart_sdk', '$moduleName'],
function(sdk, app) {
'use strict';
if (sound) {
sdk.dart.nativeNonNullAsserts(true);
} else {
sdk.dart.weakNullSafetyWarnings(false);
sdk.dart.weakNullSafetyErrors(false);
sdk.dart.nonNullAsserts(true);
}
scheduleMain = () => {
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}');
}
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<wip.WipScript> _loadScript() =>
tracker._watch('load-script', () => _loadScriptHelper());
Future<wip.WipScript> _loadScriptHelper() async {
if (_script != null) return Future.value(_script);
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 == '$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 the script is found, or timeout after a few seconds.
return _script = (await tracker._watch(
'find-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 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 resouces 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);
});
final script = 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);
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 '
'($dartLine -> $location) in $output.')));
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);
});
}
/// Evaluates a js [expression] on a breakpoint.
///
/// [breakpointId] is the ID of the breakpoint from the source.
Future<String> evaluateJsExpression({
required String breakpointId,
required String expression,
}) async {
return await _onBreakpoint(breakpointId, onPause: (event) async {
var result = await _evaluateJsExpression(
event,
expression,
);
return await stringifyRemoteObject(result);
});
}
/// 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 {
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!)));
}
});
}
/// 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 {
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!));
}
});
}
/// Evaluates a dart [expression] under the scope of [libraryUri] without
/// a breakpoint and validates the result.
///
/// When [libraryUri] is ommitted, 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] or [partSource] 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);
}
}
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 in Dart.
/// The input [dartLine] 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);
}
}
}
throw StateError(
'Unable to extract WIP Location from ${script.url} for Dart line '
'$dartLine.');
}
}
/// 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. Occationally 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 aggreagate 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)');
});
}
}