diff --git a/.github/workflows/remote-engine-tests.yml b/.github/workflows/remote-engine-tests.yml new file mode 100644 index 0000000..c2b25f9 --- /dev/null +++ b/.github/workflows/remote-engine-tests.yml @@ -0,0 +1,80 @@ +name: Remote engine tests + +# Runs remote_engine_test/ (lib/services/remote_game_engine.dart exercised +# directly against a dockerized CRG scoreboard server, no app UI/emulator +# involved) as a matrix across every supported scoreboard version, and +# publishes each version's results as a separate GitHub Check via +# dorny/test-reporter. +# +# Runs on every PR targeting main, plus manual dispatch. Not yet marked as +# a required check in branch protection - do that once this has proven +# reliable (note: required-status-checks needs each matrix job's check name +# listed individually, or a small aggregator job that depends on the whole +# matrix, since branch protection doesn't understand matrix jobs as a unit). +on: + workflow_dispatch: + pull_request: + branches: [main] + +permissions: + contents: read + checks: write + +jobs: + remote-engine-tests: + name: Remote engine tests (${{ matrix.version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + version: + # Ignoring old v2023.7 until failing test is investigated + # - v2023.7 + - v2025.0 + - v2025.1 + - v2025.2 + - v2025.3 + - v2025.4 + - v2025.5 + - v2025.6 + - v2025.7 + - v2025.8 + - v2025.9 + + steps: + - uses: actions/checkout@v7 + + - uses: subosito/flutter-action@v2 + with: + channel: stable + + - uses: actions/cache@v6 + with: + path: ~/.pub-cache + key: pub-${{ hashFiles('**/pubspec.lock') }} + restore-keys: pub- + + - name: Install dependencies + run: flutter pub get + + - name: Build scoreboard Docker image + run: ./scripts/build-scoreboard-images.sh --versions "${{ matrix.version }}" + + - name: Run remote engine tests + run: ./scripts/test-remote-engine-scoreboards.sh --versions "${{ matrix.version }}" + + - name: Upload test result JSON + if: always() + uses: actions/upload-artifact@v7 + with: + name: remote-engine-test-results-${{ matrix.version }} + path: test-results/*.json + if-no-files-found: warn + + - name: Publish test report + if: always() + uses: dorny/test-reporter@v3 + with: + name: Remote engine tests (${{ matrix.version }}) + path: 'test-results/*.json' + reporter: flutter-json diff --git a/.gitignore b/.gitignore index d42c02b..9418c5d 100644 --- a/.gitignore +++ b/.gitignore @@ -108,6 +108,9 @@ unlinked_spec.ds # Coverage coverage/ +# Machine-readable test reports (scripts/test-*-scoreboards.sh --results-dir) +/test-results/ + # Symbols app.*.symbols diff --git a/integration_test/scoreboard_integration_test.dart b/integration_test/scoreboard_integration_test.dart index f578fa3..1ba8639 100644 --- a/integration_test/scoreboard_integration_test.dart +++ b/integration_test/scoreboard_integration_test.dart @@ -13,6 +13,21 @@ import 'package:jam_ready/widgets/swipe_button.dart'; import 'utilities/common_helpers.dart'; +/// The CRG server's `GameImpl.quickClockControl()` treats two Start/Stop/ +/// Timeout button presses within 1000ms of each other as an accidental +/// double-click: same button -> silently dropped; different button -> the +/// previous action is silently undone and replaced. There's no visible +/// signal when this happens (no error, no rejected message) - it just +/// quietly corrupts game state. `tapJamControl` already guards against this +/// via [JamControls.cooldownDuration] (3s) before every tap, but +/// `swipeToStartLineup` and the overtime confirmation swipe below both send +/// a Stop-button action directly without going through that cooldown. Call +/// this immediately before either to guarantee the 1s window has passed. +const _clockActionCooldown = Duration(seconds: 2); +void _ensureClockActionCooldown() { + sleep(_clockActionCooldown); +} + class ScoreboardTestClient { ScoreboardTestClient(this._channel); @@ -274,6 +289,12 @@ Future _runPeriod( ScoreboardTestClient client, String gameId, ) async { + // Unlike tapJamControl, swipeToStartLineup doesn't wait out + // JamControls.cooldownDuration first. On a second-or-later period this + // Stop-button press can land within 1s of the previous period's final + // tapJamControl(stop), tripping the server's quick-click debounce - see + // _ensureClockActionCooldown above. + _ensureClockActionCooldown(); await swipeToStartLineup(tester); await validateActiveDisplay(tester, ActiveDisplay.lineup); @@ -315,350 +336,212 @@ Future _finishGame( void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - testWidgets('Slide to start lineup starts lineup only', (tester) async { - final client = await _launchAppAndConnect(tester); - addTearDown(client.close); - addTearDown(() async { - await tester.pumpWidget(const SizedBox.shrink()); - await tester.pump(); - }); - - await client.startNewGame(); - await _ensureSwipeToLineup(tester); - - await swipeToStartLineup(tester); - - await pumpUntil(tester, () { - final state = scoreboardState(tester); - return state.clocks['Lineup']!.running && - !state.clocks['Jam']!.running && - !state.clocks['Period']!.running; - }, timeout: const Duration(seconds: 20)); - - await validateActiveDisplay(tester, .lineup); - }); - - testWidgets('Starting initial jam starts jam and period clocks', ( - tester, - ) async { - final client = await _launchAppAndConnect(tester); - addTearDown(client.close); - addTearDown(() async { - await tester.pumpWidget(const SizedBox.shrink()); - await tester.pump(); - }); + testWidgets( + 'Jam, lineup, and undo control flow', + (tester) async { + final client = await _launchAppAndConnect(tester); + addTearDown(client.close); + addTearDown(() async { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + }); - await client.startNewGame(); - await _ensureSwipeToLineup(tester); - await swipeToStartLineup(tester); - await tapJamControl(tester, scoreboardState(tester).labelStart); - await pumpUntil(tester, () { - final state = scoreboardState(tester); - return state.clocks['Jam']!.running && state.clocks['Period']!.running; - }, timeout: const Duration(seconds: 20)); + await client.startNewGame(); + await _ensureSwipeToLineup(tester); - await validateActiveDisplay(tester, .jam); - }); + // Pre-game: no undo action available yet. + await pumpUntil( + tester, + () => !scoreboardState(tester).hasUndoAction, + timeout: const Duration(seconds: 10), + ); + expect( + find.textContaining('NO UNDO AVAILABLE'), + findsOneWidget, + reason: 'Should show NO UNDO AVAILABLE when no action', + ); - testWidgets('Stop jam ends jam and starts lineup', (tester) async { - final client = await _launchAppAndConnect(tester); - addTearDown(client.close); - addTearDown(() async { - await tester.pumpWidget(const SizedBox.shrink()); - await tester.pump(); - }); + // Swiping to start lineup starts the lineup clock only. + await swipeToStartLineup(tester); + await pumpUntil(tester, () { + final state = scoreboardState(tester); + return state.clocks['Lineup']!.running && + !state.clocks['Jam']!.running && + !state.clocks['Period']!.running; + }, timeout: const Duration(seconds: 20)); + await validateActiveDisplay(tester, .lineup); + + // Starting a jam starts the jam and period clocks. + await tapJamControl(tester, scoreboardState(tester).labelStart); + await pumpUntil(tester, () { + final state = scoreboardState(tester); + return state.clocks['Jam']!.running && state.clocks['Period']!.running; + }, timeout: const Duration(seconds: 20)); + await validateActiveDisplay(tester, .jam); + + // Undo (unstart jam) → should return to lineup. + await _swipeUndoButton(tester); + await pumpUntil(tester, () { + final state = scoreboardState(tester); + return state.clocks['Lineup']!.running && + !state.clocks['Jam']!.running; + }, timeout: const Duration(seconds: 20)); + await validateActiveDisplay(tester, ActiveDisplay.lineup); + + // Start the jam again, then stop it — should end the jam and start + // the next lineup. + await tapJamControl(tester, scoreboardState(tester).labelStart); + await pumpUntil( + tester, + () => scoreboardState(tester).clocks['Jam']!.running, + timeout: const Duration(seconds: 20), + ); + await validateActiveDisplay(tester, .jam); + + await tapJamControl(tester, scoreboardState(tester).labelStop); + await pumpUntil(tester, () { + final state = scoreboardState(tester); + return !state.clocks['Jam']!.running && + state.clocks['Lineup']!.running; + }, timeout: const Duration(seconds: 20)); + await validateActiveDisplay(tester, .lineup); + + // Undo is now available and shows the real action label from the + // server (not the "no action" sentinel). + await pumpUntil( + tester, + () => scoreboardState(tester).hasUndoAction, + timeout: const Duration(seconds: 10), + ); + final undoLabel = scoreboardState(tester).labelUndo; + expect( + find.textContaining(undoLabel.toUpperCase()), + findsAtLeast(1), + reason: 'Undo label "$undoLabel" should be visible on screen', + ); + }, + ); - await client.startNewGame(); - await _ensureSwipeToLineup(tester); - await swipeToStartLineup(tester); - await tapJamControl(tester, scoreboardState(tester).labelStart); + testWidgets( + 'Timeout flow highlights controls, decrements review counts, and undo restores timeout', + (tester) async { + final client = await _launchAppAndConnect(tester); + addTearDown(client.close); + addTearDown(() async { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + }); - await pumpUntil( - tester, - () => scoreboardState(tester).clocks['Jam']!.running, - timeout: const Duration(seconds: 20), - ); + await client.startNewGame(); + await _ensureSwipeToLineup(tester); + await swipeToStartLineup(tester); + // Start a jam before calling timeout: review-count decrementing + // only takes effect once a jam has actually run this period. + await tapJamControl(tester, scoreboardState(tester).labelStart); + await pumpUntil( + tester, + () => scoreboardState(tester).clocks['Jam']!.running, + timeout: const Duration(seconds: 20), + ); + await _waitForTeamServerIds(tester); - await validateActiveDisplay(tester, .jam); + await tapJamControl(tester, scoreboardState(tester).labelTimeout); + await _waitForTimeoutMode(tester); + await pumpUntil( + tester, + () => scoreboardState(tester).clocks['Timeout']!.running, + timeout: const Duration(seconds: 20), + ); - await tapJamControl(tester, scoreboardState(tester).labelStop); + // Find timeout menu buttons by their InkWell widgets containing the text + // The new menu uses custom InkWell buttons with uppercase text + final timeoutButtons = find.widgetWithText(InkWell, 'TIMEOUT'); + final reviewButtons = find.widgetWithText(InkWell, 'REVIEW'); + final officialTimeoutButton = find.widgetWithText( + InkWell, + 'OFFICIAL TIMEOUT', + ); - await pumpUntil(tester, () { - final state = scoreboardState(tester); - return !state.clocks['Jam']!.running && state.clocks['Lineup']!.running; - }, timeout: const Duration(seconds: 20)); + await pumpUntil( + tester, + () => + timeoutButtons.evaluate().length >= 2 && + reviewButtons.evaluate().length >= 2 && + officialTimeoutButton.evaluate().isNotEmpty, + ); - await validateActiveDisplay(tester, .lineup); - }); + // Verify initial OR count for WFTDA (1 per team per period). + expect(scoreboardState(tester).team1.officialReviews, 1); + expect(scoreboardState(tester).team2.officialReviews, 1); - testWidgets('Timeout flow highlights controls and undo restores timeout', ( - tester, - ) async { - final client = await _launchAppAndConnect(tester); - addTearDown(client.close); - addTearDown(() async { - await tester.pumpWidget(const SizedBox.shrink()); + await tester.tap(timeoutButtons.first); await tester.pump(); - }); - - await client.startNewGame(); - await _ensureSwipeToLineup(tester); - await swipeToStartLineup(tester); - await _waitForTeamServerIds(tester); - - await tapJamControl(tester, scoreboardState(tester).labelTimeout); - await _waitForTimeoutMode(tester); - await pumpUntil( - tester, - () => scoreboardState(tester).clocks['Timeout']!.running, - timeout: const Duration(seconds: 20), - ); - - // Find timeout menu buttons by their InkWell widgets containing the text - // The new menu uses custom InkWell buttons with uppercase text - final timeoutButtons = find.widgetWithText(InkWell, 'TIMEOUT'); - final reviewButtons = find.widgetWithText(InkWell, 'REVIEW'); - final officialTimeoutButton = find.widgetWithText( - InkWell, - 'OFFICIAL TIMEOUT', - ); - - await pumpUntil( - tester, - () => - timeoutButtons.evaluate().length >= 2 && - reviewButtons.evaluate().length >= 2 && - officialTimeoutButton.evaluate().isNotEmpty, - ); - - await tester.tap(timeoutButtons.first); - await tester.pump(); - await pumpUntil(tester, () { - final state = scoreboardState(tester); - return state.timeoutOwner == state.team1.serverId && - !_isOfficialReview(state); - }); - - await tester.tap(timeoutButtons.at(1)); - await tester.pump(); - await pumpUntil(tester, () { - final state = scoreboardState(tester); - return state.timeoutOwner == state.team2.serverId && - !_isOfficialReview(state); - }); - - await tester.tap(officialTimeoutButton); - await tester.pump(); - await pumpUntil(tester, () => scoreboardState(tester).timeoutOwner == 'O'); - - await tester.tap(reviewButtons.first); - await tester.pump(); - await pumpUntil(tester, () { - final state = scoreboardState(tester); - return state.timeoutOwner == state.team1.serverId && - _isOfficialReview(state); - }); - - await tester.tap(reviewButtons.at(1)); - await tester.pump(); - await pumpUntil(tester, () { - final state = scoreboardState(tester); - return state.timeoutOwner == state.team2.serverId && - _isOfficialReview(state); - }); - - final endTimeoutButton = find.widgetWithText(InkWell, 'END TIMEOUT'); - await tester.ensureVisible(endTimeoutButton); - await tester.tap(endTimeoutButton); - await tester.pump(); - await pumpUntil(tester, () { - final state = scoreboardState(tester); - return state.clocks['Lineup']!.running; - }, timeout: const Duration(seconds: 20)); - - // Wait for undo action to be available - await pumpUntil(tester, () => scoreboardState(tester).hasUndoAction); - - // Swipe the undo button to restore timeout - await _swipeUndoButton(tester); - - await pumpUntil( - tester, - () => scoreboardState(tester).clocks['Timeout']!.running, - timeout: const Duration(seconds: 20), - ); - }); + await pumpUntil(tester, () { + final state = scoreboardState(tester); + return state.timeoutOwner == state.team1.serverId && + !_isOfficialReview(state); + }); - testWidgets('Undo SwipeButton shows No Undo Available when disabled', ( - tester, - ) async { - final client = await _launchAppAndConnect(tester); - addTearDown(client.close); - addTearDown(() async { - await tester.pumpWidget(const SizedBox.shrink()); + await tester.tap(timeoutButtons.at(1)); await tester.pump(); - }); - - await client.startNewGame(); - await _ensureSwipeToLineup(tester); - - // Verify we're in pre-game state with no undo available. - await pumpUntil( - tester, - () => !scoreboardState(tester).hasUndoAction, - timeout: const Duration(seconds: 10), - ); - - // Find the undo SwipeButton - it should show "No Undo Available" - // In remote mode, there should be 2 SwipeButtons: Start Lineup and Undo - final buttons = find.byType(SwipeButton); - await pumpUntil( - tester, - () => buttons.evaluate().length >= 2, - timeout: const Duration(seconds: 10), - ); - - // Check that the undo button text is visible - expect( - find.textContaining('NO UNDO AVAILABLE'), - findsOneWidget, - reason: 'Should show NO UNDO AVAILABLE when no action', - ); - }); + await pumpUntil(tester, () { + final state = scoreboardState(tester); + return state.timeoutOwner == state.team2.serverId && + !_isOfficialReview(state); + }); - testWidgets('Undo SwipeButton shows action label when available', ( - tester, - ) async { - final client = await _launchAppAndConnect(tester); - addTearDown(client.close); - addTearDown(() async { - await tester.pumpWidget(const SizedBox.shrink()); + await tester.tap(officialTimeoutButton); await tester.pump(); - }); - - await client.startNewGame(); - await _ensureSwipeToLineup(tester); - await swipeToStartLineup(tester); - - // Wait for lineup to start - await pumpUntil( - tester, - () => scoreboardState(tester).clocks['Lineup']!.running, - timeout: const Duration(seconds: 20), - ); - - // Start a jam - await tapJamControl(tester, scoreboardState(tester).labelStart); - - // Wait for jam to start - await pumpUntil( - tester, - () => scoreboardState(tester).clocks['Jam']!.running, - timeout: const Duration(seconds: 20), - ); - - // Stop the jam - await tapJamControl(tester, scoreboardState(tester).labelStop); - - // Wait for lineup to start (after jam stop) - await pumpUntil( - tester, - () => scoreboardState(tester).clocks['Lineup']!.running, - timeout: const Duration(seconds: 20), - ); - - // Wait for undo action to become available. - await pumpUntil( - tester, - () => scoreboardState(tester).hasUndoAction, - timeout: const Duration(seconds: 10), - ); - - // Verify the undo button shows the action label from state. - // SwipeButton renders its label in uppercase. - expect(scoreboardState(tester).hasUndoAction, isTrue); - final undoLabel = scoreboardState(tester).labelUndo; - expect( - find.textContaining(undoLabel.toUpperCase()), - findsAtLeast(1), - reason: 'Undo label "$undoLabel" should be visible on screen', - ); - }); + await pumpUntil( + tester, + () => scoreboardState(tester).timeoutOwner == 'O', + ); - testWidgets('Undo after startJam returns to lineup', (tester) async { - final client = await _launchAppAndConnect(tester); - addTearDown(client.close); - addTearDown(() async { - await tester.pumpWidget(const SizedBox.shrink()); + await tester.tap(reviewButtons.first); // assign team 1 official review await tester.pump(); - }); - - await client.startNewGame(); - await _ensureSwipeToLineup(tester); - await swipeToStartLineup(tester); - - await pumpUntil( - tester, - () => scoreboardState(tester).clocks['Lineup']!.running, - timeout: const Duration(seconds: 20), - ); - - // Start jam — this is the action we want to undo - await tapJamControl(tester, scoreboardState(tester).labelStart); - await pumpUntil( - tester, - () => scoreboardState(tester).clocks['Jam']!.running, - timeout: const Duration(seconds: 20), - ); - - // Undo (unstart jam) → should return to lineup - await _swipeUndoButton(tester); - - await pumpUntil(tester, () { - final state = scoreboardState(tester); - return state.clocks['Lineup']!.running && !state.clocks['Jam']!.running; - }, timeout: const Duration(seconds: 20)); - - await validateActiveDisplay(tester, ActiveDisplay.lineup); - }); + // The owner/review-mode fields and the review-count field can arrive + // in separate state messages, so wait for the count too rather than + // asserting immediately after the owner/review-mode condition. + await pumpUntil(tester, () { + final state = scoreboardState(tester); + return state.timeoutOwner == state.team1.serverId && + _isOfficialReview(state) && + state.team1.officialReviews == 0; + }); - testWidgets('Official review assignment decrements review count in UI', ( - tester, - ) async { - final client = await _launchAppAndConnect(tester); - addTearDown(client.close); - addTearDown(() async { - await tester.pumpWidget(const SizedBox.shrink()); + await tester.tap(reviewButtons.at(1)); // assign team 2 official review await tester.pump(); - }); - - await client.startNewGame(); - await _ensureSwipeToLineup(tester); - await swipeToStartLineup(tester); - // start the jam - await tapJamControl(tester, scoreboardState(tester).labelStart); - await _waitForTeamServerIds(tester); + await pumpUntil(tester, () { + final state = scoreboardState(tester); + return state.timeoutOwner == state.team2.serverId && + _isOfficialReview(state) && + state.team2.officialReviews == 0; + }); + // Team 2 review count must have decremented from 1 → 0. + expect(scoreboardState(tester).team2.officialReviews, 0); - // Verify initial OR count for WFTDA (1 per team per period) - expect(scoreboardState(tester).team1.officialReviews, 1); + final endTimeoutButton = find.widgetWithText(InkWell, 'END TIMEOUT'); + await tester.ensureVisible(endTimeoutButton); + await tester.tap(endTimeoutButton); + await tester.pump(); + await pumpUntil(tester, () { + final state = scoreboardState(tester); + return state.clocks['Lineup']!.running; + }, timeout: const Duration(seconds: 20)); - await tapJamControl(tester, scoreboardState(tester).labelTimeout); - await _waitForTimeoutMode(tester); + // Wait for undo action to be available + await pumpUntil(tester, () => scoreboardState(tester).hasUndoAction); - final reviewButtons = find.widgetWithText(InkWell, 'REVIEW'); - await pumpUntil(tester, () => reviewButtons.evaluate().length >= 2); + // Swipe the undo button to restore timeout + await _swipeUndoButton(tester); - await tester.tap(reviewButtons.first); // assign team 1 official review - await tester.pump(); - await pumpUntil(tester, () { - final state = scoreboardState(tester); - return state.timeoutOwner == state.team1.serverId && - _isOfficialReview(state); - }); - - // Team 1 review count must have decremented from 1 → 0 - expect(scoreboardState(tester).team1.officialReviews, 0); - }); + await pumpUntil( + tester, + () => scoreboardState(tester).clocks['Timeout']!.running, + timeout: const Duration(seconds: 20), + ); + }, + ); testWidgets('Full game start/stop', (tester) async { final client = await _launchAppAndConnect(tester); @@ -741,8 +624,13 @@ void main() { timeout: const Duration(seconds: 5), ); - // sleep or server doesn't respect the signal submission - sleep(Duration(seconds: 5)); + // This swipe sends the same Stop-button action as the final + // tapJamControl(stop) above, without tapJamControl's cooldown wait. + // Same button within 1s of itself trips the server's quick-click + // debounce and gets silently dropped - see _ensureClockActionCooldown. + // Kept at 5s (rather than the 2s default) since this is the exact + // transition that was observed to flake. + sleep(const Duration(seconds: 5)); await swipeButton(tester, overtimeButton.first); await validateActiveDisplay( diff --git a/remote_engine_test/remote_engine_scoreboard_test.dart b/remote_engine_test/remote_engine_scoreboard_test.dart new file mode 100644 index 0000000..1ccb7f2 --- /dev/null +++ b/remote_engine_test/remote_engine_scoreboard_test.dart @@ -0,0 +1,486 @@ +// Exercises RemoteGameEngine + ScoreboardState directly against a real +// (dockerized) CRG Scoreboard server over a WebSocket, with no Flutter +// widget tree involved. This mirrors the game-flow logic covered by +// integration_test/scoreboard_integration_test.dart but skips app UI/build, +// which is what makes the widget-driven integration tests so slow. +// +// Requires a CRG scoreboard server reachable at SCOREBOARD_HOST:SCOREBOARD_PORT +// (see scripts/test-remote-engine-scoreboards.sh). +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:jam_ready/models/scoreboard_state.dart'; +import 'package:jam_ready/services/remote_game_engine.dart'; + +import 'utilities/remote_engine_test_helpers.dart'; +import 'utilities/scoreboard_operator_client.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + // Mock the wakelock channel — RemoteGameEngine.connect()/disconnect() + // call WakelockPlus.enable()/disable(), which need a channel handler + // even outside a widget tree. Without this, every test in this file + // fails on an uncaught PlatformException regardless of its assertions. + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler( + 'dev.flutter.pigeon.wakelock_plus_platform_interface.WakelockPlusApi.toggle', + (ByteData? message) async => + const StandardMessageCodec().encodeMessage([null]), + ); + }); + + final host = scoreboardHost(); + final port = scoreboardPort(); + final version = scoreboardVersion(); + + late ScoreboardState state; + late RemoteGameEngine engine; + late ScoreboardOperatorClient operatorClient; + + setUp(() async { + log('--- setUp: connecting engine to ws://$host:$port ---'); + state = ScoreboardState(); + engine = RemoteGameEngine(state); + await engine.connect('ws://$host:$port'); + await waitUntil( + () => engine.isConnected, + timeout: const Duration(seconds: 10), + label: 'engine connects to scoreboard server', + debugState: state, + ); + operatorClient = await ScoreboardOperatorClient.connect( + operatorWsUri(host, port), + ); + log('--- setUp complete ---'); + }); + + tearDown(() async { + engine.disconnect(); + await operatorClient.close(); + log('--- tearDown complete ---'); + }); + + /// Starts a new game and waits through the pre-game countdown. Disables the + /// EnforceTimeToOr rule so [ScoreboardOperatorClient.setOfficialScore] isn't + /// blocked by the 30s timing gate introduced in v2025.9. Returns the game ID. + Future startGame({String ruleset = 'WFTDARuleset'}) async { + log('action: startNewGame (ruleset=$ruleset)'); + await operatorClient.startNewGame( + timeToDerby: const Duration(seconds: 2), + ruleset: ruleset, + ); + await waitUntil( + () => state.gameId.isNotEmpty, + label: 'game ID populated after StartNewGame', + debugState: state, + ); + final gameId = state.gameId; + log('game ID: $gameId'); + operatorClient.disableOfficialScoreRule(gameId); + await waitUntil( + () => !state.clocks['Intermission']!.running, + timeout: const Duration(seconds: 10), + label: 'pre-game countdown finishes', + debugState: state, + ); + return gameId; + } + + /// Runs one period: start lineup → start jam → fast-forward period clock + /// → stop jam. + /// [scoreTeam1Points], if given, awards team 1 that many points partway + /// through the jam — the same WS key ("TripScore") a real operator's + /// scoring button sends, and only effective while a jam is running. Used + /// to break a 0-0 tie so plain full-game tests end decisively instead of + /// landing in the overtime-eligible state (see the dedicated "Overtime + /// transition" test for the tied case). + Future runPeriod(String gameId, {int? scoreTeam1Points}) async { + log('action: stopJam (start lineup)'); + await clockAction(() => engine.stopJam()); // "Slide to Start Lineup" + await waitUntil( + () => state.clocks['Lineup']!.running, + label: 'lineup clock starts', + debugState: state, + ); + + log('action: startJam'); + await clockAction(() => engine.startJam()); + await waitUntil( + () => state.clocks['Jam']!.running, + label: 'jam clock starts', + debugState: state, + ); + + if (scoreTeam1Points != null) { + log('action: award team 1 $scoreTeam1Points points via TripScore'); + operatorClient.addTripScore(gameId, 1, scoreTeam1Points); + await waitUntil( + () => state.team1.score == scoreTeam1Points, + label: 'team 1 score reflects awarded points', + debugState: state, + ); + } + + log('action: fast-forward Period clock to 0'); + operatorClient.setClockTime(gameId, 'Period', 0); + // Wait for the server to round-trip the Period clock update before + // ending the jam — otherwise stopJam can race ahead of the update and + // the server won't recognize the period has ended (it'll advance to + // Lineup instead of Intermission). + await waitUntil( + () => state.clocks['Period']!.time <= 0, + label: 'Period clock reflects fast-forward to 0', + debugState: state, + ); + + log('action: stopJam (end jam)'); + await clockAction(() => engine.stopJam()); + await waitUntil( + () => !state.clocks['Jam']!.running, + label: 'jam clock stops', + debugState: state, + ); + } + + /// Advances through an intermission by fast-forwarding its clock, then + /// waits for the game to be ready for the next period. + Future runIntermission(String gameId) async { + await waitUntil( + () => state.clocks['Intermission']!.running, + label: 'intermission clock starts', + debugState: state, + ); + log('action: fast-forward Intermission clock to 0'); + operatorClient.setClockTime(gameId, 'Intermission', 0); + await waitUntil( + () => !state.clocks['Intermission']!.running, + label: 'intermission clock finishes', + debugState: state, + ); + } + + /// Confirms the official score and waits for it to be reflected in state. + Future finishGame(String gameId) async { + await waitUntil( + () => !state.inJam && !state.clocks['Lineup']!.running, + label: 'post-game unofficial score state reached', + debugState: state, + ); + + // The server enforces a real delay before OfficialScore can be set + // (Rule.LINEUP_DURATION, 30s default), regardless of EnforceTimeToOr. + log('waiting 32s for InhibitFinalScore to clear before confirming score'); + await Future.delayed(const Duration(seconds: 32)); + + log('action: setOfficialScore'); + operatorClient.setOfficialScore(gameId); + await waitUntil( + () => state.officialScore, + label: 'official score confirmed', + debugState: state, + ); + } + + test('Jam, lineup, and undo control flow', () async { + await operatorClient.startNewGame(); + await waitUntil( + () => state.gameId.isNotEmpty, + label: 'game ID populated', + debugState: state, + ); + + // Pre-game: no undo action available yet. + await waitUntil( + () => !state.hasUndoAction, + timeout: const Duration(seconds: 10), + label: 'no undo action available pre-game', + debugState: state, + ); + + log('action: stopJam (start lineup)'); + await clockAction(() => engine.stopJam()); // "Slide to Start Lineup" + await waitUntil( + () => + state.clocks['Lineup']!.running && + !state.clocks['Jam']!.running && + !state.clocks['Period']!.running, + timeout: const Duration(seconds: 20), + label: 'lineup clock starts and jam/period clocks stay stopped', + debugState: state, + ); + + log('action: startJam'); + await clockAction(() => engine.startJam()); + await waitUntil( + () => state.clocks['Jam']!.running && state.clocks['Period']!.running, + timeout: const Duration(seconds: 20), + label: 'jam and period clocks start', + debugState: state, + ); + + // Undo (unstart jam) → should return to lineup. + await waitUntil( + () => state.hasUndoAction, + label: 'undo action becomes available', + debugState: state, + ); + log('action: undo'); + engine.undo(); + await waitUntil( + () => state.clocks['Lineup']!.running && !state.clocks['Jam']!.running, + timeout: const Duration(seconds: 20), + label: 'undo returns to lineup', + debugState: state, + ); + + // Start the jam again, then stop it — should end the jam and start the + // next lineup. + log('action: startJam'); + await clockAction(() => engine.startJam()); + await waitUntil( + () => state.clocks['Jam']!.running, + timeout: const Duration(seconds: 20), + label: 'jam clock starts', + debugState: state, + ); + + log('action: stopJam (end jam)'); + await clockAction(() => engine.stopJam()); + await waitUntil( + () => !state.clocks['Jam']!.running && state.clocks['Lineup']!.running, + timeout: const Duration(seconds: 20), + label: 'jam clock stops and lineup clock starts', + debugState: state, + ); + + // Undo is now available and carries a real label from the server (not + // the "no action" sentinel). + await waitUntil( + () => state.hasUndoAction, + timeout: const Duration(seconds: 10), + label: 'undo action becomes available', + debugState: state, + ); + expect(state.labelUndo, isNot(anyOf('No Action', '---'))); + }); + + test( + 'Timeout flow highlights controls, decrements review counts, and undo restores timeout', + () async { + await operatorClient.startNewGame(); + await waitUntil( + () => state.gameId.isNotEmpty, + label: 'game ID populated', + debugState: state, + ); + + log('action: stopJam (start lineup)'); + await clockAction(() => engine.stopJam()); // start lineup + await waitUntil( + () => state.clocks['Lineup']!.running, + label: 'lineup clock starts', + debugState: state, + ); + + // Start a jam before calling timeout: review-count decrementing only + // takes effect once a jam has actually run this period. + log('action: startJam'); + await clockAction(() => engine.startJam()); + await waitUntil( + () => state.team1.serverId.isNotEmpty && state.team2.serverId.isNotEmpty, + timeout: const Duration(seconds: 20), + label: 'team server IDs populated', + debugState: state, + ); + + // Verify initial OR count for WFTDA (1 per team per period). + expect(state.team1.officialReviews, 1); + expect(state.team2.officialReviews, 1); + + log('action: startTimeout'); + await clockAction(() => engine.startTimeout()); + await waitUntil( + () => state.clocks['Timeout']!.running, + timeout: const Duration(seconds: 20), + label: 'timeout clock starts', + debugState: state, + ); + + log('action: setTimeoutOwner(1)'); + engine.setTimeoutOwner('1'); + await waitUntil( + () => + state.timeoutOwner == state.team1.serverId && + !state.isOfficialReview, + label: 'timeout owner becomes team 1', + debugState: state, + ); + + log('action: setTimeoutOwner(2)'); + engine.setTimeoutOwner('2'); + await waitUntil( + () => + state.timeoutOwner == state.team2.serverId && + !state.isOfficialReview, + label: 'timeout owner becomes team 2', + debugState: state, + ); + + log('action: setTimeoutOwner(O)'); + engine.setTimeoutOwner('O'); + await waitUntil( + () => state.timeoutOwner == 'O', + label: 'timeout owner becomes official', + debugState: state, + ); + + log('action: setTimeoutOwner(1, isOfficialReview: true)'); + engine.setTimeoutOwner('1', isOfficialReview: true); + // The owner/review-mode fields and the review-count field can arrive + // in separate state messages, so wait for the count too rather than + // asserting immediately after the owner/review-mode condition. + await waitUntil( + () => + state.timeoutOwner == state.team1.serverId && + state.isOfficialReview && + state.team1.officialReviews == 0, + label: 'official review owner becomes team 1 and its count decrements', + debugState: state, + ); + + log('action: setTimeoutOwner(2, isOfficialReview: true)'); + engine.setTimeoutOwner('2', isOfficialReview: true); + await waitUntil( + () => + state.timeoutOwner == state.team2.serverId && + state.isOfficialReview && + state.team2.officialReviews == 0, + label: 'official review owner becomes team 2 and its count decrements', + debugState: state, + ); + + log('action: endTimeout'); + await clockAction(() => engine.endTimeout()); + await waitUntil( + () => state.clocks['Lineup']!.running, + timeout: const Duration(seconds: 20), + label: 'lineup clock resumes after timeout', + debugState: state, + ); + + await waitUntil( + () => state.hasUndoAction, + label: 'undo action becomes available', + debugState: state, + ); + + log('action: undo'); + engine.undo(); + + await waitUntil( + () => state.clocks['Timeout']!.running, + timeout: const Duration(seconds: 20), + label: 'timeout clock restored by undo', + debugState: state, + ); + }, + ); + + test( + 'Full game start/stop', + () async { + final gameId = await startGame(); + + log('--- period 1 ---'); + await runPeriod(gameId, scoreTeam1Points: 4); + await runIntermission(gameId); + + log('--- period 2 (final) ---'); + await runPeriod(gameId); + await finishGame(gameId); + }, + // finishGame's 32s wait for InhibitFinalScore to clear pushes this past + // package:test's default 30s per-test timeout. + timeout: const Timeout(Duration(minutes: 2)), + ); + + test( + 'Full game start/stop - RDCL', + () async { + final gameId = await startGame(ruleset: 'RDCLRuleset'); + + log('--- period 1 ---'); + await runPeriod(gameId, scoreTeam1Points: 4); + await runIntermission(gameId); // 5 min + + log('--- period 2 ---'); + await runPeriod(gameId); + await runIntermission(gameId); // 15 min + + log('--- period 3 ---'); + await runPeriod(gameId); + await runIntermission(gameId); // 5 min + + log('--- period 4 (final) ---'); + await runPeriod(gameId); + // Official-score confirmation itself (finishGame) is covered by the + // WFTDA test above and isn't ruleset-specific - skip its 32s wait + // here and just confirm the game reaches the post-game state. + await waitUntil( + () => !state.inJam && !state.clocks['Lineup']!.running, + label: 'post-game unofficial score state reached', + debugState: state, + ); + }, + // RDCL runs 4 periods (vs. WFTDA's 2); package:test's default 30s + // per-test timeout can race ahead of our own waitUntil timeouts before + // all 4 periods + intermissions finish. + timeout: const Timeout(Duration(minutes: 2)), + ); + + test( + 'Overtime transition on tied game', + () async { + // Games started with 0-0 scores are tied, so the server will offer + // overtime after the final period via Label(Stop) = "Overtime Lineup". + final gameId = await startGame(); + + log('--- period 1 ---'); + await runPeriod(gameId); + await runIntermission(gameId); + + log('--- period 2 (final) — scores remain tied at 0-0 ---'); + await runPeriod(gameId); + + await waitUntil( + () => !state.inJam && !state.clocks['Lineup']!.running, + label: 'post-game unofficial score state reached', + debugState: state, + ); + + // The server signals overtime availability via Label(Stop). + await waitUntil( + () => state.labelStop.toLowerCase().contains('overtime'), + timeout: const Duration(seconds: 20), + label: 'server signals overtime availability via Label(Stop)', + debugState: state, + ); + + // Confirming the "Overtime Lineup" swipe button calls stopJam(). + log('action: stopJam (confirm overtime lineup)'); + await clockAction(() => engine.stopJam()); + + await waitUntil( + () => state.clocks['Lineup']!.running, + timeout: const Duration(seconds: 20), + label: 'overtime lineup clock starts', + debugState: state, + ); + }, + skip: versionAtLeast(version, 2025, 4) + ? false + : 'Overtime signaling requires v2025.4+', + ); +} diff --git a/remote_engine_test/utilities/remote_engine_test_helpers.dart b/remote_engine_test/utilities/remote_engine_test_helpers.dart new file mode 100644 index 0000000..8921bcb --- /dev/null +++ b/remote_engine_test/utilities/remote_engine_test_helpers.dart @@ -0,0 +1,121 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:jam_ready/models/scoreboard_state.dart'; + +/// Prints [message] prefixed with the elapsed time since the test process +/// started, so slow steps are easy to spot in CI/script output. +final _stopwatch = Stopwatch()..start(); + +void log(String message) { + final ms = _stopwatch.elapsedMilliseconds; + final seconds = (ms / 1000).toStringAsFixed(1); + // ignore: avoid_print + print('[+${seconds}s] $message'); +} + +/// Polls [condition] until it returns true or [timeout] elapses, logging how +/// long [label] took (or where it timed out). +/// +/// Unlike the widget-test `pumpUntil` helper, this drives real wall-clock +/// time since there is no [WidgetTester] pumping a fake clock — the engine +/// under test is talking to a real, dockerized CRG server over a real +/// WebSocket connection. +Future waitUntil( + bool Function() condition, { + required String label, + Duration timeout = const Duration(seconds: 20), + Duration step = const Duration(milliseconds: 100), + ScoreboardState? debugState, +}) async { + final start = DateTime.now(); + final end = start.add(timeout); + while (DateTime.now().isBefore(end)) { + if (condition()) { + final elapsedMs = DateTime.now().difference(start).inMilliseconds; + log('waited ${elapsedMs}ms for: $label'); + return; + } + await Future.delayed(step); + } + if (!condition()) { + log('TIMED OUT after ${timeout.inMilliseconds}ms waiting for: $label'); + if (debugState != null) log(dumpState(debugState)); + throw TestFailure('Timed out waiting for: $label'); + } +} + +/// The CRG server's `GameImpl.quickClockControl()` treats two Start/Stop/ +/// Timeout button presses within 1000ms of each other as an accidental +/// double-click: same button -> silently dropped; different button -> the +/// previous action is silently undone and replaced. There's no WS-visible +/// signal when this happens (no error, no rejected message), it just quietly +/// corrupts the game state. The app's real UI avoids this by enforcing +/// [JamControls.cooldownDuration] (3s) between taps; this test suite must do +/// the same for every Start/Stop/Timeout action since we call the engine +/// directly with no such client-side cooldown. +const clockActionCooldown = Duration(milliseconds: 1200); +DateTime? _lastClockActionTime; + +/// Runs [action] (a startJam/stopJam/startTimeout/endTimeout call), waiting +/// first if needed so at least [clockActionCooldown] has elapsed since the +/// previous clock action — see [clockActionCooldown] for why this matters. +Future clockAction(void Function() action) async { + final last = _lastClockActionTime; + if (last != null) { + final elapsed = DateTime.now().difference(last); + if (elapsed < clockActionCooldown) { + await Future.delayed(clockActionCooldown - elapsed); + } + } + action(); + _lastClockActionTime = DateTime.now(); +} + +/// Renders the fields most useful for diagnosing a stuck game-flow wait: the +/// clocks (time/running/number), the high-level game flags, and the current +/// labels the server is offering for the jam controls. +String dumpState(ScoreboardState state) { + final clocks = state.clocks.entries + .map((e) => + '${e.key}(time=${e.value.time}, running=${e.value.running}, number=${e.value.number})') + .join(', '); + return 'state snapshot: gameId=${state.gameId} inJam=${state.inJam} ' + 'noMoreJam=${state.noMoreJam} inOvertime=${state.inOvertime} ' + 'officialScore=${state.officialScore} periodCount=${state.periodCount} ' + 'timeoutOwner=${state.timeoutOwner} officialReview=${state.officialReview} ' + 'labelStart=${state.labelStart} labelStop=${state.labelStop} ' + 'labelUndo=${state.labelUndo} clocks=[$clocks]'; +} + +String scoreboardHost() { + const host = String.fromEnvironment('SCOREBOARD_HOST'); + return host.isNotEmpty ? host : '127.0.0.1'; +} + +int scoreboardPort() { + const port = String.fromEnvironment('SCOREBOARD_PORT'); + return int.tryParse(port) ?? 8000; +} + +String scoreboardVersion() { + const version = String.fromEnvironment('SCOREBOARD_VERSION'); + return version; // empty string means unknown / not provided +} + +/// Returns true if the scoreboard version is at least [major].[minor]. +/// An unknown version (empty string) is treated as compatible. +bool versionAtLeast(String version, int major, int minor) { + if (version.isEmpty) return true; + final stripped = version.startsWith('v') ? version.substring(1) : version; + final parts = stripped.split('.'); + final ma = int.tryParse(parts.isNotEmpty ? parts[0] : '') ?? 0; + final mi = int.tryParse(parts.length > 1 ? parts[1] : '') ?? 0; + return ma > major || (ma == major && mi >= minor); +} + +Uri operatorWsUri(String host, int port) { + final base = Uri.parse('ws://$host:$port'); + return base.replace( + path: '/WS/', + queryParameters: const {'source': 'remote_engine_test', 'platform': 'test'}, + ); +} diff --git a/remote_engine_test/utilities/scoreboard_operator_client.dart b/remote_engine_test/utilities/scoreboard_operator_client.dart new file mode 100644 index 0000000..2d22bfb --- /dev/null +++ b/remote_engine_test/utilities/scoreboard_operator_client.dart @@ -0,0 +1,113 @@ +import 'dart:convert'; + +import 'package:web_socket_channel/web_socket_channel.dart'; + +/// Acts as the scoreboard operator/NSO: a second, independent WebSocket +/// connection to the CRG server used to drive game state (start a game, fast +/// forward clocks, confirm the official score) so the [RemoteGameEngine] +/// under test can be exercised as it would be from the app. +class ScoreboardOperatorClient { + ScoreboardOperatorClient(this._channel); + + final WebSocketChannel _channel; + + static Future connect(Uri uri) async { + final channel = WebSocketChannel.connect(uri); + await channel.ready; + return ScoreboardOperatorClient(channel); + } + + Future startNewGame({ + Duration? timeToDerby, + String ruleset = 'WFTDARuleset', + }) async { + _channel.sink.add( + jsonEncode({ + 'action': 'Register', + 'paths': ['ScoreBoard.CurrentGame.Game'], + }), + ); + final message = { + 'action': 'StartNewGame', + 'data': { + 'Team1': '', + 'Team2': '', + 'Ruleset': ruleset, + 'IntermissionClock': timeToDerby != null + ? '${timeToDerby.inMilliseconds}' + : null, + 'Advance': false, + 'Points1': 0, + 'Points2': 0, + 'TO1': 0, + 'TO2': 0, + 'OR1': 0, + 'OR2': 0, + 'Period': 0, + 'Jam': 0, + 'PeriodClock': '0', + }, + }; + + _channel.sink.add(jsonEncode(message)); + // give the server a few seconds to actually get going + await Future.delayed(const Duration(seconds: 2)); + } + + /// Sets a clock time on the server + void setClockTime(String gameId, String clockName, int timeMs) { + _channel.sink.add( + jsonEncode({ + 'action': 'Set', + 'key': 'ScoreBoard.Game($gameId).Clock($clockName).Time', + 'value': timeMs.toString(), + 'flag': '', + }), + ); + } + + /// Awards [points] to a team's current scoring trip — the same WS key a + /// real operator's "+4"-style scoring button sends. Only takes effect + /// while a jam is actually running (`Team.TRIP_SCORE`'s handler in + /// TeamImpl checks `game.isInJam()`); Team.Score itself is a derived value + /// computed from trip scores and can't be set directly. + void addTripScore(String gameId, int teamNumber, int points) { + _channel.sink.add( + jsonEncode({ + 'action': 'Set', + 'key': 'ScoreBoard.Game($gameId).Team($teamNumber).TripScore', + 'value': points.toString(), + 'flag': '', + }), + ); + } + + void disableOfficialScoreRule(String gameId) { + // v2025.9+ enforces a timing gate (INHIBIT_FINAL_SCORE) before OfficialScore + // can be set. Disable the rule so tests don't have to wait 30s after final + // jam end. + _channel.sink.add( + jsonEncode({ + 'action': 'Set', + 'key': 'ScoreBoard.Game($gameId).Rule(Score.EnforceTimeToOr)', + 'value': 'false', + 'flag': '', + }), + ); + } + + void setOfficialScore(String gameId) { + _channel.sink.add( + jsonEncode({ + 'action': 'Set', + 'key': 'ScoreBoard.Game($gameId).OfficialScore', + 'value': true, + 'flag': '', + }), + ); + } + + Future close() async { + await _channel.sink.close(); + } +} diff --git a/scripts/build-scoreboard-images.sh b/scripts/build-scoreboard-images.sh index 9f2743c..0722aaa 100755 --- a/scripts/build-scoreboard-images.sh +++ b/scripts/build-scoreboard-images.sh @@ -3,7 +3,10 @@ # Run this once before running scoreboard integration tests. # Each image is tagged crg-scoreboard:. # -# Usage: ./scripts/build-scoreboard-images.sh +# Usage: ./scripts/build-scoreboard-images.sh [--versions v1,v2,...] +# +# Options: +# --versions Comma-separated list of versions to build (default: all) # # Requires: docker, git @@ -11,7 +14,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -VERSIONS=( +ALL_VERSIONS=( v2023.7 v2025.0 v2025.1 @@ -25,6 +28,18 @@ VERSIONS=( v2025.9 ) +VERSIONS=() +while [[ $# -gt 0 ]]; do + case "$1" in + --versions) IFS=',' read -ra VERSIONS <<< "$2"; shift 2 ;; + *) echo "Unknown option: $1" >&2; exit 1 ;; + esac +done + +if [ ${#VERSIONS[@]} -eq 0 ]; then + VERSIONS=("${ALL_VERSIONS[@]}") +fi + for VERSION in "${VERSIONS[@]}"; do IMAGE="crg-scoreboard:$VERSION" diff --git a/scripts/test-remote-engine-scoreboards.sh b/scripts/test-remote-engine-scoreboards.sh new file mode 100755 index 0000000..7a3a2ce --- /dev/null +++ b/scripts/test-remote-engine-scoreboards.sh @@ -0,0 +1,155 @@ +#!/bin/bash +# Run remote_engine_test/ against each scoreboard Docker image sequentially, +# newest version first. Stops on the first failure. +# +# Unlike test-all-scoreboards.sh, this does NOT launch the app or an +# emulator/simulator — it runs plain Dart tests (via `flutter test`) that +# drive lib/services/remote_game_engine.dart directly against each +# dockerized scoreboard server over a real WebSocket connection. This is +# far faster since there's no app UI/build in the loop. +# +# Run build-scoreboard-images.sh first. +# +# Usage: ./scripts/test-remote-engine-scoreboards.sh [options] [-- flutter test args] +# +# Options: +# --versions Comma-separated list of versions to test (default: all, newest first) +# --host Host address the tests connect to (default: 127.0.0.1) +# --port Docker host port for the scoreboard container (default: 8001) +# --results-dir Directory to write a machine-readable JSON test report per +# version to, via `flutter test --file-reporter` (default: +# test-results). Consume with e.g. dorny/test-reporter +# (reporter: flutter-json) in CI. +# +# Passes any args after -- directly to flutter test. + +set -euo pipefail + +ALL_VERSIONS=( + v2025.9 + v2025.8 + v2025.7 + v2025.6 + v2025.5 + v2025.4 + v2025.3 + v2025.2 + v2025.1 + v2025.0 + v2023.7 +) + +VERSIONS=() +HOST="127.0.0.1" +PORT=8001 +RESULTS_DIR="test-results" +FLUTTER_ARGS=() + +# --------------------------------------------------------------------------- +# Argument parsing +# --------------------------------------------------------------------------- +while [[ $# -gt 0 ]]; do + case "$1" in + --versions) IFS=',' read -ra VERSIONS <<< "$2"; shift 2 ;; + --host) HOST="$2"; shift 2 ;; + --port) PORT="$2"; shift 2 ;; + --results-dir) RESULTS_DIR="$2"; shift 2 ;; + --) shift; FLUTTER_ARGS=("$@"); break ;; + *) echo "Unknown option: $1" >&2; exit 1 ;; + esac +done + +mkdir -p "$RESULTS_DIR" + +if [ ${#VERSIONS[@]} -eq 0 ]; then + VERSIONS=("${ALL_VERSIONS[@]}") +fi + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +CONTAINER="crg-scoreboard-remote-engine-test" + +cleanup() { + docker rm -f "$CONTAINER" &>/dev/null || true +} +trap cleanup EXIT + +echo "" +echo "--> Testing ${#VERSIONS[@]} version(s) on $HOST:$PORT" +echo "--> Versions: ${VERSIONS[*]}" + +PASS=() + +for VERSION in "${VERSIONS[@]}"; do + IMAGE="crg-scoreboard:$VERSION" + + echo "" + echo "========================================" + echo " $IMAGE (host: $HOST port: $PORT)" + echo "========================================" + + if ! docker image inspect "$IMAGE" &>/dev/null; then + echo "error: image $IMAGE not found — run build-scoreboard-images.sh first" >&2 + exit 1 + fi + + docker rm -f "$CONTAINER" &>/dev/null || true + + echo "--> Starting $IMAGE on 0.0.0.0:$PORT" + docker run -d \ + --name "$CONTAINER" \ + --publish "0.0.0.0:$PORT:8000" \ + "$IMAGE" + + echo "--> Waiting for scoreboard to be ready..." + ATTEMPTS=0 + until curl -sf "http://127.0.0.1:$PORT/" -o /dev/null 2>/dev/null; do + ATTEMPTS=$((ATTEMPTS + 1)) + if [ $ATTEMPTS -ge 30 ]; then + echo "error: scoreboard did not start within 30s" >&2 + docker logs "$CONTAINER" >&2 + exit 1 + fi + sleep 1 + done + echo "--> Scoreboard ready" + + echo "--> Running tests" + TEST_LOG=$(mktemp) + REPORT_FILE="$RESULTS_DIR/remote-engine-$VERSION.json" + EXIT_CODE=0 + flutter test remote_engine_test/ \ + --dart-define=SCOREBOARD_HOST="$HOST" \ + --dart-define=SCOREBOARD_PORT="$PORT" \ + --dart-define=SCOREBOARD_VERSION="$VERSION" \ + --file-reporter="json:$REPORT_FILE" \ + "${FLUTTER_ARGS[@]+"${FLUTTER_ARGS[@]}"}" \ + 2>&1 | tee "$TEST_LOG" \ + || EXIT_CODE=$? + + docker rm -f "$CONTAINER" &>/dev/null || true + + if [ $EXIT_CODE -eq 0 ]; then + PASS+=("$VERSION") + echo "--> PASS: $VERSION (report: $REPORT_FILE)" + rm -f "$TEST_LOG" + else + echo "--> FAIL: $VERSION (exit $EXIT_CODE, report: $REPORT_FILE)" + rm -f "$TEST_LOG" + echo "" + echo "========================================" + echo " Stopped at first failure" + echo "========================================" + for V in "${PASS[@]+"${PASS[@]}"}"; do echo " PASS $V"; done + echo " FAIL $VERSION" + exit 1 + fi +done + +echo "" +echo "========================================" +echo " All versions passed" +echo "========================================" +for V in "${PASS[@]+"${PASS[@]}"}"; do echo " PASS $V"; done