// The shell's sustained-failure banner must name the actual // problem: a hub that rejects the token (UNAUTHENTICATED / // PERMISSION_DENIED) is UP — telling the operator "can't reach // the hub" sends them to fix the endpoint when the token is what // needs attention. The probe result is faked through // StudioShellState.debugProbeOverride; the banner appears after // three consecutive failed polls (5 s apart), so each test pumps // the poll timer forward instead of waiting. import 'package:chain_client_sdk/chain_client_sdk.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:chain_studio/data/hub.dart'; import 'package:chain_studio/main.dart'; Future _pumpShell(WidgetTester tester) async { await tester.pumpWidget( const StudioApp( initialThemeMode: ThemeModeValue.system, initialLocale: Locale('en'), ), ); // pump (not pumpAndSettle): the app has long-lived timers that // never settle. One frame lays the shell out, then three poll // ticks cross the banner threshold. await tester.pump(const Duration(milliseconds: 100)); for (var i = 0; i < 3; i++) { await tester.pump(const Duration(seconds: 5)); await tester.pump(); } } void main() { tearDown(() { StudioShellState.debugProbeOverride = null; StudioShellState.debugReloadTokenOverride = null; }); testWidgets('sustained auth rejection points at the token, not the wire', (tester) async { StudioShellState.debugProbeOverride = () async => HubProbeResult.authRejected; await _pumpShell(tester); expect(find.textContaining('rejected the sign-in'), findsOneWidget); expect(find.textContaining("Can't reach"), findsNothing); // Token fixed (probe turns serving): the banner must clear. StudioShellState.debugProbeOverride = () async => HubProbeResult.serving; await tester.pump(const Duration(seconds: 5)); await tester.pump(); expect(find.textContaining('rejected the sign-in'), findsNothing); }); testWidgets('sustained connection failure keeps the unreachable wording', (tester) async { StudioShellState.debugProbeOverride = () async => HubProbeResult.unreachable; await _pumpShell(tester); expect(find.textContaining("Can't reach"), findsOneWidget); expect(find.textContaining('rejected the sign-in'), findsNothing); }); testWidgets('auth-rejected poll re-reads the token file and re-probes', (tester) async { // First probe(s) rejected; after the token file "changes" the // immediate re-probe serves — the operator fixed the token via // CLI/editor and Studio healed without waiting a poll cycle. var probes = 0; var reloads = 0; StudioShellState.debugProbeOverride = () async { probes++; return reloads > 0 ? HubProbeResult.serving : HubProbeResult.authRejected; }; StudioShellState.debugReloadTokenOverride = () async { reloads++; return true; // token file content changed → reconnected }; await _pumpShell(tester); expect(reloads, greaterThanOrEqualTo(1)); // The self-heal re-probe ran within the same tick (probes > // reloads means at least one immediate retry happened) and the // banner never surfaces. expect(probes, greaterThan(reloads)); expect(find.textContaining('rejected the sign-in'), findsNothing); expect(find.textContaining("Can't reach"), findsNothing); }); testWidgets('daemonAnswers treats auth-rejected as alive', (tester) async { StudioShellState.debugProbeOverride = () async => HubProbeResult.authRejected; expect(await StudioShellState.daemonAnswers(), isTrue); StudioShellState.debugProbeOverride = () async => HubProbeResult.unreachable; expect(await StudioShellState.daemonAnswers(), isFalse); }); }