From e51f74449650e5538fc44daf2e5a1e5dca60819e Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Fri, 1 May 2026 19:18:28 +0200 Subject: [PATCH 1/2] feat(smoke): add canonical SDK smoke against sdk_demo_backend Adds smoke_tests/smoke.dart implementing the three canonical scenarios defined in widgrensit/sdk_demo_backend/SMOKE.md (auth + WS connect, matchmaker -> match.matched, match.input -> match.state with x > x_initial + 10). The smoke imports only from package:flame_asobi/flame_asobi.dart so a green run also proves the bridge package's re-export contract holds for AsobiClient, AsobiRealtime, and the realtime event streams. The Flame mixins (HasAsobi, HasAsobiInput, AsobiNetworkSync, ...) require a running FlameGame loop and are not exercised here. Adds a GitHub Actions workflow (.github/workflows/smoke.yml) that checks out widgrensit/sdk_demo_backend, brings up its docker compose stack, runs the smoke against http://localhost:8084, dumps backend logs on failure, and tears the stack down. Pins asobi to a git ref on main until asobi-dart publishes the post-PR-#14 release that drops typed MatchInput in favour of Map; revert to a pub.dev version (>= 0.2.0) once published. --- .github/workflows/smoke.yml | 47 +++++++++++ pubspec.yaml | 9 ++- smoke_tests/smoke.dart | 156 ++++++++++++++++++++++++++++++++++++ 3 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/smoke.yml create mode 100644 smoke_tests/smoke.dart diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml new file mode 100644 index 0000000..9fdcba8 --- /dev/null +++ b/.github/workflows/smoke.yml @@ -0,0 +1,47 @@ +name: smoke + +on: + pull_request: + push: + branches: [main] + +jobs: + smoke: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout flame_asobi + uses: actions/checkout@v4 + + - name: Checkout sdk_demo_backend + uses: actions/checkout@v4 + with: + repository: widgrensit/sdk_demo_backend + path: _backend + + - name: Bring up sdk_demo_backend + working-directory: _backend + run: docker compose up -d + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + + - name: Resolve dependencies + run: dart pub get + + - name: Run smoke + env: + ASOBI_URL: http://localhost:8084 + run: dart run smoke_tests/smoke.dart + + - name: Backend logs on failure + if: failure() + working-directory: _backend + run: docker compose logs asobi + + - name: Tear down + if: always() + working-directory: _backend + run: docker compose down -v diff --git a/pubspec.yaml b/pubspec.yaml index 2dbc375..bc7b8a1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,7 +17,14 @@ dependencies: flutter: sdk: flutter flame: ^1.36.0 - asobi: ^0.1.0 + # Pinned to git until asobi-dart publishes the post-PR-#14 release that + # drops the typed MatchInput in favour of `Map`. The + # smoke test depends on the new sendMatchInput signature; bump back to + # a pub.dev version (>= 0.2.0) once published. + asobi: + git: + url: https://github.com/widgrensit/asobi-dart + ref: main dev_dependencies: flutter_test: diff --git a/smoke_tests/smoke.dart b/smoke_tests/smoke.dart new file mode 100644 index 0000000..f56c3ae --- /dev/null +++ b/smoke_tests/smoke.dart @@ -0,0 +1,156 @@ +// Smoke test for flame_asobi against widgrensit/sdk_demo_backend. +// +// Imports only from package:flame_asobi/flame_asobi.dart to verify the +// re-export contract: AsobiClient, AsobiRealtime, etc. must reach users +// of flame_asobi without a separate `package:asobi` dependency. +// +// The Flame mixins (HasAsobi, HasAsobiInput, AsobiNetworkSync, ...) need +// a running FlameGame loop, so they are not exercised here. Proving the +// underlying AsobiClient still works through the bridge package is +// enough to gate releases on the SDK contract. +// +// Expects the backend running at ASOBI_URL (default localhost:8084). +// See widgrensit/sdk_demo_backend/SMOKE.md for the canonical scenarios. + +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; + +import 'package:flame_asobi/flame_asobi.dart'; + +const _matchMode = 'demo'; +const _startupTimeout = Duration(seconds: 60); +const _matchJoinTimeout = Duration(seconds: 10); +const _stateTimeout = Duration(seconds: 3); + +Future main() async { + final url = _parseUrl( + Platform.environment['ASOBI_URL'] ?? 'http://localhost:8084', + ); + + _log('Waiting for backend at ${url['host']}:${url['port']}'); + await _waitForServer(url); + _log('Backend reachable.'); + + final a = await _spawnPlayer('a', url); + final b = await _spawnPlayer('b', url); + _log('Registered: ${a.playerId} | ${b.playerId}'); + + // Attach match.matched listeners BEFORE queuing to avoid a race + // with the server pairing us immediately. + final matchedA = a.client.realtime.onMatchmakerMatched.stream.first + .timeout(_matchJoinTimeout); + final matchedB = b.client.realtime.onMatchmakerMatched.stream.first + .timeout(_matchJoinTimeout); + + await a.client.realtime.addToMatchmaker(mode: _matchMode); + await b.client.realtime.addToMatchmaker(mode: _matchMode); + _log('Both queued.'); + + final matchA = await matchedA; + final matchB = await matchedB; + _log('Both matched, match_id = ${matchA.matchId}'); + + if (matchA.matchId != matchB.matchId) { + throw Exception( + 'match_id mismatch: ${matchA.matchId} vs ${matchB.matchId}', + ); + } + + // match.input -> match.state applied. + // Capture x_initial from the FIRST match.state for the local player, + // then assert a subsequent state shows x > x_initial + 10. Spawn x is + // random in [50, 700], so an `x >= 1` check would trivially pass. + double? xInitial; + final movedCompleter = Completer(); + final sub = a.client.realtime.onMatchState.stream.listen((state) { + final me = state.players[a.playerId]; + if (me == null) { + return; + } + if (xInitial == null) { + xInitial = me.x + 0.0; + _log('x_initial = $xInitial'); + a.client.realtime.sendMatchInput({'move_x': 1, 'move_y': 0}); + return; + } + if (!movedCompleter.isCompleted && me.x > xInitial! + 10) { + movedCompleter.complete(me); + } + }); + + final me = await movedCompleter.future.timeout(_stateTimeout); + await sub.cancel(); + _log('match.state confirmed: x = ${me.x} (was $xInitial)'); + + await a.client.realtime.disconnect(); + await b.client.realtime.disconnect(); + _log('PASS'); + exit(0); +} + +// ---- helpers ---- + +class _Player { + final AsobiClient client; + final String playerId; + _Player(this.client, this.playerId); +} + +Future<_Player> _spawnPlayer(String label, Map url) async { + final client = AsobiClient( + url['host'] as String, + port: url['port'] as int, + useSsl: url['useSsl'] as bool, + ); + final rng = Random(); + final ts = DateTime.now().millisecondsSinceEpoch; + final username = 'smoke_${label}_${ts}_${rng.nextInt(10000)}'; + final res = await client.auth.register( + username, + 'smoke_pw_12345', + displayName: username, + ); + await client.realtime.connect(autoReconnect: false); + return _Player(client, res.playerId); +} + +Map _parseUrl(String url) { + final uri = Uri.parse(url); + return { + 'host': uri.host, + 'port': uri.port == 0 ? (uri.scheme == 'https' ? 443 : 80) : uri.port, + 'useSsl': uri.scheme == 'https', + }; +} + +Future _waitForServer(Map url) async { + final deadline = DateTime.now().add(_startupTimeout); + final host = url['host'] as String; + final port = url['port'] as int; + final useSsl = url['useSsl'] as bool; + final scheme = useSsl ? 'https' : 'http'; + final client = HttpClient(); + while (DateTime.now().isBefore(deadline)) { + try { + final req = await client.getUrl( + Uri.parse('$scheme://$host:$port/api/v1/auth/register'), + ); + final res = await req.close(); + await res.drain(); + if (res.statusCode < 500) { + client.close(); + return; + } + } on Exception catch (_) { + // connection refused, keep polling + } + await Future.delayed(const Duration(seconds: 1)); + } + client.close(); + throw Exception('backend never became reachable at $scheme://$host:$port'); +} + +void _log(Object? msg) { + stderr.writeln('[smoke] $msg'); +} From f2ea0deb119ed251efdd3eb8b7668296ecaf884c Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Fri, 1 May 2026 19:22:59 +0200 Subject: [PATCH 2/2] fix(smoke): run via flutter test, not dart run flame_asobi transitively imports `package:flame`, which depends on `dart:ui`. The standalone Dart VM does not expose `dart:ui`, so `dart run smoke_tests/smoke.dart` fails at compile time. The Flutter test VM does expose `dart:ui`, so wrapping the smoke in a `test()` block and invoking it through `flutter test` keeps the package:flame_asobi-only re-export contract intact while letting the binary actually compile and run. Functionally the smoke is unchanged: a single async test, identical canonical flow, exits non-zero on assertion or timeout. Verified locally against `widgrensit/sdk_demo_backend` running on localhost:8084: PASS. --- .github/workflows/smoke.yml | 8 ++- smoke_tests/smoke.dart | 127 +++++++++++++++++++----------------- 2 files changed, 74 insertions(+), 61 deletions(-) diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index 9fdcba8..c0837f8 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -29,12 +29,16 @@ jobs: channel: stable - name: Resolve dependencies - run: dart pub get + run: flutter pub get - name: Run smoke env: ASOBI_URL: http://localhost:8084 - run: dart run smoke_tests/smoke.dart + # `flutter test` is required because flame_asobi transitively + # imports `package:flame`, which depends on `dart:ui`. The test + # itself is a single async test() and exits non-zero on any + # assertion or timeout, just like a console smoke would. + run: flutter test smoke_tests/smoke.dart --reporter expanded - name: Backend logs on failure if: failure() diff --git a/smoke_tests/smoke.dart b/smoke_tests/smoke.dart index f56c3ae..c9e0bf3 100644 --- a/smoke_tests/smoke.dart +++ b/smoke_tests/smoke.dart @@ -9,6 +9,12 @@ // underlying AsobiClient still works through the bridge package is // enough to gate releases on the SDK contract. // +// Runs under `flutter test` rather than plain `dart run` because +// transitively importing `package:flame` pulls `dart:ui`, which the +// standalone Dart VM does not expose. Functionally this is still a +// console smoke — there is no widget tree, just one async test that +// exits non-zero on any failure or timeout. +// // Expects the backend running at ASOBI_URL (default localhost:8084). // See widgrensit/sdk_demo_backend/SMOKE.md for the canonical scenarios. @@ -17,76 +23,79 @@ import 'dart:io'; import 'dart:math'; import 'package:flame_asobi/flame_asobi.dart'; +import 'package:flutter_test/flutter_test.dart'; const _matchMode = 'demo'; const _startupTimeout = Duration(seconds: 60); const _matchJoinTimeout = Duration(seconds: 10); const _stateTimeout = Duration(seconds: 3); +const _overallTimeout = Timeout(Duration(seconds: 90)); -Future main() async { - final url = _parseUrl( - Platform.environment['ASOBI_URL'] ?? 'http://localhost:8084', - ); - - _log('Waiting for backend at ${url['host']}:${url['port']}'); - await _waitForServer(url); - _log('Backend reachable.'); - - final a = await _spawnPlayer('a', url); - final b = await _spawnPlayer('b', url); - _log('Registered: ${a.playerId} | ${b.playerId}'); - - // Attach match.matched listeners BEFORE queuing to avoid a race - // with the server pairing us immediately. - final matchedA = a.client.realtime.onMatchmakerMatched.stream.first - .timeout(_matchJoinTimeout); - final matchedB = b.client.realtime.onMatchmakerMatched.stream.first - .timeout(_matchJoinTimeout); - - await a.client.realtime.addToMatchmaker(mode: _matchMode); - await b.client.realtime.addToMatchmaker(mode: _matchMode); - _log('Both queued.'); - - final matchA = await matchedA; - final matchB = await matchedB; - _log('Both matched, match_id = ${matchA.matchId}'); +void main() { + test('canonical flame_asobi smoke against sdk_demo_backend', () async { + final url = _parseUrl( + Platform.environment['ASOBI_URL'] ?? 'http://localhost:8084', + ); - if (matchA.matchId != matchB.matchId) { - throw Exception( - 'match_id mismatch: ${matchA.matchId} vs ${matchB.matchId}', + _log('Waiting for backend at ${url['host']}:${url['port']}'); + await _waitForServer(url); + _log('Backend reachable.'); + + final a = await _spawnPlayer('a', url); + final b = await _spawnPlayer('b', url); + _log('Registered: ${a.playerId} | ${b.playerId}'); + + // Attach match.matched listeners BEFORE queuing to avoid a race + // with the server pairing us immediately. + final matchedA = a.client.realtime.onMatchmakerMatched.stream.first + .timeout(_matchJoinTimeout); + final matchedB = b.client.realtime.onMatchmakerMatched.stream.first + .timeout(_matchJoinTimeout); + + await a.client.realtime.addToMatchmaker(mode: _matchMode); + await b.client.realtime.addToMatchmaker(mode: _matchMode); + _log('Both queued.'); + + final matchA = await matchedA; + final matchB = await matchedB; + _log('Both matched, match_id = ${matchA.matchId}'); + + expect( + matchA.matchId, + matchB.matchId, + reason: 'both clients must receive the same match_id', ); - } - // match.input -> match.state applied. - // Capture x_initial from the FIRST match.state for the local player, - // then assert a subsequent state shows x > x_initial + 10. Spawn x is - // random in [50, 700], so an `x >= 1` check would trivially pass. - double? xInitial; - final movedCompleter = Completer(); - final sub = a.client.realtime.onMatchState.stream.listen((state) { - final me = state.players[a.playerId]; - if (me == null) { - return; - } - if (xInitial == null) { - xInitial = me.x + 0.0; - _log('x_initial = $xInitial'); - a.client.realtime.sendMatchInput({'move_x': 1, 'move_y': 0}); - return; - } - if (!movedCompleter.isCompleted && me.x > xInitial! + 10) { - movedCompleter.complete(me); - } - }); + // match.input -> match.state applied. + // Capture x_initial from the FIRST match.state for the local player, + // then assert a subsequent state shows x > x_initial + 10. Spawn x + // is random in [50, 700], so an `x >= 1` check would trivially pass. + double? xInitial; + final movedCompleter = Completer(); + final sub = a.client.realtime.onMatchState.stream.listen((state) { + final me = state.players[a.playerId]; + if (me == null) { + return; + } + if (xInitial == null) { + xInitial = me.x + 0.0; + _log('x_initial = $xInitial'); + a.client.realtime.sendMatchInput({'move_x': 1, 'move_y': 0}); + return; + } + if (!movedCompleter.isCompleted && me.x > xInitial! + 10) { + movedCompleter.complete(me); + } + }); - final me = await movedCompleter.future.timeout(_stateTimeout); - await sub.cancel(); - _log('match.state confirmed: x = ${me.x} (was $xInitial)'); + final me = await movedCompleter.future.timeout(_stateTimeout); + await sub.cancel(); + _log('match.state confirmed: x = ${me.x} (was $xInitial)'); - await a.client.realtime.disconnect(); - await b.client.realtime.disconnect(); - _log('PASS'); - exit(0); + await a.client.realtime.disconnect(); + await b.client.realtime.disconnect(); + _log('PASS'); + }, timeout: _overallTimeout); } // ---- helpers ----