diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml index 400bdd10df..0f53da75a6 100644 --- a/.github/workflows/dart.yml +++ b/.github/workflows/dart.yml @@ -70,26 +70,35 @@ jobs: if: "always() && steps.pkgs_http_client_conformance_tests_pub_upgrade.conclusion == 'success'" working-directory: pkgs/http_client_conformance_tests job_003: - name: "analyze_and_format; linux; Dart 3.2.6; PKG: pkgs/web_socket; `dart analyze --fatal-infos`" + name: "analyze_and_format; linux; Dart 3.3.0; PKGS: pkgs/http, pkgs/web_socket, pkgs/web_socket_conformance_tests; `dart analyze --fatal-infos`" runs-on: ubuntu-latest steps: - name: Cache Pub hosted dependencies uses: actions/cache@13aacd865c20de90d75de3b17ebe84f7a17d57d2 with: path: "~/.pub-cache/hosted" - key: "os:ubuntu-latest;pub-cache-hosted;sdk:3.2.6;packages:pkgs/web_socket;commands:analyze_1" + key: "os:ubuntu-latest;pub-cache-hosted;sdk:3.3.0;packages:pkgs/http-pkgs/web_socket-pkgs/web_socket_conformance_tests;commands:analyze_1" restore-keys: | - os:ubuntu-latest;pub-cache-hosted;sdk:3.2.6;packages:pkgs/web_socket - os:ubuntu-latest;pub-cache-hosted;sdk:3.2.6 + os:ubuntu-latest;pub-cache-hosted;sdk:3.3.0;packages:pkgs/http-pkgs/web_socket-pkgs/web_socket_conformance_tests + os:ubuntu-latest;pub-cache-hosted;sdk:3.3.0 os:ubuntu-latest;pub-cache-hosted os:ubuntu-latest - name: Setup Dart SDK uses: dart-lang/setup-dart@fedb1266e91cf51be2fdb382869461a434b920a3 with: - sdk: "3.2.6" + sdk: "3.3.0" - id: checkout name: Checkout repository uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 + - id: pkgs_http_pub_upgrade + name: pkgs/http; dart pub upgrade + run: dart pub upgrade + if: "always() && steps.checkout.conclusion == 'success'" + working-directory: pkgs/http + - name: "pkgs/http; dart analyze --fatal-infos" + run: dart analyze --fatal-infos + if: "always() && steps.pkgs_http_pub_upgrade.conclusion == 'success'" + working-directory: pkgs/http - id: pkgs_web_socket_pub_upgrade name: pkgs/web_socket; dart pub upgrade run: dart pub upgrade @@ -99,37 +108,16 @@ jobs: run: dart analyze --fatal-infos if: "always() && steps.pkgs_web_socket_pub_upgrade.conclusion == 'success'" working-directory: pkgs/web_socket - job_004: - name: "analyze_and_format; linux; Dart 3.3.0; PKG: pkgs/http; `dart analyze --fatal-infos`" - runs-on: ubuntu-latest - steps: - - name: Cache Pub hosted dependencies - uses: actions/cache@13aacd865c20de90d75de3b17ebe84f7a17d57d2 - with: - path: "~/.pub-cache/hosted" - key: "os:ubuntu-latest;pub-cache-hosted;sdk:3.3.0;packages:pkgs/http;commands:analyze_1" - restore-keys: | - os:ubuntu-latest;pub-cache-hosted;sdk:3.3.0;packages:pkgs/http - os:ubuntu-latest;pub-cache-hosted;sdk:3.3.0 - os:ubuntu-latest;pub-cache-hosted - os:ubuntu-latest - - name: Setup Dart SDK - uses: dart-lang/setup-dart@fedb1266e91cf51be2fdb382869461a434b920a3 - with: - sdk: "3.3.0" - - id: checkout - name: Checkout repository - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 - - id: pkgs_http_pub_upgrade - name: pkgs/http; dart pub upgrade + - id: pkgs_web_socket_conformance_tests_pub_upgrade + name: pkgs/web_socket_conformance_tests; dart pub upgrade run: dart pub upgrade if: "always() && steps.checkout.conclusion == 'success'" - working-directory: pkgs/http - - name: "pkgs/http; dart analyze --fatal-infos" + working-directory: pkgs/web_socket_conformance_tests + - name: "pkgs/web_socket_conformance_tests; dart analyze --fatal-infos" run: dart analyze --fatal-infos - if: "always() && steps.pkgs_http_pub_upgrade.conclusion == 'success'" - working-directory: pkgs/http - job_005: + if: "always() && steps.pkgs_web_socket_conformance_tests_pub_upgrade.conclusion == 'success'" + working-directory: pkgs/web_socket_conformance_tests + job_004: name: "analyze_and_format; linux; Dart 3.4.0-154.0.dev; PKG: pkgs/http_profile; `dart analyze --fatal-infos`" runs-on: ubuntu-latest steps: @@ -159,17 +147,17 @@ jobs: run: dart analyze --fatal-infos if: "always() && steps.pkgs_http_profile_pub_upgrade.conclusion == 'success'" working-directory: pkgs/http_profile - job_006: - name: "analyze_and_format; linux; Dart dev; PKGS: pkgs/http, pkgs/http_client_conformance_tests, pkgs/http_profile, pkgs/web_socket; `dart analyze --fatal-infos`" + job_005: + name: "analyze_and_format; linux; Dart dev; PKGS: pkgs/http, pkgs/http_client_conformance_tests, pkgs/http_profile, pkgs/web_socket, pkgs/web_socket_conformance_tests; `dart analyze --fatal-infos`" runs-on: ubuntu-latest steps: - name: Cache Pub hosted dependencies uses: actions/cache@13aacd865c20de90d75de3b17ebe84f7a17d57d2 with: path: "~/.pub-cache/hosted" - key: "os:ubuntu-latest;pub-cache-hosted;sdk:dev;packages:pkgs/http-pkgs/http_client_conformance_tests-pkgs/http_profile-pkgs/web_socket;commands:analyze_1" + key: "os:ubuntu-latest;pub-cache-hosted;sdk:dev;packages:pkgs/http-pkgs/http_client_conformance_tests-pkgs/http_profile-pkgs/web_socket-pkgs/web_socket_conformance_tests;commands:analyze_1" restore-keys: | - os:ubuntu-latest;pub-cache-hosted;sdk:dev;packages:pkgs/http-pkgs/http_client_conformance_tests-pkgs/http_profile-pkgs/web_socket + os:ubuntu-latest;pub-cache-hosted;sdk:dev;packages:pkgs/http-pkgs/http_client_conformance_tests-pkgs/http_profile-pkgs/web_socket-pkgs/web_socket_conformance_tests os:ubuntu-latest;pub-cache-hosted;sdk:dev os:ubuntu-latest;pub-cache-hosted os:ubuntu-latest @@ -216,17 +204,26 @@ jobs: run: dart analyze --fatal-infos if: "always() && steps.pkgs_web_socket_pub_upgrade.conclusion == 'success'" working-directory: pkgs/web_socket - job_007: - name: "analyze_and_format; linux; Dart dev; PKGS: pkgs/http, pkgs/http_client_conformance_tests, pkgs/http_profile, pkgs/web_socket; `dart format --output=none --set-exit-if-changed .`" + - id: pkgs_web_socket_conformance_tests_pub_upgrade + name: pkgs/web_socket_conformance_tests; dart pub upgrade + run: dart pub upgrade + if: "always() && steps.checkout.conclusion == 'success'" + working-directory: pkgs/web_socket_conformance_tests + - name: "pkgs/web_socket_conformance_tests; dart analyze --fatal-infos" + run: dart analyze --fatal-infos + if: "always() && steps.pkgs_web_socket_conformance_tests_pub_upgrade.conclusion == 'success'" + working-directory: pkgs/web_socket_conformance_tests + job_006: + name: "analyze_and_format; linux; Dart dev; PKGS: pkgs/http, pkgs/http_client_conformance_tests, pkgs/http_profile, pkgs/web_socket, pkgs/web_socket_conformance_tests; `dart format --output=none --set-exit-if-changed .`" runs-on: ubuntu-latest steps: - name: Cache Pub hosted dependencies uses: actions/cache@13aacd865c20de90d75de3b17ebe84f7a17d57d2 with: path: "~/.pub-cache/hosted" - key: "os:ubuntu-latest;pub-cache-hosted;sdk:dev;packages:pkgs/http-pkgs/http_client_conformance_tests-pkgs/http_profile-pkgs/web_socket;commands:format" + key: "os:ubuntu-latest;pub-cache-hosted;sdk:dev;packages:pkgs/http-pkgs/http_client_conformance_tests-pkgs/http_profile-pkgs/web_socket-pkgs/web_socket_conformance_tests;commands:format" restore-keys: | - os:ubuntu-latest;pub-cache-hosted;sdk:dev;packages:pkgs/http-pkgs/http_client_conformance_tests-pkgs/http_profile-pkgs/web_socket + os:ubuntu-latest;pub-cache-hosted;sdk:dev;packages:pkgs/http-pkgs/http_client_conformance_tests-pkgs/http_profile-pkgs/web_socket-pkgs/web_socket_conformance_tests os:ubuntu-latest;pub-cache-hosted;sdk:dev os:ubuntu-latest;pub-cache-hosted os:ubuntu-latest @@ -273,7 +270,16 @@ jobs: run: "dart format --output=none --set-exit-if-changed ." if: "always() && steps.pkgs_web_socket_pub_upgrade.conclusion == 'success'" working-directory: pkgs/web_socket - job_008: + - id: pkgs_web_socket_conformance_tests_pub_upgrade + name: pkgs/web_socket_conformance_tests; dart pub upgrade + run: dart pub upgrade + if: "always() && steps.checkout.conclusion == 'success'" + working-directory: pkgs/web_socket_conformance_tests + - name: "pkgs/web_socket_conformance_tests; dart format --output=none --set-exit-if-changed ." + run: "dart format --output=none --set-exit-if-changed ." + if: "always() && steps.pkgs_web_socket_conformance_tests_pub_upgrade.conclusion == 'success'" + working-directory: pkgs/web_socket_conformance_tests + job_007: name: "analyze_and_format; linux; Flutter stable; PKG: pkgs/flutter_http_example; `dart format --output=none --set-exit-if-changed .`" runs-on: ubuntu-latest steps: @@ -303,7 +309,7 @@ jobs: run: "dart format --output=none --set-exit-if-changed ." if: "always() && steps.pkgs_flutter_http_example_pub_upgrade.conclusion == 'success'" working-directory: pkgs/flutter_http_example - job_009: + job_008: name: "analyze_and_format; linux; Flutter stable; PKG: pkgs/flutter_http_example; `flutter analyze --fatal-infos`" runs-on: ubuntu-latest steps: @@ -333,7 +339,7 @@ jobs: run: flutter analyze --fatal-infos if: "always() && steps.pkgs_flutter_http_example_pub_upgrade.conclusion == 'success'" working-directory: pkgs/flutter_http_example - job_010: + job_009: name: "unit_test; linux; Dart 3.3.0; PKG: pkgs/http; `dart run --define=no_default_http_client=true test/no_default_http_client_test.dart`" runs-on: ubuntu-latest steps: @@ -372,8 +378,7 @@ jobs: - job_006 - job_007 - job_008 - - job_009 - job_011: + job_010: name: "unit_test; linux; Dart 3.3.0; PKG: pkgs/http; `dart test --platform chrome`" runs-on: ubuntu-latest steps: @@ -412,8 +417,7 @@ jobs: - job_006 - job_007 - job_008 - - job_009 - job_012: + job_011: name: "unit_test; linux; Dart 3.3.0; PKG: pkgs/http; `dart test --platform vm`" runs-on: ubuntu-latest steps: @@ -452,8 +456,7 @@ jobs: - job_006 - job_007 - job_008 - - job_009 - job_013: + job_012: name: "unit_test; linux; Dart 3.4.0-154.0.dev; PKG: pkgs/http_profile; `dart test --platform vm`" runs-on: ubuntu-latest steps: @@ -492,8 +495,7 @@ jobs: - job_006 - job_007 - job_008 - - job_009 - job_014: + job_013: name: "unit_test; linux; Dart dev; PKG: pkgs/http; `dart run --define=no_default_http_client=true test/no_default_http_client_test.dart`" runs-on: ubuntu-latest steps: @@ -532,8 +534,7 @@ jobs: - job_006 - job_007 - job_008 - - job_009 - job_015: + job_014: name: "unit_test; linux; Dart dev; PKG: pkgs/http; `dart test --platform chrome`" runs-on: ubuntu-latest steps: @@ -572,8 +573,7 @@ jobs: - job_006 - job_007 - job_008 - - job_009 - job_016: + job_015: name: "unit_test; linux; Dart dev; PKGS: pkgs/http, pkgs/http_profile; `dart test --platform vm`" runs-on: ubuntu-latest steps: @@ -621,8 +621,7 @@ jobs: - job_006 - job_007 - job_008 - - job_009 - job_017: + job_016: name: "unit_test; linux; Dart dev; PKG: pkgs/http; `dart test --test-randomize-ordering-seed=random -p chrome -c dart2wasm`" runs-on: ubuntu-latest steps: @@ -661,8 +660,7 @@ jobs: - job_006 - job_007 - job_008 - - job_009 - job_018: + job_017: name: "unit_test; linux; Flutter stable; PKG: pkgs/flutter_http_example; `flutter test --platform chrome`" runs-on: ubuntu-latest steps: @@ -701,8 +699,7 @@ jobs: - job_006 - job_007 - job_008 - - job_009 - job_019: + job_018: name: "unit_test; linux; Flutter stable; PKG: pkgs/flutter_http_example; `flutter test`" runs-on: ubuntu-latest steps: @@ -741,8 +738,7 @@ jobs: - job_006 - job_007 - job_008 - - job_009 - job_020: + job_019: name: "unit_test; macos; Flutter stable; PKG: pkgs/flutter_http_example; `flutter test`" runs-on: macos-latest steps: @@ -781,8 +777,7 @@ jobs: - job_006 - job_007 - job_008 - - job_009 - job_021: + job_020: name: "unit_test; windows; Flutter stable; PKG: pkgs/flutter_http_example; `flutter test`" runs-on: windows-latest steps: @@ -811,4 +806,3 @@ jobs: - job_006 - job_007 - job_008 - - job_009 diff --git a/pkgs/web_socket/README.md b/pkgs/web_socket/README.md index e43843ebc9..3e9c2d0146 100644 --- a/pkgs/web_socket/README.md +++ b/pkgs/web_socket/README.md @@ -1,2 +1,46 @@ -TODO: Put a short description of the package here that helps potential users -know whether this package might be useful for them. +[![pub package](https://img.shields.io/pub/v/web_socket.svg)](https://pub.dev/packages/web_socket) +[![package publisher](https://img.shields.io/pub/publisher/web_socket.svg)](https://pub.dev/packages/web_socket/publisher) + +Any easy-to-use library for communicating with WebSockets that has multiple +implementations. + +## Using + +```dart +import 'package:web_socket/io_web_socket.dart'; +import 'package:web_socket/web_socket.dart'; + +void main() async { + final socket = + await IOWebSocket.connect(Uri.parse('wss://ws.postman-echo.com/raw')); + + socket.events.listen((e) async { + switch (e) { + case TextDataReceived(text: final text): + print('Received Text: $text'); + await socket.close(); + case BinaryDataReceived(data: final data): + print('Received Binary: $data'); + case CloseReceived(code: final code, reason: final reason): + print('Connection to server closed: $code [$reason]'); + } + }); + + socket.sendText('Hello Dart WebSockets! 🎉'); +} +``` + +## Status: experimental + +**NOTE**: This package is currently experimental and published under the +[labs.dart.dev](https://dart.dev/dart-team-packages) pub publisher in order to +solicit feedback. + +For packages in the labs.dart.dev publisher we generally plan to either graduate +the package into a supported publisher (dart.dev, tools.dart.dev) after a period +of feedback and iteration, or discontinue the package. These packages have a +much higher expected rate of API and breaking changes. + +Your feedback is valuable and will help us evolve this package. For general +feedback, suggestions, and comments, please file an issue in the +[bug tracker](https://github.com/dart-lang/http/issues). diff --git a/pkgs/web_socket/example/web_socket_example.dart b/pkgs/web_socket/example/web_socket_example.dart index 6cb625c543..27ab4569c1 100644 --- a/pkgs/web_socket/example/web_socket_example.dart +++ b/pkgs/web_socket/example/web_socket_example.dart @@ -1,3 +1,41 @@ -void main() { - // TODO: add an example. +import 'dart:convert'; +import 'dart:io'; + +import 'package:web_socket/io_web_socket.dart'; +import 'package:web_socket/web_socket.dart'; + +const requestId = 305; + +/// Prints the US dollar value of Bitcoins continuously. +void main() async { + // Whitebit public WebSocket API documentation: + // https://docs.whitebit.com/public/websocket/ + final socket = + await IOWebSocket.connect(Uri.parse('wss://api.whitebit.com/ws')); + + socket.events.listen((e) { + switch (e) { + case TextDataReceived(text: final text): + final json = jsonDecode(text) as Map; + if (json['id'] == requestId) { + if (json['error'] != null) { + stderr.writeln('Failure: ${json['error']}'); + socket.close(); + } + } else { + final params = (json['params'] as List).cast>(); + print('₿1 = USD\$${params[0][2]}'); + } + case BinaryDataReceived(): + stderr.writeln('Unexpected binary response from server'); + socket.close(); + case CloseReceived(): + stderr.writeln('Connection to server closed'); + } + }); + socket.sendText(jsonEncode({ + 'id': requestId, + 'method': 'candles_subscribe', + 'params': ['BTC_USD', 5] + })); } diff --git a/pkgs/web_socket/lib/io_web_socket.dart b/pkgs/web_socket/lib/io_web_socket.dart new file mode 100644 index 0000000000..eaea4f06dc --- /dev/null +++ b/pkgs/web_socket/lib/io_web_socket.dart @@ -0,0 +1 @@ +export 'src/io_web_socket.dart' show IOWebSocket; diff --git a/pkgs/web_socket/lib/src/io_web_socket.dart b/pkgs/web_socket/lib/src/io_web_socket.dart new file mode 100644 index 0000000000..4141aaff4a --- /dev/null +++ b/pkgs/web_socket/lib/src/io_web_socket.dart @@ -0,0 +1,91 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io' as io; +import 'dart:typed_data'; + +import '../web_socket.dart'; + +/// A `dart-io`-based [WebSocket] implementation. +class IOWebSocket implements WebSocket { + final io.WebSocket _webSocket; + final _events = StreamController(); + + static Future connect(Uri uri) async { + try { + final webSocket = await io.WebSocket.connect(uri.toString()); + return IOWebSocket._(webSocket); + } on io.WebSocketException catch (e) { + throw WebSocketException(e.message); + } + } + + IOWebSocket._(this._webSocket) { + _webSocket.listen( + (event) { + switch (event) { + case String e: + _events.add(TextDataReceived(e)); + case List e: + _events.add(BinaryDataReceived(Uint8List.fromList(e))); + } + }, + onError: (Object e, StackTrace st) { + final wse = switch (e) { + io.WebSocketException(message: final message) => + WebSocketException(message), + _ => WebSocketException(e.toString()), + }; + _events.addError(wse, st); + }, + onDone: () { + if (!_events.isClosed) { + _events + ..add(CloseReceived( + _webSocket.closeCode, _webSocket.closeReason ?? '')) + ..close(); + } + }, + ); + } + + @override + void sendBytes(Uint8List b) { + if (_events.isClosed) { + throw StateError('WebSocket is closed'); + } + _webSocket.add(b); + } + + @override + void sendText(String s) { + if (_events.isClosed) { + throw StateError('WebSocket is closed'); + } + _webSocket.add(s); + } + + @override + Future close([int? code, String? reason]) async { + if (_events.isClosed) { + throw StateError('WebSocket is closed'); + } + + if (code != null) { + RangeError.checkValueInInterval(code, 3000, 4999, 'code'); + } + if (reason != null && utf8.encode(reason).length > 123) { + throw ArgumentError.value(reason, 'reason', + 'reason must be <= 123 bytes long when encoded as UTF-8'); + } + + unawaited(_events.close()); + try { + await _webSocket.close(code, reason); + } on io.WebSocketException catch (e) { + throw WebSocketException(e.message); + } + } + + @override + Stream get events => _events.stream; +} diff --git a/pkgs/web_socket/lib/src/web_socket.dart b/pkgs/web_socket/lib/src/web_socket.dart index ffc0a3844c..4109c37960 100644 --- a/pkgs/web_socket/lib/src/web_socket.dart +++ b/pkgs/web_socket/lib/src/web_socket.dart @@ -85,7 +85,28 @@ class WebSocketConnectionClosed extends WebSocketException { /// The interface for WebSocket connections. /// -/// TODO: insert a usage example. +/// ```dart +/// import 'package:web_socket/io_web_socket.dart'; +/// import 'package:web_socket/src/web_socket.dart'; +/// +/// void main() async { +/// final socket = +/// await IOWebSocket.connect(Uri.parse('wss://ws.postman-echo.com/raw')); +/// +/// socket.events.listen((e) async { +/// switch (e) { +/// case TextDataReceived(text: final text): +/// print('Received Text: $text'); +/// await socket.close(); +/// case BinaryDataReceived(data: final data): +/// print('Received Binary: $data'); +/// case CloseReceived(code: final code, reason: final reason): +/// print('Connection to server closed: $code [$reason]'); +/// } +/// }); +/// +/// socket.sendText('Hello Dart WebSockets! 🎉'); +/// } abstract interface class WebSocket { /// Sends text data to the connected peer. /// diff --git a/pkgs/web_socket/lib/web_socket.dart b/pkgs/web_socket/lib/web_socket.dart index b901ebc76a..b08a48fd61 100644 --- a/pkgs/web_socket/lib/web_socket.dart +++ b/pkgs/web_socket/lib/web_socket.dart @@ -1,4 +1 @@ -/// TODO: write this doc string. -library; - export 'src/web_socket.dart'; diff --git a/pkgs/web_socket/pubspec.yaml b/pkgs/web_socket/pubspec.yaml index 237da791ea..6ebe7bbed5 100644 --- a/pkgs/web_socket/pubspec.yaml +++ b/pkgs/web_socket/pubspec.yaml @@ -1,11 +1,14 @@ name: web_socket description: "TODO: enter a descirption here" -publish_to: none repository: https://github.com/dart-lang/http/tree/master/pkgs/web_socket +publish_to: none + environment: - sdk: ^3.2.6 + sdk: ^3.3.0 dev_dependencies: dart_flutter_team_lints: ^2.0.0 test: ^1.24.0 + web_socket_conformance_tests: + path: ../web_socket_conformance_tests/ diff --git a/pkgs/web_socket/test/io_web_socket_conformance_test.dart b/pkgs/web_socket/test/io_web_socket_conformance_test.dart new file mode 100644 index 0000000000..9b3728ddd2 --- /dev/null +++ b/pkgs/web_socket/test/io_web_socket_conformance_test.dart @@ -0,0 +1,14 @@ +// Copyright (c) 2024, 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. + +@TestOn('vm') +library; + +import 'package:test/test.dart'; +import 'package:web_socket/io_web_socket.dart'; +import 'package:web_socket_conformance_tests/web_socket_conformance_tests.dart'; + +void main() { + testAll((uri, {protocols}) => IOWebSocket.connect(uri)); +} diff --git a/pkgs/web_socket_conformance_tests/.gitattributes b/pkgs/web_socket_conformance_tests/.gitattributes new file mode 100644 index 0000000000..104d0ecaf9 --- /dev/null +++ b/pkgs/web_socket_conformance_tests/.gitattributes @@ -0,0 +1,2 @@ +lib/src/*_server_vm.dart linguist-generated=true +lib/src/*_server_web.dart linguist-generated=true diff --git a/pkgs/web_socket_conformance_tests/LICENSE b/pkgs/web_socket_conformance_tests/LICENSE new file mode 100644 index 0000000000..e5b2b46dcf --- /dev/null +++ b/pkgs/web_socket_conformance_tests/LICENSE @@ -0,0 +1,27 @@ +Copyright 2024, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/pkgs/web_socket_conformance_tests/README.md b/pkgs/web_socket_conformance_tests/README.md new file mode 100644 index 0000000000..50b514cd32 --- /dev/null +++ b/pkgs/web_socket_conformance_tests/README.md @@ -0,0 +1,35 @@ +[![pub package](https://img.shields.io/pub/v/web_socket_conformance_tests.svg)](https://pub.dev/packages/web_socket_conformance_tests) + +A library that tests whether implementations of `package:web_socket` +`WebSocket` behave as expected. + +This package is intended to be used in the tests of packages that implement +`package:web_socket` `Socket`. + +The tests work by starting a series of test servers and running the provided +`package:web_socket` `WebSocket` against them. + +## Usage + +`package:web_socket_conformance_tests` is meant to be used in the tests suite +of a `package:web_socket` `WebSocket` like: + +```dart +import 'package:web_socket/web_socket.dart'; +import 'package:test/test.dart'; + +import 'package:web_socket_conformance_tests/web_socket_conformance_tests.dart'; + +class MyWebSocket implements WebSocket { + // Your implementation here. +} + +void main() { + group('WebSocket conformance tests', () { + testAll(MyWebSocket()); + }); +} +``` + +**Note**: This package does not have it's own tests, instead it is +exercised by the tests in `package:web_socket`. diff --git a/pkgs/web_socket_conformance_tests/bin/generate_server_wrappers.dart b/pkgs/web_socket_conformance_tests/bin/generate_server_wrappers.dart new file mode 100644 index 0000000000..c9787b6495 --- /dev/null +++ b/pkgs/web_socket_conformance_tests/bin/generate_server_wrappers.dart @@ -0,0 +1,51 @@ +// Copyright (c) 2024, 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. + +/// Generates the '*_server_vm.dart' and '*_server_web.dart' support files. +library; + +import 'dart:core'; +import 'dart:io'; + +import 'package:dart_style/dart_style.dart'; + +const vm = '''// Generated by generate_server_wrappers.dart. Do not edit. + +import 'package:stream_channel/stream_channel.dart'; + +import ''; + +/// Starts the redirect test HTTP server in the same process. +Future> startServer() async { + final controller = StreamChannelController(sync: true); + hybridMain(controller.foreign); + return controller.local; +} +'''; + +const web = '''// Generated by generate_server_wrappers.dart. Do not edit. + +import 'package:stream_channel/stream_channel.dart'; +import 'package:test/test.dart'; + +/// Starts the redirect test HTTP server out-of-process. +Future> startServer() async => spawnHybridUri(Uri( + scheme: 'package', + path: 'web_socket_conformance_tests/src/')); +'''; + +void main() async { + final files = await Directory('lib/src').list().toList(); + final formatter = DartFormatter(); + + files.where((file) => file.path.endsWith('_server.dart')).forEach((file) { + final vmPath = file.path.replaceAll('_server.dart', '_server_vm.dart'); + File(vmPath).writeAsStringSync(formatter.format(vm.replaceAll( + '', file.uri.pathSegments.last))); + + final webPath = file.path.replaceAll('_server.dart', '_server_web.dart'); + File(webPath).writeAsStringSync(formatter.format(web.replaceAll( + '', file.uri.pathSegments.last))); + }); +} diff --git a/pkgs/web_socket_conformance_tests/example/client_test.dart b/pkgs/web_socket_conformance_tests/example/client_test.dart new file mode 100644 index 0000000000..ec3d01c17a --- /dev/null +++ b/pkgs/web_socket_conformance_tests/example/client_test.dart @@ -0,0 +1,29 @@ +import 'dart:typed_data'; + +import 'package:test/test.dart'; +import 'package:web_socket/web_socket.dart'; +import 'package:web_socket_conformance_tests/web_socket_conformance_tests.dart'; + +class MyWebSocketImplementation implements WebSocket { + static Future connect(Uri uri, + {Iterable? protocols}) async => + MyWebSocketImplementation(); + + @override + Future close([int? code, String? reason]) => throw UnimplementedError(); + + @override + Stream get events => throw UnimplementedError(); + + @override + void sendBytes(Uint8List b) => throw UnimplementedError(); + + @override + void sendText(String s) => throw UnimplementedError(); +} + +void main() { + group('client conformance tests', () { + testAll(MyWebSocketImplementation.connect); + }); +} diff --git a/pkgs/web_socket_conformance_tests/lib/src/close_local_server.dart b/pkgs/web_socket_conformance_tests/lib/src/close_local_server.dart new file mode 100644 index 0000000000..8991de3cc8 --- /dev/null +++ b/pkgs/web_socket_conformance_tests/lib/src/close_local_server.dart @@ -0,0 +1,33 @@ +// Copyright (c) 2024, 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:io'; + +import 'package:stream_channel/stream_channel.dart'; + +/// Starts an WebSocket server that waits for the peer to send a Close frame. +void hybridMain(StreamChannel channel) async { + late HttpServer server; + + server = (await HttpServer.bind('localhost', 0)) + ..listen((request) async { + final webSocket = await WebSocketTransformer.upgrade( + request, + ); + + webSocket.listen((event) { + channel.sink.add(event); + }, onDone: () { + webSocket.close(4123, 'server closed the connection'); + channel.sink.add(webSocket.closeCode); + channel.sink.add(webSocket.closeReason); + }); + }); + + channel.sink.add(server.port); + await channel + .stream.first; // Any writes indicates that the server should exit. + unawaited(server.close()); +} diff --git a/pkgs/web_socket_conformance_tests/lib/src/close_local_server_vm.dart b/pkgs/web_socket_conformance_tests/lib/src/close_local_server_vm.dart new file mode 100644 index 0000000000..c0d0652326 --- /dev/null +++ b/pkgs/web_socket_conformance_tests/lib/src/close_local_server_vm.dart @@ -0,0 +1,12 @@ +// Generated by generate_server_wrappers.dart. Do not edit. + +import 'package:stream_channel/stream_channel.dart'; + +import 'close_local_server.dart'; + +/// Starts the redirect test HTTP server in the same process. +Future> startServer() async { + final controller = StreamChannelController(sync: true); + hybridMain(controller.foreign); + return controller.local; +} diff --git a/pkgs/web_socket_conformance_tests/lib/src/close_local_server_web.dart b/pkgs/web_socket_conformance_tests/lib/src/close_local_server_web.dart new file mode 100644 index 0000000000..f7bb3810a6 --- /dev/null +++ b/pkgs/web_socket_conformance_tests/lib/src/close_local_server_web.dart @@ -0,0 +1,9 @@ +// Generated by generate_server_wrappers.dart. Do not edit. + +import 'package:stream_channel/stream_channel.dart'; +import 'package:test/test.dart'; + +/// Starts the redirect test HTTP server out-of-process. +Future> startServer() async => spawnHybridUri(Uri( + scheme: 'package', + path: 'web_socket_conformance_tests/src/close_local_server.dart')); diff --git a/pkgs/web_socket_conformance_tests/lib/src/close_local_tests.dart b/pkgs/web_socket_conformance_tests/lib/src/close_local_tests.dart new file mode 100644 index 0000000000..2fe27bb040 --- /dev/null +++ b/pkgs/web_socket_conformance_tests/lib/src/close_local_tests.dart @@ -0,0 +1,106 @@ +// Copyright (c) 2024, 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 'package:async/async.dart'; +import 'package:stream_channel/stream_channel.dart'; +import 'package:test/test.dart'; +import 'package:web_socket/web_socket.dart'; + +import 'close_local_server_vm.dart' + if (dart.library.html) 'close_local_server_web.dart'; + +/// Tests that the [WebSocket] can correctly close the connection to the peer. +void testCloseLocal( + Future Function(Uri uri, {Iterable? protocols}) + channelFactory) { + group('local close', () { + late Uri uri; + late StreamChannel httpServerChannel; + late StreamQueue httpServerQueue; + + setUp(() async { + httpServerChannel = await startServer(); + httpServerQueue = StreamQueue(httpServerChannel.stream); + uri = Uri.parse('ws://localhost:${await httpServerQueue.next}'); + }); + tearDown(() async { + httpServerChannel.sink.add(null); + }); + + test('reserved close code', () async { + final channel = await channelFactory(uri); + await expectLater(() => channel.close(1004), throwsA(isA())); + }); + + test('too long close reason', () async { + final channel = await channelFactory(uri); + await expectLater(() => channel.close(3000, 'a'.padLeft(124)), + throwsA(isA())); + }); + + test('close', () async { + final channel = await channelFactory(uri); + + await channel.close(); + final closeCode = await httpServerQueue.next as int?; + final closeReason = await httpServerQueue.next as String?; + + expect(closeCode, 1005); + expect(closeReason, ''); + expect(await channel.events.isEmpty, true); + }); + + test('with code 3000', () async { + final channel = await channelFactory(uri); + + await channel.close(3000); + final closeCode = await httpServerQueue.next as int?; + final closeReason = await httpServerQueue.next as String?; + + expect(closeCode, 3000); + expect(closeReason, ''); + expect(await channel.events.isEmpty, true); + }); + + test('with code 4999', () async { + final channel = await channelFactory(uri); + + await channel.close(4999); + final closeCode = await httpServerQueue.next as int?; + final closeReason = await httpServerQueue.next as String?; + + expect(closeCode, 4999); + expect(closeReason, ''); + expect(await channel.events.isEmpty, true); + }); + + test('with code and reason', () async { + final channel = await channelFactory(uri); + + await channel.close(3000, 'Client initiated closure'); + final closeCode = await httpServerQueue.next as int?; + final closeReason = await httpServerQueue.next as String?; + + expect(closeCode, 3000); + expect(closeReason, 'Client initiated closure'); + expect(await channel.events.isEmpty, true); + }); + + test('close after close', () async { + final channel = await channelFactory(uri); + + await channel.close(3000, 'Client initiated closure'); + + await expectLater( + () async => await channel.close(3001, 'Client initiated closure'), + throwsStateError); + final closeCode = await httpServerQueue.next as int?; + final closeReason = await httpServerQueue.next as String?; + + expect(closeCode, 3000); + expect(closeReason, 'Client initiated closure'); + expect(await channel.events.isEmpty, true); + }); + }); +} diff --git a/pkgs/web_socket_conformance_tests/lib/src/close_remote_server.dart b/pkgs/web_socket_conformance_tests/lib/src/close_remote_server.dart new file mode 100644 index 0000000000..9bbf84ed48 --- /dev/null +++ b/pkgs/web_socket_conformance_tests/lib/src/close_remote_server.dart @@ -0,0 +1,31 @@ +// Copyright (c) 2024, 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:io'; + +import 'package:stream_channel/stream_channel.dart'; + +/// Starts an WebSocket server that sends a Close frame after receiving any +/// data. +void hybridMain(StreamChannel channel) async { + late HttpServer server; + + server = (await HttpServer.bind('localhost', 0)) + ..listen((request) async { + final webSocket = await WebSocketTransformer.upgrade( + request, + ); + + webSocket.listen((event) { + channel.sink.add(event); + webSocket.close(4123, 'server closed the connection'); + }); + }); + + channel.sink.add(server.port); + await channel + .stream.first; // Any writes indicates that the server should exit. + unawaited(server.close()); +} diff --git a/pkgs/web_socket_conformance_tests/lib/src/close_remote_server_vm.dart b/pkgs/web_socket_conformance_tests/lib/src/close_remote_server_vm.dart new file mode 100644 index 0000000000..4cc6dba56e --- /dev/null +++ b/pkgs/web_socket_conformance_tests/lib/src/close_remote_server_vm.dart @@ -0,0 +1,12 @@ +// Generated by generate_server_wrappers.dart. Do not edit. + +import 'package:stream_channel/stream_channel.dart'; + +import 'close_remote_server.dart'; + +/// Starts the redirect test HTTP server in the same process. +Future> startServer() async { + final controller = StreamChannelController(sync: true); + hybridMain(controller.foreign); + return controller.local; +} diff --git a/pkgs/web_socket_conformance_tests/lib/src/close_remote_server_web.dart b/pkgs/web_socket_conformance_tests/lib/src/close_remote_server_web.dart new file mode 100644 index 0000000000..6e832bacac --- /dev/null +++ b/pkgs/web_socket_conformance_tests/lib/src/close_remote_server_web.dart @@ -0,0 +1,9 @@ +// Generated by generate_server_wrappers.dart. Do not edit. + +import 'package:stream_channel/stream_channel.dart'; +import 'package:test/test.dart'; + +/// Starts the redirect test HTTP server out-of-process. +Future> startServer() async => spawnHybridUri(Uri( + scheme: 'package', + path: 'web_socket_conformance_tests/src/close_remote_server.dart')); diff --git a/pkgs/web_socket_conformance_tests/lib/src/close_remote_tests.dart b/pkgs/web_socket_conformance_tests/lib/src/close_remote_tests.dart new file mode 100644 index 0000000000..647f74e27c --- /dev/null +++ b/pkgs/web_socket_conformance_tests/lib/src/close_remote_tests.dart @@ -0,0 +1,57 @@ +// Copyright (c) 2024, 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 'package:async/async.dart'; +import 'package:stream_channel/stream_channel.dart'; +import 'package:test/test.dart'; +import 'package:web_socket/web_socket.dart'; + +import 'close_remote_server_vm.dart' + if (dart.library.html) 'close_remote_server_web.dart'; + +/// Tests that the [WebSocket] can correctly receive Close frames from the peer. +void testCloseRemote( + Future Function(Uri uri, {Iterable? protocols}) + channelFactory) { + group('remote close', () { + late Uri uri; + late StreamChannel httpServerChannel; + late StreamQueue httpServerQueue; + + setUp(() async { + httpServerChannel = await startServer(); + httpServerQueue = StreamQueue(httpServerChannel.stream); + uri = Uri.parse('ws://localhost:${await httpServerQueue.next}'); + }); + tearDown(() async { + httpServerChannel.sink.add(null); + }); + + test('with code and reason', () async { + final channel = await channelFactory(uri); + + channel.sendText('Please close'); + expect(await channel.events.toList(), + [CloseReceived(4123, 'server closed the connection')]); + }); + + test('send after close received', () async { + final channel = await channelFactory(uri); + + channel.sendText('Please close'); + expect(await channel.events.toList(), + [CloseReceived(4123, 'server closed the connection')]); + expect(() => channel.sendText('test'), throwsStateError); + }); + + test('close after close received', () async { + final channel = await channelFactory(uri); + + channel.sendText('Please close'); + expect(await channel.events.toList(), + [CloseReceived(4123, 'server closed the connection')]); + await expectLater(channel.close, throwsStateError); + }); + }); +} diff --git a/pkgs/web_socket_conformance_tests/lib/src/disconnect_after_upgrade_server.dart b/pkgs/web_socket_conformance_tests/lib/src/disconnect_after_upgrade_server.dart new file mode 100644 index 0000000000..965521c439 --- /dev/null +++ b/pkgs/web_socket_conformance_tests/lib/src/disconnect_after_upgrade_server.dart @@ -0,0 +1,36 @@ +// Copyright (c) 2024, 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:crypto/crypto.dart'; +import 'package:stream_channel/stream_channel.dart'; + +const _webSocketGuid = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; + +/// Starts an WebSocket server that disconnects after WebSocket upgrade. +void hybridMain(StreamChannel channel) async { + late final HttpServer server; + server = (await HttpServer.bind('localhost', 0)) + ..listen((request) async { + var key = request.headers.value('Sec-WebSocket-Key'); + var digest = sha1.convert('$key$_webSocketGuid'.codeUnits); + var accept = base64.encode(digest.bytes); + request.response + ..statusCode = HttpStatus.switchingProtocols + ..headers.add(HttpHeaders.connectionHeader, 'Upgrade') + ..headers.add(HttpHeaders.upgradeHeader, 'websocket') + ..headers.add('Sec-WebSocket-Accept', accept); + request.response.contentLength = 0; + final socket = await request.response.detachSocket(); + socket.destroy(); + }); + + channel.sink.add(server.port); + + await channel + .stream.first; // Any writes indicates that the server should exit. + unawaited(server.close()); +} diff --git a/pkgs/web_socket_conformance_tests/lib/src/disconnect_after_upgrade_server_vm.dart b/pkgs/web_socket_conformance_tests/lib/src/disconnect_after_upgrade_server_vm.dart new file mode 100644 index 0000000000..0bc7426239 --- /dev/null +++ b/pkgs/web_socket_conformance_tests/lib/src/disconnect_after_upgrade_server_vm.dart @@ -0,0 +1,12 @@ +// Generated by generate_server_wrappers.dart. Do not edit. + +import 'package:stream_channel/stream_channel.dart'; + +import 'disconnect_after_upgrade_server.dart'; + +/// Starts the redirect test HTTP server in the same process. +Future> startServer() async { + final controller = StreamChannelController(sync: true); + hybridMain(controller.foreign); + return controller.local; +} diff --git a/pkgs/web_socket_conformance_tests/lib/src/disconnect_after_upgrade_server_web.dart b/pkgs/web_socket_conformance_tests/lib/src/disconnect_after_upgrade_server_web.dart new file mode 100644 index 0000000000..9e1a13771f --- /dev/null +++ b/pkgs/web_socket_conformance_tests/lib/src/disconnect_after_upgrade_server_web.dart @@ -0,0 +1,10 @@ +// Generated by generate_server_wrappers.dart. Do not edit. + +import 'package:stream_channel/stream_channel.dart'; +import 'package:test/test.dart'; + +/// Starts the redirect test HTTP server out-of-process. +Future> startServer() async => spawnHybridUri(Uri( + scheme: 'package', + path: + 'web_socket_conformance_tests/src/disconnect_after_upgrade_server.dart')); diff --git a/pkgs/web_socket_conformance_tests/lib/src/disconnect_after_upgrade_tests.dart b/pkgs/web_socket_conformance_tests/lib/src/disconnect_after_upgrade_tests.dart new file mode 100644 index 0000000000..16ccd68fa8 --- /dev/null +++ b/pkgs/web_socket_conformance_tests/lib/src/disconnect_after_upgrade_tests.dart @@ -0,0 +1,41 @@ +// Copyright (c) 2024, 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 'package:async/async.dart'; +import 'package:stream_channel/stream_channel.dart'; +import 'package:test/test.dart'; +import 'package:web_socket/web_socket.dart'; + +import 'disconnect_after_upgrade_server_vm.dart' + if (dart.library.html) 'disconnect_after_upgrade_server_web.dart'; + +/// Tests that the [WebSocket] generates a correct [CloseReceived] event if +/// the peer disconnects after WebSocket upgrade. +void testDisconnectAfterUpgrade( + Future Function(Uri uri, {Iterable? protocols}) + channelFactory) { + group('disconnect', () { + late final Uri uri; + late final StreamChannel httpServerChannel; + late final StreamQueue httpServerQueue; + + setUpAll(() async { + httpServerChannel = await startServer(); + httpServerQueue = StreamQueue(httpServerChannel.stream); + uri = Uri.parse('ws://localhost:${await httpServerQueue.next}'); + }); + tearDownAll(() => httpServerChannel.sink.add(null)); + + test('disconnect after upgrade', () async { + final channel = await channelFactory(uri); + channel.sendText('test'); + expect( + (await channel.events.single as CloseReceived).code, + anyOf([ + 1005, // closed no status + 1006, // closed abnormal + ])); + }); + }); +} diff --git a/pkgs/web_socket_conformance_tests/lib/src/echo_server.dart b/pkgs/web_socket_conformance_tests/lib/src/echo_server.dart new file mode 100644 index 0000000000..6728507a35 --- /dev/null +++ b/pkgs/web_socket_conformance_tests/lib/src/echo_server.dart @@ -0,0 +1,22 @@ +// Copyright (c) 2024, 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:io'; + +import 'package:stream_channel/stream_channel.dart'; + +/// Starts an WebSocket server that echos the payload of the request. +void hybridMain(StreamChannel channel) async { + late HttpServer server; + + server = (await HttpServer.bind('localhost', 0)) + ..transform(WebSocketTransformer()) + .listen((WebSocket webSocket) => webSocket.listen(webSocket.add)); + + channel.sink.add(server.port); + await channel + .stream.first; // Any writes indicates that the server should exit. + unawaited(server.close()); +} diff --git a/pkgs/web_socket_conformance_tests/lib/src/echo_server_vm.dart b/pkgs/web_socket_conformance_tests/lib/src/echo_server_vm.dart new file mode 100644 index 0000000000..a589cc0d1c --- /dev/null +++ b/pkgs/web_socket_conformance_tests/lib/src/echo_server_vm.dart @@ -0,0 +1,12 @@ +// Generated by generate_server_wrappers.dart. Do not edit. + +import 'package:stream_channel/stream_channel.dart'; + +import 'echo_server.dart'; + +/// Starts the redirect test HTTP server in the same process. +Future> startServer() async { + final controller = StreamChannelController(sync: true); + hybridMain(controller.foreign); + return controller.local; +} diff --git a/pkgs/web_socket_conformance_tests/lib/src/echo_server_web.dart b/pkgs/web_socket_conformance_tests/lib/src/echo_server_web.dart new file mode 100644 index 0000000000..b553554f69 --- /dev/null +++ b/pkgs/web_socket_conformance_tests/lib/src/echo_server_web.dart @@ -0,0 +1,9 @@ +// Generated by generate_server_wrappers.dart. Do not edit. + +import 'package:stream_channel/stream_channel.dart'; +import 'package:test/test.dart'; + +/// Starts the redirect test HTTP server out-of-process. +Future> startServer() async => spawnHybridUri(Uri( + scheme: 'package', + path: 'web_socket_conformance_tests/src/echo_server.dart')); diff --git a/pkgs/web_socket_conformance_tests/lib/src/no_upgrade_server.dart b/pkgs/web_socket_conformance_tests/lib/src/no_upgrade_server.dart new file mode 100644 index 0000000000..dec194186f --- /dev/null +++ b/pkgs/web_socket_conformance_tests/lib/src/no_upgrade_server.dart @@ -0,0 +1,22 @@ +// Copyright (c) 2024, 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:io'; +import 'package:stream_channel/stream_channel.dart'; + +/// Starts an WebSocket server that closes the HTTP connection before WebSocket +/// upgrade. +void hybridMain(StreamChannel channel) async { + final server = (await HttpServer.bind('localhost', 0)) + ..listen((request) async { + request.response.statusCode = 200; + await request.response.close(); + }); + channel.sink.add(server.port); + + await channel + .stream.first; // Any writes indicates that the server should exit. + unawaited(server.close()); +} diff --git a/pkgs/web_socket_conformance_tests/lib/src/no_upgrade_server_vm.dart b/pkgs/web_socket_conformance_tests/lib/src/no_upgrade_server_vm.dart new file mode 100644 index 0000000000..7f8cd5cf5a --- /dev/null +++ b/pkgs/web_socket_conformance_tests/lib/src/no_upgrade_server_vm.dart @@ -0,0 +1,12 @@ +// Generated by generate_server_wrappers.dart. Do not edit. + +import 'package:stream_channel/stream_channel.dart'; + +import 'no_upgrade_server.dart'; + +/// Starts the redirect test HTTP server in the same process. +Future> startServer() async { + final controller = StreamChannelController(sync: true); + hybridMain(controller.foreign); + return controller.local; +} diff --git a/pkgs/web_socket_conformance_tests/lib/src/no_upgrade_server_web.dart b/pkgs/web_socket_conformance_tests/lib/src/no_upgrade_server_web.dart new file mode 100644 index 0000000000..97409bc34a --- /dev/null +++ b/pkgs/web_socket_conformance_tests/lib/src/no_upgrade_server_web.dart @@ -0,0 +1,9 @@ +// Generated by generate_server_wrappers.dart. Do not edit. + +import 'package:stream_channel/stream_channel.dart'; +import 'package:test/test.dart'; + +/// Starts the redirect test HTTP server out-of-process. +Future> startServer() async => spawnHybridUri(Uri( + scheme: 'package', + path: 'web_socket_conformance_tests/src/no_upgrade_server.dart')); diff --git a/pkgs/web_socket_conformance_tests/lib/src/no_upgrade_tests.dart b/pkgs/web_socket_conformance_tests/lib/src/no_upgrade_tests.dart new file mode 100644 index 0000000000..c06955c70d --- /dev/null +++ b/pkgs/web_socket_conformance_tests/lib/src/no_upgrade_tests.dart @@ -0,0 +1,35 @@ +// Copyright (c) 2024, 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 'package:async/async.dart'; +import 'package:stream_channel/stream_channel.dart'; +import 'package:test/test.dart'; +import 'package:web_socket/web_socket.dart'; + +import 'no_upgrade_server_vm.dart' + if (dart.library.html) 'no_upgrade_server_web.dart'; + +/// Tests that the [WebSocket] generates the correct exception if the peer +/// closes the HTTP connection before WebSocket upgrade. +void testNoUpgrade( + Future Function(Uri uri, {Iterable? protocols}) + channelFactory) { + group('no upgrade', () { + late final Uri uri; + late final StreamChannel httpServerChannel; + late final StreamQueue httpServerQueue; + + setUpAll(() async { + httpServerChannel = await startServer(); + httpServerQueue = StreamQueue(httpServerChannel.stream); + uri = Uri.parse('ws://localhost:${await httpServerQueue.next}'); + }); + tearDownAll(() => httpServerChannel.sink.add(null)); + + test('close before upgrade', () async { + await expectLater( + () => channelFactory(uri), throwsA(isA())); + }); + }); +} diff --git a/pkgs/web_socket_conformance_tests/lib/src/payload_transfer_tests.dart b/pkgs/web_socket_conformance_tests/lib/src/payload_transfer_tests.dart new file mode 100644 index 0000000000..6b04b91bb5 --- /dev/null +++ b/pkgs/web_socket_conformance_tests/lib/src/payload_transfer_tests.dart @@ -0,0 +1,102 @@ +// Copyright (c) 2024, 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:typed_data'; + +import 'package:async/async.dart'; +import 'package:stream_channel/stream_channel.dart'; +import 'package:test/test.dart'; +import 'package:web_socket/web_socket.dart'; + +import 'echo_server_vm.dart' if (dart.library.html) 'echo_server_web.dart'; + +/// Tests that the [WebSocket] can correctly transmit and receive text +/// and binary payloads. +void testPayloadTransfer( + Future Function(Uri uri, {Iterable? protocols}) + webSocketFactory) { + group('payload transfer', () { + late Uri uri; + late StreamChannel httpServerChannel; + late StreamQueue httpServerQueue; + late WebSocket webSocket; + + setUp(() async { + httpServerChannel = await startServer(); + httpServerQueue = StreamQueue(httpServerChannel.stream); + uri = Uri.parse('ws://localhost:${await httpServerQueue.next}'); + webSocket = await webSocketFactory(uri); + }); + tearDown(() async { + httpServerChannel.sink.add(null); + await webSocket.close(); + }); + + test('empty string request and response', () async { + webSocket.sendText(''); + expect(await webSocket.events.first, TextDataReceived('')); + }); + + test('empty binary request and response', () async { + webSocket.sendBytes(Uint8List(0)); + expect(await webSocket.events.first, BinaryDataReceived(Uint8List(0))); + }); + + test('string request and response', () async { + webSocket.sendText('Hello World!'); + expect(await webSocket.events.first, TextDataReceived('Hello World!')); + }); + + test('binary request and response', () async { + webSocket.sendBytes(Uint8List.fromList([1, 2, 3, 4, 5])); + expect(await webSocket.events.first, + BinaryDataReceived(Uint8List.fromList([1, 2, 3, 4, 5]))); + }); + + test('large string request and response', () async { + final data = 'Hello World!' * 10000; + + webSocket.sendText(data); + expect(await webSocket.events.first, TextDataReceived(data)); + }); + + test('large binary request and response', () async { + final data = Uint8List(1000000); + data + ..fillRange(0, data.length ~/ 10, 1) + ..fillRange(0, data.length ~/ 10, 2) + ..fillRange(0, data.length ~/ 10, 3) + ..fillRange(0, data.length ~/ 10, 4) + ..fillRange(0, data.length ~/ 10, 5) + ..fillRange(0, data.length ~/ 10, 6) + ..fillRange(0, data.length ~/ 10, 7) + ..fillRange(0, data.length ~/ 10, 8) + ..fillRange(0, data.length ~/ 10, 9) + ..fillRange(0, data.length ~/ 10, 10); + + webSocket.sendBytes(data); + expect(await webSocket.events.first, BinaryDataReceived(data)); + }); + + test('non-ascii string request and response', () async { + webSocket.sendText('🎨⛳🌈'); + expect(await webSocket.events.first, TextDataReceived('🎨⛳🌈')); + }); + + test('alternative string and binary request and response', () async { + webSocket + ..sendBytes(Uint8List.fromList([1])) + ..sendText('Hello!') + ..sendBytes(Uint8List.fromList([1, 2])) + ..sendText('Hello World!'); + + expect(await webSocket.events.take(4).toList(), [ + BinaryDataReceived(Uint8List.fromList([1])), + TextDataReceived('Hello!'), + BinaryDataReceived(Uint8List.fromList([1, 2])), + TextDataReceived('Hello World!') + ]); + }); + }); +} diff --git a/pkgs/web_socket_conformance_tests/lib/src/peer_protocol_errors_server.dart b/pkgs/web_socket_conformance_tests/lib/src/peer_protocol_errors_server.dart new file mode 100644 index 0000000000..8760bb9a38 --- /dev/null +++ b/pkgs/web_socket_conformance_tests/lib/src/peer_protocol_errors_server.dart @@ -0,0 +1,37 @@ +// Copyright (c) 2024, 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:crypto/crypto.dart'; +import 'package:stream_channel/stream_channel.dart'; + +const _webSocketGuid = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; + +/// Starts an WebSocket server that sends invalid frames after completing the +/// WebSocket upgrade. +void hybridMain(StreamChannel channel) async { + late final HttpServer server; + server = (await HttpServer.bind('localhost', 0)) + ..listen((request) async { + var key = request.headers.value('Sec-WebSocket-Key'); + var digest = sha1.convert('$key$_webSocketGuid'.codeUnits); + var accept = base64.encode(digest.bytes); + request.response + ..statusCode = HttpStatus.switchingProtocols + ..headers.add(HttpHeaders.connectionHeader, 'Upgrade') + ..headers.add(HttpHeaders.upgradeHeader, 'websocket') + ..headers.add('Sec-WebSocket-Accept', accept); + request.response.contentLength = 0; + final socket = await request.response.detachSocket(); + socket.write('marry had a little lamb whose fleece was white as snow'); + }); + + channel.sink.add(server.port); + + await channel + .stream.first; // Any writes indicates that the server should exit. + unawaited(server.close()); +} diff --git a/pkgs/web_socket_conformance_tests/lib/src/peer_protocol_errors_server_vm.dart b/pkgs/web_socket_conformance_tests/lib/src/peer_protocol_errors_server_vm.dart new file mode 100644 index 0000000000..4996e3b6c2 --- /dev/null +++ b/pkgs/web_socket_conformance_tests/lib/src/peer_protocol_errors_server_vm.dart @@ -0,0 +1,12 @@ +// Generated by generate_server_wrappers.dart. Do not edit. + +import 'package:stream_channel/stream_channel.dart'; + +import 'peer_protocol_errors_server.dart'; + +/// Starts the redirect test HTTP server in the same process. +Future> startServer() async { + final controller = StreamChannelController(sync: true); + hybridMain(controller.foreign); + return controller.local; +} diff --git a/pkgs/web_socket_conformance_tests/lib/src/peer_protocol_errors_server_web.dart b/pkgs/web_socket_conformance_tests/lib/src/peer_protocol_errors_server_web.dart new file mode 100644 index 0000000000..361b02c30f --- /dev/null +++ b/pkgs/web_socket_conformance_tests/lib/src/peer_protocol_errors_server_web.dart @@ -0,0 +1,9 @@ +// Generated by generate_server_wrappers.dart. Do not edit. + +import 'package:stream_channel/stream_channel.dart'; +import 'package:test/test.dart'; + +/// Starts the redirect test HTTP server out-of-process. +Future> startServer() async => spawnHybridUri(Uri( + scheme: 'package', + path: 'web_socket_conformance_tests/src/peer_protocol_errors_server.dart')); diff --git a/pkgs/web_socket_conformance_tests/lib/src/peer_protocol_errors_tests.dart b/pkgs/web_socket_conformance_tests/lib/src/peer_protocol_errors_tests.dart new file mode 100644 index 0000000000..ba44f5122c --- /dev/null +++ b/pkgs/web_socket_conformance_tests/lib/src/peer_protocol_errors_tests.dart @@ -0,0 +1,52 @@ +// Copyright (c) 2024, 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 'package:async/async.dart'; +import 'package:stream_channel/stream_channel.dart'; +import 'package:test/test.dart'; +import 'package:web_socket/web_socket.dart'; + +import 'peer_protocol_errors_server_vm.dart' + if (dart.library.html) 'peer_protocol_errors_server_web.dart'; + +/// Tests that the [WebSocket] can correctly handle incorrect WebSocket frames. +void testPeerProtocolErrors( + Future Function(Uri uri, {Iterable? protocols}) + channelFactory) { + group('peer protocol errors', () { + late final Uri uri; + late final StreamChannel httpServerChannel; + late final StreamQueue httpServerQueue; + + setUpAll(() async { + httpServerChannel = await startServer(); + httpServerQueue = StreamQueue(httpServerChannel.stream); + uri = Uri.parse('ws://localhost:${await httpServerQueue.next}'); + }); + tearDownAll(() => httpServerChannel.sink.add(null)); + + test('bad data after upgrade', () async { + final channel = await channelFactory(uri); + expect( + (await channel.events.single as CloseReceived).code, + anyOf([ + 1002, // protocol error + 1005, // closed no status + 1006, // closed abnormal + ])); + }); + + test('bad data after upgrade with write', () async { + final channel = await channelFactory(uri); + channel.sendText('test'); + expect( + (await channel.events.single as CloseReceived).code, + anyOf([ + 1002, // protocol error + 1005, // closed no status + 1006, // closed abnormal + ])); + }); + }); +} diff --git a/pkgs/web_socket_conformance_tests/lib/web_socket_conformance_tests.dart b/pkgs/web_socket_conformance_tests/lib/web_socket_conformance_tests.dart new file mode 100644 index 0000000000..248fc3870a --- /dev/null +++ b/pkgs/web_socket_conformance_tests/lib/web_socket_conformance_tests.dart @@ -0,0 +1,23 @@ +// Copyright (c) 2024, 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 'package:web_socket/web_socket.dart'; +import 'src/close_local_tests.dart'; +import 'src/close_remote_tests.dart'; +import 'src/disconnect_after_upgrade_tests.dart'; +import 'src/no_upgrade_tests.dart'; +import 'src/payload_transfer_tests.dart'; +import 'src/peer_protocol_errors_tests.dart'; + +/// Runs the entire test suite against the given [WebSocket]. +void testAll( + Future Function(Uri uri, {Iterable? protocols}) + webSocketFactory) { + testCloseLocal(webSocketFactory); + testCloseRemote(webSocketFactory); + testDisconnectAfterUpgrade(webSocketFactory); + testNoUpgrade(webSocketFactory); + testPayloadTransfer(webSocketFactory); + testPeerProtocolErrors(webSocketFactory); +} diff --git a/pkgs/web_socket_conformance_tests/mono_pkg.yaml b/pkgs/web_socket_conformance_tests/mono_pkg.yaml new file mode 100644 index 0000000000..16e4e7a5f3 --- /dev/null +++ b/pkgs/web_socket_conformance_tests/mono_pkg.yaml @@ -0,0 +1,10 @@ +sdk: +- pubspec +- dev + +stages: +- analyze_and_format: + - analyze: --fatal-infos + - format: + sdk: + - dev diff --git a/pkgs/web_socket_conformance_tests/pubspec.yaml b/pkgs/web_socket_conformance_tests/pubspec.yaml new file mode 100644 index 0000000000..afb952f804 --- /dev/null +++ b/pkgs/web_socket_conformance_tests/pubspec.yaml @@ -0,0 +1,22 @@ +name: web_socket_conformance_tests +description: >- + A library that tests whether implementations of `package:web_socket`'s + `WebSocket` class behave as expected. +repository: https://github.com/dart-lang/http/tree/master/pkgs/web_socket_conformance_tests + +publish_to: none + +environment: + sdk: ^3.3.0 + +dependencies: + async: ^2.11.0 + crypto: ^3.0.3 + dart_style: ^2.3.4 + stream_channel: ^2.1.2 + test: ^1.24.0 + web_socket: + path: ../web_socket + +dev_dependencies: + dart_flutter_team_lints: ^2.0.0