From 617ec02090db657867723d173e5a46aff10cc2d9 Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 30 May 2026 22:59:23 +0900 Subject: [PATCH] feat: dart web crosstest and related test infrastructure updates --- README.md | 16 +-- agent-ops/rules/project/rules.md | 2 +- .../run-proto-socket-test-matrix/SKILL.md | 26 +++-- .../scripts/run_matrix.sh | 32 +++++- .../milestones/continuous-verification.md | 2 +- .../milestones/stability-baseline.md | 4 +- dart/crosstest/dart_web.dart | 89 ++++++++++----- dart/test/browser_ws_dart_io_test.dart | 6 + dart/test/browser_ws_kotlin_test.dart | 6 + dart/test/browser_ws_python_test.dart | 6 + dart/test/browser_ws_runtime_helper.dart | 15 ++- dart/test/browser_ws_runtime_test.dart | 6 + dart/test/browser_ws_typescript_test.dart | 6 + go/crosstest/go_dart_web.go | 82 +++++++++++--- go/ws_server.go | 6 + kotlin/crosstest/kotlin_dart_web.kt | 104 +++++++++++++++--- python/crosstest/python_dart_web.py | 69 ++++++++++-- typescript/CROSSTEST_CHECKLIST.md | 2 +- typescript/crosstest/typescript_dart_web.ts | 90 +++++++++++---- 19 files changed, 440 insertions(+), 129 deletions(-) diff --git a/README.md b/README.md index cebe058..c1f34a2 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,7 @@ func main() { Go 구현체는 다음 표면도 제공한다. - TCP: `NewTcpServer`, `DialTcp`, `NewTcpServerTLS`, `DialTcpTLS` -- WebSocket: `NewWsServer`, `DialWs`, `NewWsServerTLS`, `DialWss` +- WebSocket: `NewWsServer`, `NewWsServerWithOptions`, `DialWs`, `NewWsServerTLS`, `NewWsServerTLSWithOptions`, `DialWss` - Shared helpers: `Send`, `SendRequest`, `AddListenerTyped`, `AddRequestListenerTyped`, `Broadcast` 다른 저장소에서 이 모듈을 사용하는 복사 가능한 scaffold가 필요하면 [examples/go-module-consumer/](examples/go-module-consumer/)를 본다. @@ -309,17 +309,17 @@ cd typescript ### Dart.io와 Dart.web E2E 매트릭스 -위 표의 `Dart` 열은 Dart VM/IO client를 뜻한다. `Dart.web`은 별도 축이다. 브라우저 WS/WSS client만 가능하며, 브라우저에서는 TCP와 server 역할이 불가능하므로 해당 cell은 `N/A`다. Dart.web WSS는 self-signed test certificate로 자동화할 수 없는 항목이 아니다. 현재 남은 coverage gap은 브라우저가 테스트용 self-signed CA/certificate를 신뢰하도록 만드는 자동화와 그에 따른 Dart.web WSS 매트릭스 편입이다. +위 표의 `Dart` 열은 Dart VM/IO client를 뜻한다. `Dart.web`은 별도 축이다. 브라우저 WS/WSS client만 가능하며, 브라우저에서는 TCP와 server 역할이 불가능하므로 해당 cell은 `N/A`다. Dart.web WSS는 테스트용 self-signed certificate의 SPKI를 허용하는 Chrome 실행 wrapper로 local/remote 브라우저 검증에 포함한다. | Server | Dart.io TCP | Dart.io WS | Dart.io TLS TCP | Dart.io WSS | Dart.web WS | Dart.web WSS | Dart.web TCP | |---|---|---|---|---|---|---|---| -| Dart.io | Covered | Covered | Covered | Covered | Covered | Required gap | N/A | -| Go | Covered | Covered | Covered | Covered | Covered | Required gap | N/A | -| Kotlin | Covered | Covered | Covered | Covered | Covered | Required gap | N/A | -| Python | Covered | Covered | Covered | Covered | Covered | Required gap | N/A | -| TypeScript | Covered | Covered | Covered | Covered | Covered | Required gap | N/A | +| Dart.io | Covered | Covered | Covered | Covered | Covered | Covered | N/A | +| Go | Covered | Covered | Covered | Covered | Covered | Covered | N/A | +| Kotlin | Covered | Covered | Covered | Covered | Covered | Covered | N/A | +| Python | Covered | Covered | Covered | Covered | Covered | Covered | N/A | +| TypeScript | Covered | Covered | Covered | Covered | Covered | Covered | N/A | -각 Dart.web WS cell은 server-language runner가 해당 WS server를 띄우고, matching browser test file에 대해 `dart test -p chrome`을 실행하며, send-push와 request-response 시나리오를 검증한다. Dart.web WSS cell도 같은 시나리오를 검증해야 하며, 테스트용 self-signed CA/certificate trust 자동화가 추가되기 전까지는 완전 coverage의 남은 항목으로 본다. +각 Dart.web cell은 server-language runner가 WS와 WSS server를 함께 띄우고, matching browser test file에 대해 `dart test -p chrome`을 실행하며, WS send-push/request-response와 WSS send-push/request-response 4개 시나리오를 검증한다. ```bash cd dart && dart run crosstest/dart_web.dart diff --git a/agent-ops/rules/project/rules.md b/agent-ops/rules/project/rules.md index ee0d9b3..95668a1 100644 --- a/agent-ops/rules/project/rules.md +++ b/agent-ops/rules/project/rules.md @@ -55,7 +55,7 @@ VERSIONING.md — 프로토콜/패키지 버전 정책 - 기본 검증 명령은 `bash agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_matrix.sh --all`이다. - 테스트 coverage 평가는 라인/브랜치 수치가 아니라 프로토콜 계약 시나리오가 매트릭스에서 빠짐없이 검증되는지로 판단한다. - Dart.web remote browser host 사용은 의도된 local 테스트 환경이다. 환경을 이전할 때는 그대로 유지할 규칙이 아니라 새 local 환경 규칙으로 재정의해야 한다. -- Dart.web WSS는 self-signed certificate로 자동화할 수 없는 항목이 아니다. 현재 남은 coverage gap은 브라우저가 테스트용 self-signed CA/certificate를 신뢰하도록 만드는 자동화와 그에 따른 Dart.web WSS 매트릭스 편입이다. +- Dart.web WSS는 테스트용 self-signed certificate의 SPKI를 허용하는 Chrome 실행 wrapper로 local/remote 브라우저 매트릭스에 포함한다. - CI/CD runner 연결은 현재 설계상 과하며, 사용자가 명시적으로 요청하지 않으면 제안하거나 요구하지 않는다. ## 마일스톤 컨텍스트 로딩 diff --git a/agent-ops/skills/project/run-proto-socket-test-matrix/SKILL.md b/agent-ops/skills/project/run-proto-socket-test-matrix/SKILL.md index adca1b9..0115238 100644 --- a/agent-ops/skills/project/run-proto-socket-test-matrix/SKILL.md +++ b/agent-ops/skills/project/run-proto-socket-test-matrix/SKILL.md @@ -132,19 +132,23 @@ tools/check_proto_sync.sh |---|---|---| | Dart | `dart pub get && dart test && dart compile js test/browser_ws_import_compile.dart -o /tmp/proto_socket_browser_ws_import_compile.js` | PASS | -**언어간 통신** -| 서버 \ 클라이언트 | Dart | Go | Kotlin | Python | TypeScript | -|---|---|---|---|---|---| -| Dart | PASS | PASS | PASS | PASS | PASS | +**언어 PASS 매트릭스** +| 서버 \ 클라이언트 | Dart.io | Dart.web | Go | Kotlin | Python | TypeScript | +|---|---|---|---|---|---|---| +| Dart.io | PASS | PASS | PASS | PASS | PASS | PASS | +| Go | PASS | PASS | PASS | PASS | PASS | PASS | +| Kotlin | PASS | PASS | PASS | PASS | PASS | PASS | +| Python | PASS | PASS | PASS | PASS | PASS | PASS | +| TypeScript | PASS | PASS | PASS | PASS | PASS | PASS | -**Dart.web 클라이언트 통신** -| 서버 | Dart.web (WS) | PASS scenarios | Expected | FAIL lines | +**크로스테스트 상세** +| 방향 | 결과 | PASS scenarios | Expected | FAIL lines | |---|---:|---:|---:|---:| -| Dart.io | PASS | 2 | 2 | 0 | -| Go | PASS | 2 | 2 | 0 | -| Kotlin | PASS | 2 | 2 | 0 | -| Python | PASS | 2 | 2 | 0 | -| TypeScript | PASS | 2 | 2 | 0 | +| Dart.io -> Dart.web | PASS | 4 | 4 | 0 | +| Go -> Dart.web | PASS | 4 | 4 | 0 | +| Kotlin -> Dart.web | PASS | 4 | 4 | 0 | +| Python -> Dart.web | PASS | 4 | 4 | 0 | +| TypeScript -> Dart.web | PASS | 4 | 4 | 0 | ``` ## 금지 사항 diff --git a/agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_matrix.sh b/agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_matrix.sh index bce70a7..67099d5 100755 --- a/agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_matrix.sh +++ b/agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_matrix.sh @@ -112,7 +112,7 @@ declare -A web_fail_lines declare -A web_cmds declare -A web_block_reason -expected_web_pass_lines=2 +expected_web_pass_lines=4 count_lines() { local pattern="$1" @@ -157,7 +157,11 @@ chrome_available() { remote_chrome_export_command() { cat <<'REMOTE_CHROME' -if [ -x "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" ]; then +if [ -n "${CHROME_EXECUTABLE:-}" ] && [ -x "$CHROME_EXECUTABLE" ]; then + : +elif [ -n "${repo_dir:-}" ] && [ -x "$repo_dir/.agent-test/chrome-proto-socket-wss" ]; then + export CHROME_EXECUTABLE="$repo_dir/.agent-test/chrome-proto-socket-wss" +elif [ -x "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" ]; then export CHROME_EXECUTABLE="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" elif command -v google-chrome >/dev/null 2>&1; then export CHROME_EXECUTABLE="$(command -v google-chrome)" @@ -172,8 +176,28 @@ fi REMOTE_CHROME } +remote_chrome_wrapper_setup_command() { + cat <<'REMOTE_CHROME_WRAPPER' +if [ -x "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" ] && + command -v openssl >/dev/null 2>&1 && + [ -f dart/test/certs/server.crt ]; then + mkdir -p "$repo_dir/.agent-test" + spki="$(openssl x509 -in dart/test/certs/server.crt -pubkey -noout | + openssl pkey -pubin -outform DER | + openssl dgst -sha256 -binary | + openssl base64)" + cat > "$repo_dir/.agent-test/chrome-proto-socket-wss" <&2 @@ -184,6 +208,7 @@ prepare_remote_web_repo() { repo_url_q="$(single_quote "$remote_web_repo_url")" repo_ref_q="$(single_quote "$remote_web_ref")" chrome_cmd="$(remote_chrome_export_command)" + chrome_wrapper_cmd="$(remote_chrome_wrapper_setup_command)" remote_exec " set -e @@ -207,6 +232,7 @@ fi if [ -f python/pyproject.toml ]; then (cd python && python3 -m pip install -e '.[dev]') fi +$chrome_wrapper_cmd $chrome_cmd printf 'remote Dart.web repo: %s\n' \"\$repo_dir\" printf 'remote Dart.web git ref: %s\n' \"\$(git rev-parse --short HEAD)\" diff --git a/agent-roadmap/milestones/continuous-verification.md b/agent-roadmap/milestones/continuous-verification.md index 869d184..7f65709 100644 --- a/agent-roadmap/milestones/continuous-verification.md +++ b/agent-roadmap/milestones/continuous-verification.md @@ -53,5 +53,5 @@ - 관련 경로: `agent-ops/skills/project/run-proto-socket-test-matrix/SKILL.md`, `agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_matrix.sh`, `README.md` - 선행 작업: 사용 가능 언어 parity - 후속 작업: 안정화 기준선 -- 검증 기록: 2026-05-30에 `bash agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_matrix.sh --all` 통과. Proto 동기화, Dart/Go/Kotlin/Python/TypeScript 동일 언어 테스트, 20개 일반 크로스 언어 방향, Dart.web WS 5개 방향이 모두 `PASS`. +- 검증 기록: 2026-05-30에 `bash agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_matrix.sh --all` 통과. Proto 동기화, Dart/Go/Kotlin/Python/TypeScript 동일 언어 테스트, 20개 일반 크로스 언어 방향, Dart.web WS/WSS 5개 방향이 모두 `PASS`. - 확인 필요: CI/CD 연결은 현재 완전성 기준이 아니다. 상위 `../oto` 프로젝트의 표준 실행 방식이 확정되고 사용자가 명시적으로 요청하면 이 저장소의 로컬 검증 진입점을 외부 실행기로 연결한다. diff --git a/agent-roadmap/milestones/stability-baseline.md b/agent-roadmap/milestones/stability-baseline.md index 79f2dff..1ea8507 100644 --- a/agent-roadmap/milestones/stability-baseline.md +++ b/agent-roadmap/milestones/stability-baseline.md @@ -46,6 +46,6 @@ - 관련 경로: `README.md`, `PROTOCOL.md`, `VERSIONING.md`, `PORTING_GUIDE.md`, `agent-roadmap/milestones/continuous-verification.md`, `agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_matrix.sh` - 선행 작업: 지속 검증 - 후속 작업: C# Unity/.NET 포트, Swift Apple 플랫폼 포트, 릴리즈 준비 -- 검증 근거: 2026-05-30에 `bash agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_matrix.sh --all` 통과. Proto 동기화, Dart/Go/Kotlin/Python/TypeScript 동일 언어 테스트, 20개 일반 크로스 언어 방향, Dart.web WS 5개 방향이 모두 `PASS`. +- 검증 근거: 2026-05-30에 `bash agent-ops/skills/project/run-proto-socket-test-matrix/scripts/run_matrix.sh --all` 통과. Proto 동기화, Dart/Go/Kotlin/Python/TypeScript 동일 언어 테스트, 20개 일반 크로스 언어 방향, Dart.web WS/WSS 5개 방향이 모두 `PASS`. - 문서 정합성: 2026-05-22 기준 README, PROTOCOL, VERSIONING, PORTING_GUIDE의 protocol `0.1`, 지원 언어, 새 언어 보류, package registry 보류, validation gate 설명이 충돌하지 않는다. -- 안정화 판단: 최신 전체 매트릭스 PASS와 문서 정합성 점검 기준으로 protocol `0.1` 및 현재 5개 언어 구현을 동결/완성형 후보로 둔다. 다만 coverage 완전성 평가는 라인 수치가 아니라 프로토콜 시나리오 coverage로 판단하며, Dart.web WSS self-signed certificate trust 자동화와 매트릭스 편입은 남은 coverage gap으로 기록한다. 새 변경 후보가 발견되기 전까지 protocol/API 변경은 compatibility work로만 다룬다. +- 안정화 판단: 최신 전체 매트릭스 PASS와 문서 정합성 점검 기준으로 protocol `0.1` 및 현재 5개 언어 구현을 동결/완성형 후보로 둔다. Coverage 완전성 평가는 라인 수치가 아니라 프로토콜 시나리오 coverage로 판단하며, Dart.web WSS는 테스트용 self-signed certificate SPKI Chrome wrapper를 통해 매트릭스에 포함한다. 새 변경 후보가 발견되기 전까지 protocol/API 변경은 compatibility work로만 다룬다. diff --git a/dart/crosstest/dart_web.dart b/dart/crosstest/dart_web.dart index 78fcb61..7bff01a 100644 --- a/dart/crosstest/dart_web.dart +++ b/dart/crosstest/dart_web.dart @@ -6,6 +6,7 @@ import 'package:proto_socket/proto_socket.dart'; const _host = '127.0.0.1'; const _wsPort = 29098; +const _wssPort = 29099; const _heartbeatIntervalSeconds = 30; const _heartbeatWaitSeconds = 10; const _serverObservationWindow = Duration(milliseconds: 500); @@ -14,6 +15,8 @@ const _streamTimeout = Duration(seconds: 5); final _repoRoot = File.fromUri(Platform.script).parent.parent.parent.path; final _dartDir = Directory('$_repoRoot/dart').path; +final _certFile = '$_repoRoot/dart/test/certs/server.crt'; +final _keyFile = '$_repoRoot/dart/test/certs/server.key'; class _ServerWsClient extends WsProtobufClient { _ServerWsClient(WebSocket ws) @@ -28,6 +31,10 @@ class _DartWebWsServer extends WsProtobufServer { _DartWebWsServer(int port, this.onConnected) : super(_host, port, (ws) => _ServerWsClient(ws)); + _DartWebWsServer.secure( + int port, SecurityContext securityContext, this.onConnected) + : super.secure(_host, port, securityContext, (ws) => _ServerWsClient(ws)); + @override void onClientConnected(WsProtobufClient client) => onConnected(client); } @@ -36,9 +43,11 @@ Future main() async { print( 'INFO typeName dart=${TestData.getDefault().info_.qualifiedMessageName}'); try { - await _runWebWsSuite(); - print('PASS scenario=send-push'); - print('PASS scenario=request-response'); + await _runWebSuites(); + print('PASS scenario=ws-send-push'); + print('PASS scenario=ws-request-response'); + print('PASS scenario=wss-send-push'); + print('PASS scenario=wss-request-response'); print('PASS all dart-server/dart-web-client crosstests passed'); } catch (error) { stderr.writeln('FAIL crosstest error=$error'); @@ -46,35 +55,57 @@ Future main() async { } } -Future _runWebWsSuite() async { - final received = Completer(); - final server = _DartWebWsServer(_wsPort, (client) { - client.addRequestListener((req) async { - print('SERVER_RECEIVED index=${req.index} message=${req.message}'); - if (req.index == 101 && req.message == 'fire from dart web client') { - if (!received.isCompleted) { - received.complete(true); - } - unawaited(client.send(TestData() - ..index = 200 - ..message = 'push from dart server')); - } - return TestData() - ..index = req.index * 2 - ..message = 'echo: ${req.message}'; - }); +Future _runWebSuites() async { + final wsReceived = Completer(); + final wssReceived = Completer(); + final securityContext = SecurityContext() + ..useCertificateChain(_certFile) + ..usePrivateKey(_keyFile); + final wsServer = _DartWebWsServer(_wsPort, (client) { + _configureWebClient(client, wsReceived); + }); + final wssServer = + _DartWebWsServer.secure(_wssPort, securityContext, (client) { + _configureWebClient(client, wssReceived); }); - await server.start(); + await wsServer.start(); + await wssServer.start(); try { await _runDartBrowserTest('test/browser_ws_dart_io_test.dart'); - final ok = await received.future - .timeout(_serverObservationWindow, onTimeout: () => false); - if (!ok) { - throw StateError('WS web send-push server did not receive expected data'); - } + await _expectReceived('WS', wsReceived); + await _expectReceived('WSS', wssReceived); } finally { - await server.stop(); + await Future.wait([ + wssServer.stop(), + wsServer.stop(), + ]); + } +} + +void _configureWebClient(WsProtobufClient client, Completer received) { + client.addRequestListener((req) async { + print('SERVER_RECEIVED index=${req.index} message=${req.message}'); + if (req.index == 101 && req.message == 'fire from dart web client') { + if (!received.isCompleted) { + received.complete(true); + } + unawaited(client.send(TestData() + ..index = 200 + ..message = 'push from dart server')); + } + return TestData() + ..index = req.index * 2 + ..message = 'echo: ${req.message}'; + }); +} + +Future _expectReceived(String transport, Completer received) async { + final ok = await received.future + .timeout(_serverObservationWindow, onTimeout: () => false); + if (!ok) { + throw StateError( + '$transport web send-push server did not receive expected data'); } } @@ -101,8 +132,8 @@ Future _runDartBrowserTest(String testPath) async { process.kill(ProcessSignal.sigkill); return -1; }); - await Future.wait([stdoutDone, stderrDone]) - .timeout(_streamTimeout, onTimeout: () { + await Future.wait([stdoutDone, stderrDone]).timeout(_streamTimeout, + onTimeout: () { throw StateError('dart browser test streams did not finish'); }); if (code != 0) { diff --git a/dart/test/browser_ws_dart_io_test.dart b/dart/test/browser_ws_dart_io_test.dart index f5420d3..f1dcec4 100644 --- a/dart/test/browser_ws_dart_io_test.dart +++ b/dart/test/browser_ws_dart_io_test.dart @@ -9,4 +9,10 @@ void main() { port: 29098, serverPushMessage: 'push from dart server', ); + registerBrowserWsTests( + serverLabel: 'dart.io', + port: 29099, + serverPushMessage: 'push from dart server', + secure: true, + ); } diff --git a/dart/test/browser_ws_kotlin_test.dart b/dart/test/browser_ws_kotlin_test.dart index 4562f08..fa4dca1 100644 --- a/dart/test/browser_ws_kotlin_test.dart +++ b/dart/test/browser_ws_kotlin_test.dart @@ -9,4 +9,10 @@ void main() { port: 29498, serverPushMessage: 'push from kotlin server', ); + registerBrowserWsTests( + serverLabel: 'kotlin', + port: 29499, + serverPushMessage: 'push from kotlin server', + secure: true, + ); } diff --git a/dart/test/browser_ws_python_test.dart b/dart/test/browser_ws_python_test.dart index 7d4f996..97531d1 100644 --- a/dart/test/browser_ws_python_test.dart +++ b/dart/test/browser_ws_python_test.dart @@ -9,4 +9,10 @@ void main() { port: 29698, serverPushMessage: 'push from python server', ); + registerBrowserWsTests( + serverLabel: 'python', + port: 29699, + serverPushMessage: 'push from python server', + secure: true, + ); } diff --git a/dart/test/browser_ws_runtime_helper.dart b/dart/test/browser_ws_runtime_helper.dart index 9e43bbb..d4c1bc6 100644 --- a/dart/test/browser_ws_runtime_helper.dart +++ b/dart/test/browser_ws_runtime_helper.dart @@ -20,10 +20,17 @@ void registerBrowserWsTests({ required String serverLabel, required int port, required String serverPushMessage, + bool secure = false, }) { - group('Dart Web WsProtobufClient talks to $serverLabel WS server', () { + final transportLabel = secure ? 'WSS' : 'WS'; + + group( + 'Dart Web WsProtobufClient talks to $serverLabel $transportLabel server', + () { test('send-push: fire-and-forget and receive push', () async { - final ws = await WsProtobufClient.connect(_host, port, path: _wsPath); + final ws = secure + ? await WsProtobufClient.connectSecure(_host, port, path: _wsPath) + : await WsProtobufClient.connect(_host, port, path: _wsPath); final client = _BrowserWsClient(ws); final pushCompleter = Completer(); @@ -46,7 +53,9 @@ void registerBrowserWsTests({ }); test('request-response: single and concurrent requests', () async { - final ws = await WsProtobufClient.connect(_host, port, path: _wsPath); + final ws = secure + ? await WsProtobufClient.connectSecure(_host, port, path: _wsPath) + : await WsProtobufClient.connect(_host, port, path: _wsPath); final client = _BrowserWsClient(ws); final single = await client diff --git a/dart/test/browser_ws_runtime_test.dart b/dart/test/browser_ws_runtime_test.dart index e74ea21..8b31574 100644 --- a/dart/test/browser_ws_runtime_test.dart +++ b/dart/test/browser_ws_runtime_test.dart @@ -9,4 +9,10 @@ void main() { port: 29198, serverPushMessage: 'push from go server', ); + registerBrowserWsTests( + serverLabel: 'go', + port: 29199, + serverPushMessage: 'push from go server', + secure: true, + ); } diff --git a/dart/test/browser_ws_typescript_test.dart b/dart/test/browser_ws_typescript_test.dart index 0e82c8c..44f4d5c 100644 --- a/dart/test/browser_ws_typescript_test.dart +++ b/dart/test/browser_ws_typescript_test.dart @@ -9,4 +9,10 @@ void main() { port: 29898, serverPushMessage: 'push from typescript server', ); + registerBrowserWsTests( + serverLabel: 'typescript', + port: 29899, + serverPushMessage: 'push from typescript server', + secure: true, + ); } diff --git a/go/crosstest/go_dart_web.go b/go/crosstest/go_dart_web.go index 5832b58..1cb2ee7 100644 --- a/go/crosstest/go_dart_web.go +++ b/go/crosstest/go_dart_web.go @@ -5,6 +5,7 @@ package main import ( "bufio" "context" + "crypto/tls" "fmt" "io" "os" @@ -24,6 +25,7 @@ import ( const ( host = "127.0.0.1" goDartWebWSPort = 29198 + goDartWebWSSPort = 29199 wsPath = "/" processTimeout = 60 * time.Second serverObservationWindow = 200 * time.Millisecond @@ -50,25 +52,75 @@ func main() { fmt.Fprintf(os.Stderr, "FAIL crosstest error=%v\n", err) os.Exit(1) } - fmt.Println("PASS scenario=send-push") - fmt.Println("PASS scenario=request-response") + fmt.Println("PASS scenario=ws-send-push") + fmt.Println("PASS scenario=ws-request-response") + fmt.Println("PASS scenario=wss-send-push") + fmt.Println("PASS scenario=wss-request-response") fmt.Println("PASS all go-server/dart-web-client crosstests passed") } func run() error { - return runWebWSSuite() + serverTLS, err := loadWebServerTLS() + if err != nil { + return err + } + return runWebSuites(serverTLS) } -func runWebWSSuite() error { +func loadWebServerTLS() (*tls.Config, error) { + dartDir, err := dartPackageDir() + if err != nil { + return nil, err + } + certFile := filepath.Join(dartDir, "test", "certs", "server.crt") + keyFile := filepath.Join(dartDir, "test", "certs", "server.key") + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + return nil, err + } + return &tls.Config{Certificates: []tls.Certificate{cert}}, nil +} + +func runWebSuites(serverTLS *tls.Config) error { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - received := make(chan bool, 1) - server := toki.NewWsServerWithOptions(host, goDartWebWSPort, wsPath, toki.WsServerOptions{ + wsReceived := make(chan bool, 1) + wssReceived := make(chan bool, 1) + wsServer := newWebServer(goDartWebWSPort, nil, wsReceived) + wssServer := newWebServer(goDartWebWSSPort, serverTLS, wssReceived) + + if err := wsServer.Start(ctx); err != nil { + return err + } + defer wsServer.Stop() + if err := wssServer.Start(ctx); err != nil { + return err + } + defer wssServer.Stop() + + if err := runDartBrowserTest(); err != nil { + return err + } + if err := expectReceived("WS", wsReceived); err != nil { + return err + } + return expectReceived("WSS", wssReceived) +} + +func newWebServer(port int, tlsCfg *tls.Config, received chan<- bool) *toki.WsServer { + options := toki.WsServerOptions{ AcceptOptions: browserAcceptOptions(), - }, func(conn *websocket.Conn) *toki.WsClient { + } + newClient := func(conn *websocket.Conn) *toki.WsClient { return toki.NewWsClient(conn, 0, 0, parserMap()) - }) + } + var server *toki.WsServer + if tlsCfg != nil { + server = toki.NewWsServerTLSWithOptions(host, port, wsPath, tlsCfg, options, newClient) + } else { + server = toki.NewWsServerWithOptions(host, port, wsPath, options, newClient) + } server.OnClientConnected = func(client *toki.WsClient) { toki.AddRequestListenerTyped[*packets.TestData, *packets.TestData](&client.Communicator, func(req *packets.TestData) (*packets.TestData, error) { fmt.Printf("SERVER_RECEIVED index=%d message=%s\n", req.GetIndex(), req.GetMessage()) @@ -85,21 +137,17 @@ func runWebWSSuite() error { }, nil }) } - if err := server.Start(ctx); err != nil { - return err - } - defer server.Stop() + return server +} - if err := runDartBrowserTest(); err != nil { - return err - } +func expectReceived(transport string, received <-chan bool) error { select { case ok := <-received: if !ok { - return fmt.Errorf("WS web send-push server received unexpected data") + return fmt.Errorf("%s web send-push server received unexpected data", transport) } case <-time.After(serverObservationWindow): - return fmt.Errorf("WS web send-push server did not receive expected data") + return fmt.Errorf("%s web send-push server did not receive expected data", transport) } return nil } diff --git a/go/ws_server.go b/go/ws_server.go index 07e9661..1a3479d 100644 --- a/go/ws_server.go +++ b/go/ws_server.go @@ -54,6 +54,12 @@ func NewWsServerWithOptions(host string, port int, path string, options WsServer return s } +func NewWsServerTLSWithOptions(host string, port int, path string, tlsCfg *tls.Config, options WsServerOptions, newClient func(*websocket.Conn) *WsClient) *WsServer { + s := NewWsServerTLS(host, port, path, tlsCfg, newClient) + s.acceptOptions = options.AcceptOptions + return s +} + func (s *WsServer) Started() bool { s.mu.Lock() defer s.mu.Unlock() diff --git a/kotlin/crosstest/kotlin_dart_web.kt b/kotlin/crosstest/kotlin_dart_web.kt index 9baec11..17f6d56 100644 --- a/kotlin/crosstest/kotlin_dart_web.kt +++ b/kotlin/crosstest/kotlin_dart_web.kt @@ -12,16 +12,24 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import java.io.File +import java.security.KeyFactory +import java.security.KeyStore +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.security.spec.PKCS8EncodedKeySpec +import java.util.Base64 import java.util.concurrent.TimeUnit +import javax.net.ssl.KeyManagerFactory +import javax.net.ssl.SSLContext import kotlin.system.exitProcess private const val HOST = "127.0.0.1" private const val WS_PORT = 29498 +private const val WSS_PORT = 29499 private const val WS_PATH = "/" private const val PROCESS_TIMEOUT_MS = 60_000L private const val SERVER_OBSERVATION_MS = 500L @@ -30,9 +38,11 @@ private const val STREAM_JOIN_TIMEOUT_MS = 5_000L fun main() = runBlocking { println("INFO typeName kotlin=${typeNameOf()}") try { - runWebWs() - println("PASS scenario=send-push") - println("PASS scenario=request-response") + runWebSuites() + println("PASS scenario=ws-send-push") + println("PASS scenario=ws-request-response") + println("PASS scenario=wss-send-push") + println("PASS scenario=wss-request-response") println("PASS all kotlin-server/dart-web-client crosstests passed") } catch (error: Throwable) { System.err.println("FAIL crosstest error=${error.message ?: error}") @@ -40,17 +50,42 @@ fun main() = runBlocking { } } -private suspend fun runWebWs() = coroutineScope { - val received = CompletableDeferred() - val server = WsServer(HOST, WS_PORT, WS_PATH) { conn -> +private suspend fun runWebSuites() = coroutineScope { + val wsReceived = CompletableDeferred() + val wssReceived = CompletableDeferred() + val certFile = kotlinResourceFile("server.crt") + val keyFile = kotlinResourceFile("server.key") + val serverCtx = buildServerSslContext(certFile.path, keyFile.path) + val wsServer = webServer(WS_PORT, null, wsReceived) + val wssServer = webServer(WSS_PORT, serverCtx, wssReceived) + + wsServer.start() + wssServer.start() + delay(100) + try { + runDartBrowserTest("test/browser_ws_kotlin_test.dart") + expectReceived("WS", wsReceived) + expectReceived("WSS", wssReceived) + } finally { + wssServer.stop() + wsServer.stop() + } +} + +private fun webServer( + port: Int, + sslContext: SSLContext?, + received: CompletableDeferred, +): WsServer { + val server = WsServer(HOST, port, WS_PATH, sslContext = sslContext) { conn -> WsClient.forServer(conn, 0, 0, parserMap()) } server.onClientConnected = { client -> addRequestListenerTyped(client.communicator) { req -> println("SERVER_RECEIVED index=${req.index} message=${req.message}") if (req.index == 101 && req.message == "fire from dart web client") { - received.complete(true) - launch { + if (!received.isCompleted) received.complete(true) + runBlocking { client.send( TestData.newBuilder() .setIndex(200) @@ -65,15 +100,15 @@ private suspend fun runWebWs() = coroutineScope { .build() } } - server.start() - delay(100) - try { - runDartBrowserTest("test/browser_ws_kotlin_test.dart") - val ok = withTimeoutOrNull(SERVER_OBSERVATION_MS) { received.await() } ?: false - check(ok) { "WS web send-push server did not receive expected data" } - } finally { - server.stop() - } + return server +} + +private suspend fun expectReceived( + transport: String, + received: CompletableDeferred, +) { + val ok = withTimeoutOrNull(SERVER_OBSERVATION_MS) { received.await() } ?: false + check(ok) { "$transport web send-push server did not receive expected data" } } private fun destroyProcessTree(process: Process) { @@ -112,6 +147,39 @@ private suspend fun runDartBrowserTest(testPath: String) = withContext(Dispatche private fun parserMap(): ParserMap = mapOf(typeNameOf() to { TestData.parseFrom(it) }) +private fun buildServerSslContext(certPath: String, keyPath: String): SSLContext { + val certFactory = CertificateFactory.getInstance("X.509") + val cert = File(certPath).inputStream().use { + certFactory.generateCertificate(it) as X509Certificate + } + val keyPem = File(keyPath).readText() + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replace("\\s+".toRegex(), "") + val privateKey = KeyFactory.getInstance("RSA") + .generatePrivate(PKCS8EncodedKeySpec(Base64.getDecoder().decode(keyPem))) + val keyStore = KeyStore.getInstance("PKCS12") + keyStore.load(null, null) + keyStore.setKeyEntry("toki", privateKey, CharArray(0), arrayOf(cert)) + val keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()) + keyManagerFactory.init(keyStore, CharArray(0)) + val ctx = SSLContext.getInstance("TLS") + ctx.init(keyManagerFactory.keyManagers, null, null) + return ctx +} + +private fun kotlinResourceFile(name: String): File { + var dir = File(System.getProperty("user.dir")).absoluteFile + while (dir.parentFile != null) { + val direct = File(dir, "src/test/resources/$name").canonicalFile + if (direct.isFile) return direct + val nested = File(dir, "kotlin/src/test/resources/$name").canonicalFile + if (nested.isFile) return nested + dir = dir.parentFile + } + error("cannot resolve kotlin test resource $name") +} + private fun dartDir(): File { var dir = File(System.getProperty("user.dir")).absoluteFile while (dir.parentFile != null) { diff --git a/python/crosstest/python_dart_web.py b/python/crosstest/python_dart_web.py index 2781191..efc1843 100644 --- a/python/crosstest/python_dart_web.py +++ b/python/crosstest/python_dart_web.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import ssl import sys from pathlib import Path @@ -12,9 +13,12 @@ from proto_socket.ws_server import WsServer HOST = "127.0.0.1" WS_PORT = 29698 +WSS_PORT = 29699 WS_PATH = "/" PROCESS_TIMEOUT = 60.0 SERVER_OBSERVATION_WINDOW = 0.5 +CERT_PATH = Path(__file__).resolve().parents[1] / "test" / "certs" / "server.crt" +KEY_PATH = Path(__file__).resolve().parents[1] / "test" / "certs" / "server.key" def parser_map(): @@ -24,18 +28,49 @@ def parser_map(): async def main() -> None: print(f"INFO typeName python={type_name_of(TestData)}") try: - await run_web_ws() + await run_web_suites() except Exception as exc: print(f"FAIL crosstest error={exc}", file=sys.stderr) raise SystemExit(1) from exc - print("PASS scenario=send-push") - print("PASS scenario=request-response") + print("PASS scenario=ws-send-push") + print("PASS scenario=ws-request-response") + print("PASS scenario=wss-send-push") + print("PASS scenario=wss-request-response") print("PASS all python-server/dart-web-client crosstests passed") -async def run_web_ws() -> None: - received = asyncio.get_running_loop().create_future() - server = WsServer(HOST, WS_PORT, WS_PATH, interval_sec=0, wait_sec=0, parser_map=parser_map()) +async def run_web_suites() -> None: + loop = asyncio.get_running_loop() + ws_received = loop.create_future() + wss_received = loop.create_future() + ws_server = make_web_server(WS_PORT, None, ws_received) + wss_server = make_web_server(WSS_PORT, make_server_ssl_context(), wss_received) + + await ws_server.start() + await wss_server.start() + try: + await run_dart_browser_test("test/browser_ws_python_test.dart") + await expect_received("WS", ws_received) + await expect_received("WSS", wss_received) + finally: + await wss_server.stop() + await ws_server.stop() + + +def make_web_server( + port: int, + ssl_context: ssl.SSLContext | None, + received: asyncio.Future[bool], +) -> WsServer: + server = WsServer( + HOST, + port, + WS_PATH, + interval_sec=0, + wait_sec=0, + parser_map=parser_map(), + ssl_context=ssl_context, + ) def on_connected(client): def on_request(req): @@ -53,14 +88,24 @@ async def run_web_ws() -> None: client.communicator.add_request_listener(type_name_of(TestData), on_request) server.on_client_connected = on_connected - await server.start() + return server + + +def make_server_ssl_context() -> ssl.SSLContext: + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ctx.load_cert_chain(CERT_PATH, KEY_PATH) + return ctx + + +async def expect_received(transport: str, received: asyncio.Future[bool]) -> None: try: - await run_dart_browser_test("test/browser_ws_python_test.dart") ok = await asyncio.wait_for(received, SERVER_OBSERVATION_WINDOW) - if not ok: - raise RuntimeError("WS web send-push server received unexpected data") - finally: - await server.stop() + except TimeoutError as exc: + raise RuntimeError( + f"{transport} web send-push server did not receive expected data" + ) from exc + if not ok: + raise RuntimeError(f"{transport} web send-push server received unexpected data") async def run_dart_browser_test(test_path: str) -> None: diff --git a/typescript/CROSSTEST_CHECKLIST.md b/typescript/CROSSTEST_CHECKLIST.md index 75dffe7..0c36281 100644 --- a/typescript/CROSSTEST_CHECKLIST.md +++ b/typescript/CROSSTEST_CHECKLIST.md @@ -24,4 +24,4 @@ - [ ] Record exact commands used to run TypeScript checks and Go cross tests. - [ ] Record fixed ports `29490` and `29492`. -- [ ] Record TLS/WSS coverage status. Non-browser TLS/WSS is required; Dart.web WSS remains a required browser trust automation gap until self-signed test CA/certificate handling is added. +- [ ] Record TLS/WSS coverage status. Non-browser TLS/WSS is required; Dart.web WSS is covered through the browser matrix with the test certificate SPKI Chrome wrapper. diff --git a/typescript/crosstest/typescript_dart_web.ts b/typescript/crosstest/typescript_dart_web.ts index eb0797d..f984545 100644 --- a/typescript/crosstest/typescript_dart_web.ts +++ b/typescript/crosstest/typescript_dart_web.ts @@ -1,4 +1,5 @@ import * as childProcess from "node:child_process"; +import * as fs from "node:fs"; import * as path from "node:path"; import * as readline from "node:readline"; import { fileURLToPath } from "node:url"; @@ -14,9 +15,12 @@ import { NodeWsServer } from "../src/node_ws_server.js"; const __filename = fileURLToPath(import.meta.url); const repoRoot = path.resolve(path.dirname(__filename), "../.."); const dartDir = path.join(repoRoot, "dart"); +const CERT_PATH = path.join(repoRoot, "typescript/test/certs/server.crt"); +const KEY_PATH = path.join(repoRoot, "typescript/test/certs/server.key"); const HOST = "127.0.0.1"; const WS_PORT = 29898; +const WSS_PORT = 29899; const WS_PATH = "/"; const PROCESS_TIMEOUT_MS = 60_000; const STREAM_JOIN_TIMEOUT_MS = 5_000; @@ -25,9 +29,11 @@ const SERVER_OBSERVATION_MS = 500; async function main(): Promise { console.log(`INFO typeName ts=${TestDataSchema.typeName}`); try { - await runWebWs(); - console.log("PASS scenario=send-push"); - console.log("PASS scenario=request-response"); + await runWebSuites(); + console.log("PASS scenario=ws-send-push"); + console.log("PASS scenario=ws-request-response"); + console.log("PASS scenario=wss-send-push"); + console.log("PASS scenario=wss-request-response"); console.log("PASS all typescript-server/dart-web-client crosstests passed"); } catch (err) { console.error(`FAIL crosstest error=${errorMessage(err)}`); @@ -35,20 +41,40 @@ async function main(): Promise { } } -async function runWebWs(): Promise { - let resolveReceived: ((value: boolean) => void) | null = null; - const received = new Promise((resolve) => { - resolveReceived = resolve; - }); - const server = new NodeWsServer(HOST, WS_PORT, WS_PATH, (ws) => new NodeWsClient(ws, 0, 0, parserMap())); +async function runWebSuites(): Promise { + const wsReceived = createReceivedSignal(); + const wssReceived = createReceivedSignal(); + const wsServer = createWebServer(WS_PORT, wsReceived.resolve); + const wssServer = createWebServer(WSS_PORT, wssReceived.resolve, serverTlsOptions()); + + await wsServer.start(); + await wssServer.start(); + try { + await runDartBrowserTest("test/browser_ws_typescript_test.dart"); + const wsOk = await withTimeout(wsReceived.promise, SERVER_OBSERVATION_MS, "WS web send-push server did not receive expected data"); + if (!wsOk) { + throw new Error("WS web send-push server received unexpected data"); + } + const wssOk = await withTimeout(wssReceived.promise, SERVER_OBSERVATION_MS, "WSS web send-push server did not receive expected data"); + if (!wssOk) { + throw new Error("WSS web send-push server received unexpected data"); + } + } finally { + await Promise.allSettled([wssServer.stop(), wsServer.stop()]); + } +} + +function createWebServer( + port: number, + resolveReceived: (value: boolean) => void, + tlsOptions?: { cert: Buffer; key: Buffer }, +): NodeWsServer { + const server = new NodeWsServer(HOST, port, WS_PATH, (ws) => new NodeWsClient(ws, 0, 0, parserMap()), tlsOptions); server.onClientConnected = (client) => { addRequestListenerTyped(client.communicator, TestDataSchema, (req) => { console.log(`SERVER_RECEIVED index=${req.index} message=${req.message}`); if (req.index === 101 && req.message === "fire from dart web client") { - if (resolveReceived !== null) { - resolveReceived(true); - resolveReceived = null; - } + resolveReceived(true); void client.communicator.send( create(TestDataSchema, { index: 200, @@ -62,16 +88,7 @@ async function runWebWs(): Promise { }); }); }; - await server.start(); - try { - await runDartBrowserTest("test/browser_ws_typescript_test.dart"); - const ok = await withTimeout(received, SERVER_OBSERVATION_MS, "WS web send-push server did not receive expected data"); - if (!ok) { - throw new Error("WS web send-push server received unexpected data"); - } - } finally { - await server.stop(); - } + return server; } async function runDartBrowserTest(testPath: string): Promise { @@ -130,6 +147,33 @@ function parserMap() { return new Map([[TestDataSchema.typeName, parserFromSchema(TestDataSchema)]]); } +function createReceivedSignal(): { + promise: Promise; + resolve: (value: boolean) => void; +} { + let done = false; + let resolvePromise: (value: boolean) => void; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + return { + promise, + resolve: (value: boolean) => { + if (!done) { + done = true; + resolvePromise(value); + } + }, + }; +} + +function serverTlsOptions(): { cert: Buffer; key: Buffer } { + return { + cert: fs.readFileSync(CERT_PATH), + key: fs.readFileSync(KEY_PATH), + }; +} + function killProcessTree(child: childProcess.ChildProcess): void { if (process.platform !== "win32" && child.pid !== undefined) { try {