diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml new file mode 100644 index 0000000..c0837f8 --- /dev/null +++ b/.github/workflows/smoke.yml @@ -0,0 +1,51 @@ +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: flutter pub get + + - name: Run smoke + env: + ASOBI_URL: http://localhost:8084 + # `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() + 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..c9e0bf3 --- /dev/null +++ b/smoke_tests/smoke.dart @@ -0,0 +1,165 @@ +// 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. +// +// 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. + +import 'dart:async'; +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)); + +void main() { + test('canonical flame_asobi smoke against sdk_demo_backend', () 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}'); + + 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); + } + }); + + 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'); + }, timeout: _overallTimeout); +} + +// ---- 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'); +}