blob: 29cd7f6a6338239a8b539ee2f24cd3424b3c974a [file] [edit]
// Copyright (c) 2026, 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';
import 'package:dwds/src/debugging/webkit_debugger.dart';
import 'package:test/test.dart';
import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart';
const retryInterval = Duration(milliseconds: 50);
/// Longer than any test runs to detect bad resends.
const longDuration = Duration(minutes: 1);
/// Reasonable timeout for an immediate event.
const shortDuration = Duration(seconds: 10);
void main() {
late FakeChrome chrome;
final debuggers = <WebkitDebugger>[];
setUp(() async {
chrome = await FakeChrome.start();
});
tearDown(() async {
for (final debugger in debuggers) {
await debugger.close();
}
debuggers.clear();
await chrome.close();
});
Future<WebkitDebugger> connect({
Duration enableRetryInterval = retryInterval,
Duration enableTimeout = const Duration(seconds: 30),
}) async {
final debugger = WebkitDebugger(
WipDebugger(await WipConnection.connect(chrome.url)),
enableRetryInterval: enableRetryInterval,
enableTimeout: enableTimeout,
);
debuggers.add(debugger);
return debugger;
}
/// Expects that no more `Debugger.enable` requests are sent.
Future<void> expectNoMoreRequests() async {
// Wait for requests that are already in flight.
await Future<void>.delayed(retryInterval);
final requests = chrome.enableRequests;
await Future<void>.delayed(retryInterval * 3);
expect(chrome.enableRequests, requests);
}
group('enable', () {
test('completes when Chrome responds', () async {
final debugger = await connect(
enableRetryInterval: const Duration(seconds: 30),
);
await debugger.enable();
expect(chrome.enableRequests, 1);
});
test('waits for a slow response to the first request', () async {
// Only respond to the first request after it has been resent.
chrome.onEnable = (request) {
if (request.count == 1) Timer(retryInterval * 5, request.respond);
};
final debugger = await connect();
await debugger.enable();
expect(chrome.enableRequests, greaterThan(1));
});
test('resends requests that are not answered', () async {
chrome.onEnable = (request) {
if (request.count > 1) request.respond();
};
final debugger = await connect();
await debugger.enable();
expect(chrome.enableRequests, greaterThan(1));
});
test('resends requests after reconnecting', () async {
// Requests sent on a closed connection never complete, so it must be
// resent on a new connection. Use a long retry interval so a reconnect
// happens before resending the request.
chrome.onEnable = (request) {
if (request.count == 1) {
unawaited(request.closeConnection());
} else {
request.respond();
}
};
final debugger = await connect(
enableRetryInterval: const Duration(milliseconds: 500),
);
await debugger.enable();
expect(chrome.connections, 2);
expect(chrome.enableRequests, 2);
});
test('throws errors from Chrome', () async {
chrome.onEnable = (request) => request.respond(error: 'Failed');
final debugger = await connect();
await expectLater(debugger.enable(), throwsA(isA<WipError>()));
await expectNoMoreRequests();
});
test('throws a TimeoutException if Chrome never responds', () async {
chrome.onEnable = (_) {};
final debugger = await connect(enableTimeout: retryInterval * 4);
await expectLater(debugger.enable(), throwsA(isA<TimeoutException>()));
expect(chrome.enableRequests, greaterThan(1));
await expectNoMoreRequests();
});
test('throws if the debugger is closed', () async {
final debugger = await connect();
await debugger.close();
await expectLater(debugger.enable(), throwsA(isA<WebSocketException>()));
expect(chrome.enableRequests, 0);
});
test('throws when the debugger is closed while waiting', () async {
final requested = Completer<void>();
chrome.onEnable = (_) => requested.complete();
final debugger = await connect(enableRetryInterval: longDuration);
final enabled = expectLater(
debugger.enable().timeout(shortDuration),
throwsA(isA<WebSocketException>()),
);
await requested.future;
await debugger.close();
await enabled;
});
test('throws when the connection is lost', () async {
final requested = Completer<void>();
chrome.onEnable = (_) => requested.complete();
final debugger = await connect(enableRetryInterval: longDuration);
final enabled = expectLater(
debugger.enable().timeout(shortDuration),
throwsA(isA<WebSocketException>()),
);
await requested.future;
// Like closing the tab: the connection is closed, and can't be
// reestablished.
await chrome.close();
await enabled;
});
});
group('close', () {
test('stops reconnecting', () async {
final reconnecting = Completer<void>();
final acceptReconnect = Completer<void>();
chrome
..onReconnect = () {
reconnecting.complete();
return acceptReconnect.future;
}
..onEnable = (request) => unawaited(request.closeConnection());
final debugger = await connect(enableRetryInterval: longDuration);
final enabled = expectLater(
debugger.enable().timeout(shortDuration),
throwsA(isA<WebSocketException>()),
);
final closed = debugger.onClose.first;
await reconnecting.future;
await debugger.close();
await enabled;
// The new connection is closed instead of being used.
acceptReconnect.complete();
await closed.timeout(shortDuration);
await chrome.connectionsClosed.timeout(shortDuration);
expect(chrome.connections, 2);
});
});
}
/// A fake Chrome DevTools Protocol endpoint.
///
/// Handles `Debugger.enable` requests with [onEnable], and responds to all
/// other commands immediately.
class FakeChrome {
final HttpServer _server;
final _sockets = <WebSocket>[];
final _socketsDone = <Future<void>>[];
var _connectionAttempts = 0;
Future<void>? _closed;
/// Handles a `Debugger.enable` request.
///
/// Responds immediately by default.
void Function(EnableRequest request) onEnable = (request) =>
request.respond();
/// Called for each connection attempt after the first one, which is
/// accepted once the returned future completes.
Future<void> Function()? onReconnect;
/// The number of `Debugger.enable` requests received.
int enableRequests = 0;
FakeChrome._(this._server) {
_server.listen((request) async {
if (_connectionAttempts++ > 0) await onReconnect?.call();
final socket = await WebSocketTransformer.upgrade(request);
final done = Completer<void>();
_sockets.add(socket);
_socketsDone.add(done.future);
socket.listen((data) {
final command = jsonDecode(data as String) as Map<String, Object?>;
final id = command['id'] as int;
if (command['method'] == 'Debugger.enable') {
onEnable(EnableRequest._(++enableRequests, socket, id));
} else {
socket.add(jsonEncode({'id': id, 'result': <String, Object?>{}}));
}
}, onDone: done.complete);
});
}
static Future<FakeChrome> start() async =>
FakeChrome._(await HttpServer.bind(InternetAddress.loopbackIPv4, 0));
String get url => 'ws://${_server.address.address}:${_server.port}';
/// The number of connections made to this endpoint.
int get connections => _sockets.length;
/// Completes when all connections made so far are closed.
Future<void> get connectionsClosed => Future.wait(_socketsDone);
/// Closes all connections, and stops accepting new ones.
Future<void> close() => _closed ??= () async {
await Future.wait([for (final socket in _sockets) socket.close()]);
await _server.close(force: true);
}();
}
/// A `Debugger.enable` request received by [FakeChrome].
class EnableRequest {
/// The number of `Debugger.enable` requests received so far, including this
/// one.
final int count;
final WebSocket _socket;
final int _id;
EnableRequest._(this.count, this._socket, this._id);
/// Responds to this request, with an error response if [error] is given.
void respond({String? error}) {
_socket.add(
jsonEncode({
'id': _id,
if (error == null)
'result': {'debuggerId': 'fake-debugger-id'}
else
'error': {'code': -32000, 'message': error},
}),
);
}
/// Closes the connection this request was received on.
Future<void> closeConnection() => _socket.close();
}