import 'dart:async'; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/utils/web_socket_connect.dart'; void main() { /// Accepts TCP connections and never answers the upgrade. late ServerSocket blackHole; final stalled = []; late StreamSubscription accepting; setUp(() async { blackHole = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); accepting = blackHole.listen(stalled.add); }); tearDown(() async { await accepting.cancel(); for (final socket in stalled) { socket.destroy(); } stalled.clear(); await blackHole.close(); }); Uri blackHoleUri() => Uri.parse('ws://${blackHole.address.address}:${blackHole.port}/ws'); Future peerSawClose(Socket peer) => peer.drain().timeout(const Duration(seconds: 5)); group('WebSocketConnectAttempt', () { test('same-turn cancellation settles without admitting a later connection', () async { final attempt = WebSocketConnectAttempt(blackHoleUri(), connectTimeout: const Duration(seconds: 30)); final result = expectLater(attempt.socket, throwsA(isA())); attempt.cancel(); await result.timeout(const Duration(seconds: 1)); await pumpEventQueue(); for (final peer in stalled) { await peerSawClose(peer); } }); test('a delayed factory result disposes its already-connected socket before registration', () async { final creation = Completer>(); final started = Completer(); final attempt = WebSocketConnectAttempt.withConnectionFactory( blackHoleUri(), connectTimeout: const Duration(seconds: 30), connectionFactory: (_, _, _) { started.complete(); return creation.future; }, ); addTearDown(attempt.cancel); final result = expectLater(attempt.socket, throwsA(isA())); await started.future; final outgoing = await Socket.connect(blackHole.address, blackHole.port); addTearDown(outgoing.destroy); await _waitFor(() => stalled.isNotEmpty); final peerClosed = peerSawClose(stalled.single); attempt.cancel(); await result; creation.complete(ConnectionTask.fromSocket(Future.value(outgoing), () {})); await peerClosed; }); for (final cancelFirst in [true, false]) { test( 'a registered task closes its late socket with cancel ${cancelFirst ? 'before' : 'after'} publication', () async { final connected = Completer(); final started = Completer(); final attempt = WebSocketConnectAttempt.withConnectionFactory( blackHoleUri(), connectTimeout: const Duration(seconds: 30), connectionFactory: (_, _, _) { started.complete(); return Future.value(ConnectionTask.fromSocket(connected.future, () {})); }, ); addTearDown(attempt.cancel); final result = expectLater(attempt.socket, throwsA(isA())); await started.future; await pumpEventQueue(); final outgoing = await Socket.connect(blackHole.address, blackHole.port); addTearDown(outgoing.destroy); await _waitFor(() => stalled.isNotEmpty); final peerClosed = peerSawClose(stalled.single); if (cancelFirst) attempt.cancel(); connected.complete(outgoing); if (!cancelFirst) attempt.cancel(); await result; await peerClosed; }, ); } test('late factory errors and synchronous task cancellation errors are consumed', () async { for (final failFactory in [true, false]) { final creation = Completer>(); final started = Completer(); final cancelled = Completer(); final attempt = WebSocketConnectAttempt.withConnectionFactory( blackHoleUri(), connectTimeout: const Duration(seconds: 30), connectionFactory: (_, _, _) { started.complete(); return creation.future; }, ); addTearDown(attempt.cancel); final result = expectLater(attempt.socket, throwsA(isA())); await started.future; attempt.cancel(); await result; if (failFactory) { creation.completeError(const SocketException('late factory failure')); } else { final connected = Completer.sync(); creation.complete( ConnectionTask.fromSocket(connected.future, () { connected.completeError(const SocketException('late task cancelled')); cancelled.complete(); }), ); await cancelled.future.timeout(const Duration(seconds: 1)); } // The test zone reports any uncaught discarded-future error. await pumpEventQueue(); } }); test('cancellation retains every transport requested before upgrade handoff', () async { final client = _PendingFactoryClient(2); final attempt = HttpOverrides.runZoned( () => WebSocketConnectAttempt.withConnectionFactory( blackHoleUri(), connectTimeout: const Duration(seconds: 30), connectionFactory: (_, _, _) async { final outgoing = await Socket.connect(blackHole.address, blackHole.port); addTearDown(outgoing.destroy); return ConnectionTask.fromSocket(Future.value(outgoing), () {}); }, ), createHttpClient: (_) => client, ); addTearDown(attempt.cancel); final result = expectLater(attempt.socket, throwsA(isA())); await client.created.future.timeout(const Duration(seconds: 5)); await _waitFor(() => stalled.length == 2); final closed = stalled.map(peerSawClose).toList(); attempt.cancel(); await result; await Future.wait(closed); }); test('cancel settles the socket at once and closes the stalled peer', () async { final attempt = WebSocketConnectAttempt(blackHoleUri(), connectTimeout: const Duration(seconds: 30)); await _waitFor(() => stalled.isNotEmpty); final peerClosed = peerSawClose(stalled.single); final result = expectLater(attempt.socket, throwsA(isA())); attempt.cancel(); attempt.cancel(); await result.timeout(const Duration(seconds: 1)); await peerClosed; }); test('the deadline fails the attempt with a TimeoutException and closes the peer', () async { final attempt = WebSocketConnectAttempt(blackHoleUri(), connectTimeout: const Duration(milliseconds: 200)); await _waitFor(() => stalled.isNotEmpty); final peerClosed = peerSawClose(stalled.single); await expectLater(attempt.socket, throwsA(isA())); await peerClosed; }); test('an upgrade landing after cancel is closed, not leaked', () async { final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); addTearDown(() => server.close(force: true)); final releaseUpgrade = Completer(); final requestArrived = Completer(); final lateSocketClosed = Completer(); server.listen((request) async { requestArrived.complete(); await releaseUpgrade.future; final socket = await WebSocketTransformer.upgrade(request); socket.listen((_) {}, onDone: socket.close); unawaited(socket.done.then((_) => lateSocketClosed.complete())); }); final attempt = WebSocketConnectAttempt( Uri.parse('ws://${server.address.address}:${server.port}/ws'), connectTimeout: const Duration(seconds: 30), ); final result = expectLater(attempt.socket, throwsA(isA())); await requestArrived.future.timeout(const Duration(seconds: 5)); attempt.cancel(); await result; releaseUpgrade.complete(); await lateSocketClosed.future.timeout( const Duration(seconds: 5), onTimeout: () => fail('late websocket was never closed by the client'), ); }); test('a delivered socket survives a later cancel', () async { final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); addTearDown(() => server.close(force: true)); final received = Completer(); server.listen((request) async { final socket = await WebSocketTransformer.upgrade(request); socket.listen((data) { received.complete(data); socket.close(); }); }); final attempt = WebSocketConnectAttempt( Uri.parse('ws://${server.address.address}:${server.port}/ws'), connectTimeout: const Duration(seconds: 30), ); final webSocket = await attempt.socket; addTearDown(webSocket.close); attempt.cancel(); webSocket.add('still here'); expect(await received.future.timeout(const Duration(seconds: 5)), 'still here'); }); test('redirected upgrade transfers ownership of the delivered transport', () async { final target = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); final redirect = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); addTearDown(() => target.close(force: true)); addTearDown(() => redirect.close(force: true)); target.listen((request) async { final socket = await WebSocketTransformer.upgrade(request); addTearDown(socket.close); socket.listen(socket.add, onDone: socket.close); }); redirect.listen((request) { unawaited(request.response.redirect(Uri.parse('http://${target.address.address}:${target.port}/ws'))); }); final attempt = WebSocketConnectAttempt.withConnectionFactory( Uri.parse('ws://${redirect.address.address}:${redirect.port}/ws'), connectTimeout: const Duration(seconds: 30), connectionFactory: (url, _, _) async { final socket = await Socket.connect(url.host, url.port); addTearDown(socket.destroy); return ConnectionTask.fromSocket(Future.value(socket), () {}); }, ); addTearDown(attempt.cancel); final socket = await attempt.socket.timeout(const Duration(seconds: 5)); addTearDown(socket.close); attempt.cancel(); attempt.cancel(); socket.add('after redirect and cancellation'); expect(await socket.first.timeout(const Duration(seconds: 5)), 'after redirect and cancellation'); }); test('a portless ws URL exchanges frames on port 80', () async { final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); addTearDown(() => server.close(force: true)); server.listen((request) async { final socket = await WebSocketTransformer.upgrade(request); addTearDown(socket.close); socket.listen(socket.add, onDone: socket.close); }); final parentZone = Zone.current; await IOOverrides.runZoned( () async { final attempt = WebSocketConnectAttempt( Uri.parse('ws://${server.address.address}/relay'), connectTimeout: const Duration(seconds: 5), ); addTearDown(attempt.cancel); final socket = await attempt.socket; addTearDown(socket.close); socket.add('portless relay'); expect(await socket.first.timeout(const Duration(seconds: 5)), 'portless relay'); }, socketStartConnect: (host, port, {sourceAddress, sourcePort = 0}) { // Route the default port to an isolated listener without binding port 80. if (port != 80) return Future(() => throw SocketException('No listener on port $port')); return parentZone.run(() => Socket.startConnect(host, server.port)); }, ); }); test('a portless wss URL negotiates TLS on port 443', () async { final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); addTearDown(server.close); final accepted = server.listen((socket) { addTearDown(socket.destroy); // A plaintext endpoint must fail TLS, not become an insecure WebSocket. socket.add('HTTP/1.1 400 Bad Request\r\n\r\n'.codeUnits); }); addTearDown(accepted.cancel); final parentZone = Zone.current; await IOOverrides.runZoned( () async { final attempt = WebSocketConnectAttempt( Uri.parse('wss://${server.address.address}/relay'), connectTimeout: const Duration(seconds: 5), ); addTearDown(attempt.cancel); await expectLater(attempt.socket, throwsA(isA())); }, socketStartConnect: (host, port, {sourceAddress, sourcePort = 0}) { // Only the HTTPS listener is reachable; port 0 fails before TLS. if (port != 443) return Future(() => throw SocketException('No listener on port $port')); return parentZone.run(() => Socket.startConnect(host, server.port)); }, ); }); }); group('connectWebSocketChannel', () { test('closing the sink before ready cancels the attempt', () async { final channel = connectWebSocketChannel(blackHoleUri(), connectTimeout: const Duration(seconds: 30)); await _waitFor(() => stalled.isNotEmpty); final peerClosed = peerSawClose(stalled.single); final ready = expectLater(channel.ready, throwsA(anything)); unawaited(channel.sink.close()); await ready.timeout(const Duration(seconds: 1)); await peerClosed; }); test('an established channel exchanges frames and closes normally', () async { final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); addTearDown(() => server.close(force: true)); server.listen((request) async { final socket = await WebSocketTransformer.upgrade(request); socket.listen(socket.add, onDone: socket.close); }); final channel = connectWebSocketChannel( Uri.parse('ws://${server.address.address}:${server.port}/ws'), connectTimeout: const Duration(seconds: 30), ); await channel.ready; channel.sink.add('echo'); expect(await channel.stream.first.timeout(const Duration(seconds: 5)), 'echo'); await channel.sink.close(); }); }); } Future _waitFor(bool Function() condition) async { final deadline = DateTime.now().add(const Duration(seconds: 5)); while (!condition()) { if (DateTime.now().isAfter(deadline)) fail('condition not met in time'); await Future.delayed(const Duration(milliseconds: 5)); } } /// Holds factory results in the SDK's pre-registration interval. Multiple /// invocations model redirect/proxy alternatives, but all transports are real /// loopback sockets and this client deliberately cannot clean them up. class _PendingFactoryClient implements HttpClient { _PendingFactoryClient(this.count); final int count; final created = Completer(); final _request = Completer(); late Future> Function(Uri, String?, int?) _factory; @override set connectionFactory(Future> Function(Uri, String?, int?)? value) { _factory = value!; } @override Future openUrl(String method, Uri url) async { await Future.wait([for (var i = 0; i < count; i++) _factory(url, null, null)]); created.complete(); return _request.future; } @override void close({bool force = false}) { if (!_request.isCompleted) _request.completeError(const SocketException('HTTP client closed')); } @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); }