blob: d800e97a758948a925ad811121f1725ca0be64ae [file] [log] [blame]
// Copyright 2013 The Flutter Authors. 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' show json;
import 'dart:js_interop';
import 'dart:math' as math;
import 'package:web/web.dart';
import 'src/common.dart';
import 'src/recorder.dart';
export 'src/recorder.dart';
/// Signature for a function that creates a [Recorder].
typedef RecorderFactory = Recorder Function();
/// List of all benchmarks that run in the devicelab.
///
/// When adding a new benchmark, add it to this map. Make sure that the name
/// of your benchmark is unique.
late Map<String, RecorderFactory> _benchmarks;
final LocalBenchmarkServerClient _client = LocalBenchmarkServerClient();
/// Starts a local benchmark client to run [benchmarks].
///
/// Usually used in combination with a benchmark server, which orders the
/// client to run each benchmark in order.
///
/// When used without a server, prompts the user to select a benchmark to
/// run next.
Future<void> runBenchmarks(
Map<String, RecorderFactory> benchmarks, {
String initialPage = defaultInitialPage,
}) async {
// Set local benchmarks.
_benchmarks = benchmarks;
// Check if the benchmark server wants us to run a specific benchmark.
final String nextBenchmark = await _client.requestNextBenchmark();
if (nextBenchmark == LocalBenchmarkServerClient.kManualFallback) {
_fallbackToManual(
'The server did not tell us which benchmark to run next.');
return;
}
await _runBenchmark(nextBenchmark);
final Uri currentUri = Uri.parse(window.location.href);
// Create a new URI with the current 'page' value set to [initialPage] to
// ensure the benchmark app is reloaded at the proper location.
final String newUri = Uri(
scheme: currentUri.scheme,
host: currentUri.host,
port: currentUri.port,
path: initialPage,
).toString();
// Reloading the window will trigger the next benchmark to run.
await _client.printToConsole(
'Client preparing to reload the window to: "$newUri"',
);
window.location.replace(newUri);
}
Future<void> _runBenchmark(String? benchmarkName) async {
final RecorderFactory? recorderFactory = _benchmarks[benchmarkName];
if (recorderFactory == null) {
_fallbackToManual('Benchmark $benchmarkName not found.');
return;
}
await runZoned<Future<void>>(
() async {
final Recorder recorder = recorderFactory();
final Runner runner = recorder.isTracingEnabled && !_client.isInManualMode
? Runner(
recorder: recorder,
setUpAllDidRun: () =>
_client.startPerformanceTracing(benchmarkName),
tearDownAllWillRun: _client.stopPerformanceTracing,
)
: Runner(recorder: recorder);
final Profile profile = await runner.run();
if (!_client.isInManualMode) {
await _client.sendProfileData(profile);
} else {
_printResultsToScreen(profile);
print(profile); // ignore: avoid_print
}
},
zoneSpecification: ZoneSpecification(
print: (Zone self, ZoneDelegate parent, Zone zone, String line) async {
if (_client.isInManualMode) {
parent.print(zone, '[$benchmarkName] $line');
} else {
await _client.printToConsole(line);
}
},
handleUncaughtError: (
Zone self,
ZoneDelegate parent,
Zone zone,
Object error,
StackTrace stackTrace,
) async {
if (_client.isInManualMode) {
parent.print(zone, '[$benchmarkName] $error, $stackTrace');
parent.handleUncaughtError(zone, error, stackTrace);
} else {
await _client.reportError(error, stackTrace);
}
},
),
);
}
void _fallbackToManual(String error) {
document.body!.appendHtml('''
<div id="manual-panel">
<h3>$error</h3>
<p>Choose one of the following benchmarks:</p>
<!-- Absolutely position it so it receives the clicks and not the glasspane -->
<ul style="position: absolute">
${_benchmarks.keys.map((String name) => '<li><button id="$name">$name</button></li>').join('\n')}
</ul>
</div>
''');
for (final String benchmarkName in _benchmarks.keys) {
// Find the button elements added above.
final Element button = document.querySelector('#$benchmarkName')!;
button.addEventListener(
'click',
(JSAny? arg) {
final Element? manualPanel = document.querySelector('#manual-panel');
manualPanel?.remove();
_runBenchmark(benchmarkName);
}.toJS);
}
}
/// Visualizes results on the Web page for manual inspection.
void _printResultsToScreen(Profile profile) {
final HTMLBodyElement body = document.body! as HTMLBodyElement;
body.innerHTML = '<h2>${profile.name}</h2>';
profile.scoreData.forEach((String scoreKey, Timeseries timeseries) {
body.appendHtml('<h2>$scoreKey</h2>');
body.appendHtml('<pre>${timeseries.computeStats()}</pre>');
body.appendChild(TimeseriesVisualization(timeseries).render());
});
}
/// Draws timeseries data and statistics on a canvas.
class TimeseriesVisualization {
/// Creates a visualization for a [Timeseries].
TimeseriesVisualization(this._timeseries) {
_stats = _timeseries.computeStats();
_canvas = HTMLCanvasElement();
_screenWidth = window.screen.width;
_canvas.width = _screenWidth;
_canvas.height = (_kCanvasHeight * window.devicePixelRatio).round();
_canvas.style
..width = '100%'
..height = '${_kCanvasHeight}px'
..outline = '1px solid green';
_ctx = _canvas.context2D;
// The amount of vertical space available on the chart. Because some
// outliers can be huge they can dwarf all the useful values. So we
// limit it to 1.5 x the biggest non-outlier.
_maxValueChartRange = 1.5 *
_stats.samples
.where((AnnotatedSample sample) => !sample.isOutlier)
.map<double>((AnnotatedSample sample) => sample.magnitude)
.fold<double>(0, math.max);
}
static const double _kCanvasHeight = 200;
final Timeseries _timeseries;
late TimeseriesStats _stats;
late HTMLCanvasElement _canvas;
late CanvasRenderingContext2D _ctx;
late int _screenWidth;
// Used to normalize benchmark values to chart height.
late double _maxValueChartRange;
/// Converts a sample value to vertical canvas coordinates.
///
/// This does not work for horizontal coordinates.
double _normalized(double value) {
return _kCanvasHeight * value / _maxValueChartRange;
}
/// A utility for drawing lines.
void drawLine(num x1, num y1, num x2, num y2) {
_ctx.beginPath();
_ctx.moveTo(x1, y1);
_ctx.lineTo(x2, y2);
_ctx.stroke();
}
/// Renders the timeseries into a `<canvas>` and returns the canvas element.
HTMLCanvasElement render() {
_ctx.translate(0, _kCanvasHeight * window.devicePixelRatio);
_ctx.scale(1, -window.devicePixelRatio);
final double barWidth = _screenWidth / _stats.samples.length;
double xOffset = 0;
for (int i = 0; i < _stats.samples.length; i++) {
final AnnotatedSample sample = _stats.samples[i];
if (sample.isWarmUpValue) {
// Put gray background behind warm-up samples.
_ctx.fillStyle = 'rgba(200,200,200,1)'.toJS;
_ctx.fillRect(xOffset, 0, barWidth, _normalized(_maxValueChartRange));
}
if (sample.magnitude > _maxValueChartRange) {
// The sample value is so big it doesn't fit on the chart. Paint it purple.
_ctx.fillStyle = 'rgba(100,50,100,0.8)'.toJS;
} else if (sample.isOutlier) {
// The sample is an outlier, color it light red.
_ctx.fillStyle = 'rgba(255,50,50,0.6)'.toJS;
} else {
// A non-outlier sample, color it light blue.
_ctx.fillStyle = 'rgba(50,50,255,0.6)'.toJS;
}
_ctx.fillRect(xOffset, 0, barWidth - 1, _normalized(sample.magnitude));
xOffset += barWidth;
}
// Draw a horizontal solid line corresponding to the average.
_ctx.lineWidth = 1;
drawLine(0, _normalized(_stats.average), _screenWidth,
_normalized(_stats.average));
// Draw a horizontal dashed line corresponding to the outlier cut off.
_ctx.setLineDash(<JSNumber>[5.toJS, 5.toJS].toJS);
drawLine(0, _normalized(_stats.outlierCutOff), _screenWidth,
_normalized(_stats.outlierCutOff));
// Draw a light red band that shows the noise (1 stddev in each direction).
_ctx.fillStyle = 'rgba(255,50,50,0.3)'.toJS;
_ctx.fillRect(
0,
_normalized(_stats.average * (1 - _stats.noise)),
_screenWidth,
_normalized(2 * _stats.average * _stats.noise),
);
return _canvas;
}
}
/// Implements the client REST API for the local benchmark server.
///
/// The local server is optional. If it is not available the benchmark UI must
/// implement a manual fallback. This allows debugging benchmarks using plain
/// `flutter run`.
class LocalBenchmarkServerClient {
/// This value is returned by [requestNextBenchmark].
static const String kManualFallback = '__manual_fallback__';
/// Whether we fell back to manual mode.
///
/// This happens when you run benchmarks using plain `flutter run` rather than
/// devicelab test harness. The test harness spins up a special server that
/// provides API for automatically picking the next benchmark to run.
late bool isInManualMode;
/// Asks the local server for the name of the next benchmark to run.
///
/// Returns [kManualFallback] if local server is not available (uses 404 as a
/// signal).
Future<String> requestNextBenchmark() async {
final XMLHttpRequest request = await _requestXhr(
'/next-benchmark',
method: 'POST',
mimeType: 'application/json',
sendData: json.encode(_benchmarks.keys.toList()),
);
// `kEndOfBenchmarks` is expected when the benchmark server is telling us there are no more benchmarks to run.
// 404 is expected when the benchmark is run using plain `flutter run`, which does not provide "next-benchmark" handler.
if (request.responseText == kEndOfBenchmarks || request.status == 404) {
isInManualMode = true;
return kManualFallback;
}
isInManualMode = false;
return request.responseText;
}
void _checkNotManualMode() {
if (isInManualMode) {
throw StateError('Operation not supported in manual fallback mode.');
}
}
/// Asks the local server to begin tracing performance.
///
/// This uses the chrome://tracing tracer, which is not available from within
/// the page itself, and therefore must be controlled from outside using the
/// DevTools Protocol.
Future<void> startPerformanceTracing(String? benchmarkName) async {
_checkNotManualMode();
await _requestXhr(
'/start-performance-tracing?label=$benchmarkName',
method: 'POST',
mimeType: 'application/json',
);
}
/// Stops the performance tracing session started by [startPerformanceTracing].
Future<void> stopPerformanceTracing() async {
_checkNotManualMode();
await _requestXhr(
'/stop-performance-tracing',
method: 'POST',
mimeType: 'application/json',
);
}
/// Sends the profile data collected by the benchmark to the local benchmark
/// server.
Future<void> sendProfileData(Profile profile) async {
_checkNotManualMode();
final XMLHttpRequest request = await _requestXhr(
'/profile-data',
method: 'POST',
mimeType: 'application/json',
sendData: json.encode(profile.toJson()),
);
if (request.status != 200) {
throw Exception('Failed to report profile data to benchmark server. '
'The server responded with status code ${request.status}.');
}
}
/// Reports an error to the benchmark server.
///
/// The server will halt the devicelab task and log the error.
Future<void> reportError(dynamic error, StackTrace stackTrace) async {
_checkNotManualMode();
await _requestXhr(
'/on-error',
method: 'POST',
mimeType: 'application/json',
sendData: json.encode(<String, dynamic>{
'error': '$error',
'stackTrace': '$stackTrace',
}),
);
}
/// Reports a message about the demo to the benchmark server.
Future<void> printToConsole(String report) async {
_checkNotManualMode();
await _requestXhr(
'/print-to-console',
method: 'POST',
mimeType: 'text/plain',
sendData: report,
);
}
/// This is the same as calling [XMLHttpRequest.request] but it doesn't
/// crash on 404, which we use to detect `flutter run`.
Future<XMLHttpRequest> _requestXhr(
String url, {
required String method,
required String mimeType,
String? sendData,
}) {
final Completer<XMLHttpRequest> completer = Completer<XMLHttpRequest>();
final XMLHttpRequest xhr = XMLHttpRequest();
xhr.open(method, url, true);
xhr.overrideMimeType(mimeType);
xhr.onLoad.listen((ProgressEvent e) {
completer.complete(xhr);
});
xhr.onError.listen(completer.completeError);
if (sendData != null) {
xhr.send(sendData.toJS);
} else {
xhr.send();
}
return completer.future;
}
}
extension on HTMLElement {
void appendHtml(String input) => insertAdjacentHTML('beforeend', input);
}