feat: dart web crosstest and related test infrastructure updates

This commit is contained in:
toki 2026-05-30 22:59:23 +09:00
parent c94f2e82b5
commit 617ec02090
19 changed files with 440 additions and 129 deletions

View file

@ -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

View file

@ -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 연결은 현재 설계상 과하며, 사용자가 명시적으로 요청하지 않으면 제안하거나 요구하지 않는다.
## 마일스톤 컨텍스트 로딩

View file

@ -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 |
```
## 금지 사항

View file

@ -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" <<WRAPPER
#!/usr/bin/env bash
set -e
exec "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --ignore-certificate-errors-spki-list="$spki" --allow-insecure-localhost "\$@"
WRAPPER
chmod +x "$repo_dir/.agent-test/chrome-proto-socket-wss"
fi
REMOTE_CHROME_WRAPPER
}
prepare_remote_web_repo() {
local repo_name_q repo_url_q repo_ref_q chrome_cmd
local repo_name_q repo_url_q repo_ref_q chrome_cmd chrome_wrapper_cmd
if [ -z "$remote_web_repo_url" ]; then
echo "remote repository URL is not configured" >&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)\"

View file

@ -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` 프로젝트의 표준 실행 방식이 확정되고 사용자가 명시적으로 요청하면 이 저장소의 로컬 검증 진입점을 외부 실행기로 연결한다.

View file

@ -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로만 다룬다.

View file

@ -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<void> 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,9 +55,35 @@ Future<void> main() async {
}
}
Future<void> _runWebWsSuite() async {
final received = Completer<bool>();
final server = _DartWebWsServer(_wsPort, (client) {
Future<void> _runWebSuites() async {
final wsReceived = Completer<bool>();
final wssReceived = Completer<bool>();
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 wsServer.start();
await wssServer.start();
try {
await _runDartBrowserTest('test/browser_ws_dart_io_test.dart');
await _expectReceived('WS', wsReceived);
await _expectReceived('WSS', wssReceived);
} finally {
await Future.wait([
wssServer.stop(),
wsServer.stop(),
]);
}
}
void _configureWebClient(WsProtobufClient client, Completer<bool> received) {
client.addRequestListener<TestData, TestData>((req) async {
print('SERVER_RECEIVED index=${req.index} message=${req.message}');
if (req.index == 101 && req.message == 'fire from dart web client') {
@ -63,18 +98,14 @@ Future<void> _runWebWsSuite() async {
..index = req.index * 2
..message = 'echo: ${req.message}';
});
});
}
await server.start();
try {
await _runDartBrowserTest('test/browser_ws_dart_io_test.dart');
Future<void> _expectReceived(String transport, Completer<bool> received) async {
final ok = await received.future
.timeout(_serverObservationWindow, onTimeout: () => false);
if (!ok) {
throw StateError('WS web send-push server did not receive expected data');
}
} finally {
await server.stop();
throw StateError(
'$transport web send-push server did not receive expected data');
}
}
@ -101,8 +132,8 @@ Future<void> _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) {

View file

@ -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,
);
}

View file

@ -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,
);
}

View file

@ -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,
);
}

View file

@ -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<TestData>();
@ -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

View file

@ -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,
);
}

View file

@ -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,
);
}

View file

@ -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
return server
}
defer server.Stop()
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
}

View file

@ -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()

View file

@ -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<TestData>()}")
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<Boolean>()
val server = WsServer(HOST, WS_PORT, WS_PATH) { conn ->
private suspend fun runWebSuites() = coroutineScope {
val wsReceived = CompletableDeferred<Boolean>()
val wssReceived = CompletableDeferred<Boolean>()
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<Boolean>,
): WsServer {
val server = WsServer(HOST, port, WS_PATH, sslContext = sslContext) { conn ->
WsClient.forServer(conn, 0, 0, parserMap())
}
server.onClientConnected = { client ->
addRequestListenerTyped<TestData, TestData>(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<Boolean>,
) {
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<TestData>() 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) {

View file

@ -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)
except TimeoutError as exc:
raise RuntimeError(
f"{transport} web send-push server did not receive expected data"
) from exc
if not ok:
raise RuntimeError("WS web send-push server received unexpected data")
finally:
await server.stop()
raise RuntimeError(f"{transport} web send-push server received unexpected data")
async def run_dart_browser_test(test_path: str) -> None:

View file

@ -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.

View file

@ -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<void> {
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<void> {
}
}
async function runWebWs(): Promise<void> {
let resolveReceived: ((value: boolean) => void) | null = null;
const received = new Promise<boolean>((resolve) => {
resolveReceived = resolve;
});
const server = new NodeWsServer(HOST, WS_PORT, WS_PATH, (ws) => new NodeWsClient(ws, 0, 0, parserMap()));
async function runWebSuites(): Promise<void> {
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;
}
void client.communicator.send(
create(TestDataSchema, {
index: 200,
@ -62,16 +88,7 @@ async function runWebWs(): Promise<void> {
});
});
};
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<void> {
@ -130,6 +147,33 @@ function parserMap() {
return new Map([[TestDataSchema.typeName, parserFromSchema(TestDataSchema)]]);
}
function createReceivedSignal(): {
promise: Promise<boolean>;
resolve: (value: boolean) => void;
} {
let done = false;
let resolvePromise: (value: boolean) => void;
const promise = new Promise<boolean>((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 {