diff --git a/lib/data/friendly_error.dart b/lib/data/friendly_error.dart index e415485..a375b2a 100644 --- a/lib/data/friendly_error.dart +++ b/lib/data/friendly_error.dart @@ -217,6 +217,47 @@ FriendlyError? _matchHubPattern(String detail, AppLocalizations l) { return null; } +/// How a page-level load failure should be presented. Every page +/// that fetches its content from the hub used to fold ANY failure +/// into "hub not reachable", which contradicted the sidebar's +/// green connected dot whenever the hub answered but the RPC +/// failed (version skew, feature gates, real bugs). The shared +/// classification keeps "not reachable" reserved for genuine +/// connection failures. +enum HubLoadIssue { + /// Socket-level failure or gRPC UNAVAILABLE / DEADLINE_EXCEEDED: + /// the hub itself cannot be reached. + unreachable, + + /// gRPC UNIMPLEMENTED: the hub answered, but its version + /// predates the RPC this view needs — connected, just too old. + unsupported, + + /// Anything else — show the friendly error with copyable detail. + other, +} + +/// Classify a page-load failure (see [HubLoadIssue]). +HubLoadIssue classifyHubLoadError(Object error) { + switch (grpcCodeOf(error)) { + case 12: // UNIMPLEMENTED + return HubLoadIssue.unsupported; + case 4: // DEADLINE_EXCEEDED + case 14: // UNAVAILABLE + return HubLoadIssue.unreachable; + } + // Non-gRPC failures: only clear socket-level shapes count as + // "unreachable"; everything else keeps its real story. + final s = error.toString().toLowerCase(); + if (s.contains('socketexception') || + s.contains('connection refused') || + s.contains('connection terminated') || + s.contains('failed to connect')) { + return HubLoadIssue.unreachable; + } + return HubLoadIssue.other; +} + /// Duck-typed `GrpcError.code` reader — public so pages that /// classify errors themselves (e.g. the runs monitor separating /// "hub down" from "hub too old") share one accessor instead of diff --git a/lib/data/hub.dart b/lib/data/hub.dart index d0dc1cd..8446677 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -20,7 +20,17 @@ export 'flow_output.dart'; class HubService { HubService._(); - static final HubService instance = HubService._(); + static HubService instance = HubService._(); + + /// Test hook: replace the app-wide instance with a scriptable + /// fake so widget suites are hermetic — they must never depend + /// on whether a real hub happens to listen on the operator's + /// machine (the state-matrix suite injects per-RPC behaviours + /// through this). Pass null to restore a real instance. + @visibleForTesting + static void debugSetInstance(HubService? replacement) { + instance = replacement ?? HubService._(); + } HubClient _client = HubClient(); diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 09f7b7d..b682023 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1357,6 +1357,9 @@ "n8nHint": "Aktive n8n-Workflows erscheinen als n8n.. Fähigkeiten. Einmal konfigurieren, im Store sichtbar.", "n8nEmpty": "Keine n8n-Endpunkte konfiguriert. + klicken zum Hinzufügen.", "hubUnreachable": "Hub nicht erreichbar", + "hubViewTooOldTitle": "Diese Ansicht braucht eine neuere Hub-Version", + "hubViewTooOldHint": "Der Hub ist verbunden, aber seine Version kennt diese Ansicht noch nicht. Aktualisieren Sie den Hub (Diagnose → Update), dann erscheint der Inhalt hier.", + "hubViewLoadFailedTitle": "Diese Ansicht konnte nicht geladen werden", "hubUnreachableHint": "Hub starten mit chain serve.", "languageEnglish": "English", "languageGerman": "Deutsch", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8280776..fddce37 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1381,6 +1381,9 @@ "n8nHint": "Active n8n workflows surface as n8n.. capabilities. Configure once, see them in the Store.", "n8nEmpty": "No n8n endpoints configured. Click + to add one.", "hubUnreachable": "Hub unreachable", + "hubViewTooOldTitle": "This view needs a newer hub version", + "hubViewTooOldHint": "The hub is connected, but its version does not know this view yet. Update the hub (Doctor → Update) and the content will appear here.", + "hubViewLoadFailedTitle": "This view could not be loaded", "hubUnreachableHint": "Start the hub with chain serve.", "languageEnglish": "English", "languageGerman": "Deutsch", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index d2a8e09..ed87ba2 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -4076,6 +4076,24 @@ abstract class AppLocalizations { /// **'Hub unreachable'** String get hubUnreachable; + /// No description provided for @hubViewTooOldTitle. + /// + /// In en, this message translates to: + /// **'This view needs a newer hub version'** + String get hubViewTooOldTitle; + + /// No description provided for @hubViewTooOldHint. + /// + /// In en, this message translates to: + /// **'The hub is connected, but its version does not know this view yet. Update the hub (Doctor → Update) and the content will appear here.'** + String get hubViewTooOldHint; + + /// No description provided for @hubViewLoadFailedTitle. + /// + /// In en, this message translates to: + /// **'This view could not be loaded'** + String get hubViewLoadFailedTitle; + /// No description provided for @hubUnreachableHint. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 4a56518..256e206 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2399,6 +2399,18 @@ class AppLocalizationsDe extends AppLocalizations { @override String get hubUnreachable => 'Hub nicht erreichbar'; + @override + String get hubViewTooOldTitle => + 'Diese Ansicht braucht eine neuere Hub-Version'; + + @override + String get hubViewTooOldHint => + 'Der Hub ist verbunden, aber seine Version kennt diese Ansicht noch nicht. Aktualisieren Sie den Hub (Diagnose → Update), dann erscheint der Inhalt hier.'; + + @override + String get hubViewLoadFailedTitle => + 'Diese Ansicht konnte nicht geladen werden'; + @override String get hubUnreachableHint => 'Hub starten mit chain serve.'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index f20b7e0..f9ad6e4 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2400,6 +2400,16 @@ class AppLocalizationsEn extends AppLocalizations { @override String get hubUnreachable => 'Hub unreachable'; + @override + String get hubViewTooOldTitle => 'This view needs a newer hub version'; + + @override + String get hubViewTooOldHint => + 'The hub is connected, but its version does not know this view yet. Update the hub (Doctor → Update) and the content will appear here.'; + + @override + String get hubViewLoadFailedTitle => 'This view could not be loaded'; + @override String get hubUnreachableHint => 'Start the hub with chain serve.'; diff --git a/lib/pages/approvals.dart b/lib/pages/approvals.dart index c98f5ed..59bacdb 100644 --- a/lib/pages/approvals.dart +++ b/lib/pages/approvals.dart @@ -89,6 +89,12 @@ class _ApprovalsPageState extends State limit: 200, project: project, ); + // Only the visible tab has a FutureBuilder listening; give the + // hidden tab's future a silent listener so a load failure there + // never surfaces as an uncaught async error (the FutureBuilder + // that attaches on tab switch still receives the error). + _pendingFuture.then((_) {}, onError: (_) {}); + _historyFuture.then((_) {}, onError: (_) {}); }); } @@ -331,7 +337,6 @@ class _PendingList extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = Theme.of(context); final l = AppLocalizations.of(context)!; return FutureBuilder>( future: future, @@ -340,16 +345,7 @@ class _PendingList extends StatelessWidget { return const Center(child: CircularProgressIndicator()); } if (snapshot.hasError) { - return ChainEmptyState( - icon: Icons.cloud_off_outlined, - iconColor: theme.colorScheme.error, - title: l.hubUnreachable, - hint: l.hubUnreachableHint, - action: FilledButton.tonal( - onPressed: onRetry, - child: Text(l.buttonRetry), - ), - ); + return HubLoadErrorView(error: snapshot.error!, onRetry: onRetry); } final pending = snapshot.data ?? []; if (pending.isEmpty) { @@ -513,7 +509,6 @@ class _HistoryList extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = Theme.of(context); final l = AppLocalizations.of(context)!; return FutureBuilder>( future: future, @@ -522,16 +517,7 @@ class _HistoryList extends StatelessWidget { return const Center(child: CircularProgressIndicator()); } if (snapshot.hasError) { - return ChainEmptyState( - icon: Icons.cloud_off_outlined, - iconColor: theme.colorScheme.error, - title: l.hubUnreachable, - hint: l.hubUnreachableHint, - action: FilledButton.tonal( - onPressed: onRetry, - child: Text(l.buttonRetry), - ), - ); + return HubLoadErrorView(error: snapshot.error!, onRetry: onRetry); } final decided = snapshot.data ?? []; if (decided.isEmpty) { diff --git a/lib/pages/audit.dart b/lib/pages/audit.dart index d9e7645..0163978 100644 --- a/lib/pages/audit.dart +++ b/lib/pages/audit.dart @@ -6,6 +6,7 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import '../data/error_presentation.dart'; +import '../data/friendly_error.dart'; import '../data/hub.dart'; import '../data/workspace.dart'; import '../l10n/app_localizations.dart'; @@ -69,7 +70,7 @@ class _AuditPageState extends State { String _typeFilter = 'all'; String _search = ''; List _events = const []; - String? _error; + Object? _error; bool _initialLoaded = false; Timer? _poller; @@ -274,7 +275,7 @@ class _AuditPageState extends State { } catch (e) { if (!mounted) return; setState(() { - _error = e.toString(); + _error = e; _initialLoaded = true; }); } @@ -363,7 +364,18 @@ class _AuditPageState extends State { ), body: Column( children: [ - _LiveStatusBar(eventCount: filtered.length, error: _error), + // The status bar shows the CLASSIFIED one-liner, never the + // raw thrown object — walls of gRPC text belong behind the + // error state's detail expander (state-matrix invariant). + _LiveStatusBar( + eventCount: filtered.length, + error: _error == null + ? null + : friendlyError( + _error!, + AppLocalizations.of(context)!, + ).headline, + ), Padding( padding: const EdgeInsets.fromLTRB( ChainSpace.xl, @@ -388,12 +400,7 @@ class _AuditPageState extends State { child: !_initialLoaded ? const Center(child: CircularProgressIndicator()) : _error != null && _events.isEmpty - ? ChainEmptyState( - icon: Icons.cloud_off_outlined, - iconColor: theme.colorScheme.error, - title: AppLocalizations.of(context)!.hubUnreachable, - hint: AppLocalizations.of(context)!.hubUnreachableHint, - ) + ? HubLoadErrorView(error: _error!, onRetry: _refresh) : filtered.isEmpty ? ChainEmptyState( icon: Icons.timeline_outlined, diff --git a/lib/pages/doctor.dart b/lib/pages/doctor.dart index 32c6ae8..84a07e3 100644 --- a/lib/pages/doctor.dart +++ b/lib/pages/doctor.dart @@ -60,17 +60,7 @@ class _DoctorPageState extends State { return const Center(child: CircularProgressIndicator()); } if (snapshot.hasError) { - final l = AppLocalizations.of(context)!; - return ChainEmptyState( - icon: Icons.cloud_off_outlined, - iconColor: Theme.of(context).colorScheme.error, - title: l.hubUnreachable, - hint: l.hubUnreachableHint, - action: FilledButton.tonal( - onPressed: _refresh, - child: Text(l.buttonRetry), - ), - ); + return HubLoadErrorView(error: snapshot.error!, onRetry: _refresh); } final s = snapshot.data!; final showUpdate = diff --git a/lib/pages/federation.dart b/lib/pages/federation.dart index fa5210d..d9cdaf7 100644 --- a/lib/pages/federation.dart +++ b/lib/pages/federation.dart @@ -126,16 +126,7 @@ class _FederationPageState extends State { return const Center(child: CircularProgressIndicator()); } if (snapshot.hasError) { - return ChainEmptyState( - icon: Icons.cloud_off_outlined, - iconColor: theme.colorScheme.error, - title: l.hubUnreachable, - hint: l.hubUnreachableHint, - action: FilledButton.tonal( - onPressed: _refresh, - child: Text(l.buttonRetry), - ), - ); + return HubLoadErrorView(error: snapshot.error!, onRetry: _refresh); } final sats = snapshot.data ?? []; if (sats.isEmpty) { diff --git a/lib/pages/runs.dart b/lib/pages/runs.dart index 64fdf57..8f0ec5c 100644 --- a/lib/pages/runs.dart +++ b/lib/pages/runs.dart @@ -51,30 +51,22 @@ enum RunsLoadIssue { } /// Classify a [HubService.listDetachedRuns] failure into the view -/// state the page should render. +/// state the page should render. The runs-specific part is the +/// detached feature gate; everything else delegates to the shared +/// page-load classification. RunsLoadIssue classifyRunsLoadError(Object error) { - switch (grpcCodeOf(error)) { - case 12: // UNIMPLEMENTED — hub predates the RPC - return RunsLoadIssue.unsupported; - case 9: // FAILED_PRECONDITION — the hub's detached gate - final msg = (grpcMessageOf(error) ?? '').toLowerCase(); - return msg.contains('detached') - ? RunsLoadIssue.featureDisabled - : RunsLoadIssue.other; - case 4: // DEADLINE_EXCEEDED - case 14: // UNAVAILABLE - return RunsLoadIssue.unreachable; + if (grpcCodeOf(error) == 9) { + // FAILED_PRECONDITION — the hub's detached gate. + final msg = (grpcMessageOf(error) ?? '').toLowerCase(); + return msg.contains('detached') + ? RunsLoadIssue.featureDisabled + : RunsLoadIssue.other; } - // Non-gRPC failures: only clear socket-level shapes count as - // "unreachable"; everything else keeps its real story. - final s = error.toString().toLowerCase(); - if (s.contains('socketexception') || - s.contains('connection refused') || - s.contains('connection terminated') || - s.contains('failed to connect')) { - return RunsLoadIssue.unreachable; - } - return RunsLoadIssue.other; + return switch (classifyHubLoadError(error)) { + HubLoadIssue.unreachable => RunsLoadIssue.unreachable, + HubLoadIssue.unsupported => RunsLoadIssue.unsupported, + HubLoadIssue.other => RunsLoadIssue.other, + }; } /// The error rendition of the runs monitor — public + callback-driven diff --git a/lib/pages/store.dart b/lib/pages/store.dart index f2dba77..0aead7d 100644 --- a/lib/pages/store.dart +++ b/lib/pages/store.dart @@ -288,15 +288,9 @@ class _StorePageState extends State { return const Center(child: CircularProgressIndicator()); } if (snap.hasError) { - return ChainEmptyState( - icon: Icons.cloud_off_outlined, - iconColor: theme.colorScheme.error, - title: l.hubUnreachable, - hint: l.hubUnreachableHint, - action: FilledButton.tonal( - onPressed: _runSearch, - child: Text(l.buttonRetry), - ), + return HubLoadErrorView( + error: snap.error!, + onRetry: _runSearch, ); } final raw = snap.data ?? const []; diff --git a/lib/widgets/hub_load_error_view.dart b/lib/widgets/hub_load_error_view.dart new file mode 100644 index 0000000..ebea618 --- /dev/null +++ b/lib/widgets/hub_load_error_view.dart @@ -0,0 +1,80 @@ +// HubLoadErrorView — the one way a page presents "my content +// could not be loaded". Pages used to open-code a "hub not +// reachable" empty state for ANY failure, contradicting the +// sidebar's green connected dot whenever the hub answered but the +// RPC failed (an older hub without the RPC, a feature gate, a real +// bug). This widget routes the failure through the shared +// classification instead: +// +// * unreachable -> the honest "hub not reachable" state +// * unsupported -> "this view needs a newer hub version" +// * other -> "could not load" + the friendly error in a +// copyable detail box (hard rule: errors are +// always copyable) +// +// The state-matrix suite pins the behaviour across every page. + +import 'package:flutter/material.dart'; + +import '../data/friendly_error.dart'; +import '../l10n/app_localizations.dart'; +import 'chain_empty_state.dart'; +import 'chain_error_box.dart'; + +class HubLoadErrorView extends StatelessWidget { + final Object error; + + /// Re-runs the page's load. Rendered on the unreachable and + /// load-failed states when provided. + final VoidCallback? onRetry; + + const HubLoadErrorView({super.key, required this.error, this.onRetry}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l = AppLocalizations.of(context)!; + final retry = onRetry == null + ? null + : FilledButton.tonal( + onPressed: onRetry, + child: Text(l.buttonRetry), + ); + switch (classifyHubLoadError(error)) { + case HubLoadIssue.unreachable: + return ChainEmptyState( + icon: Icons.cloud_off_outlined, + iconColor: theme.colorScheme.error, + title: l.hubUnreachable, + hint: l.hubUnreachableHint, + action: retry, + ); + case HubLoadIssue.unsupported: + return ChainEmptyState( + icon: Icons.system_update_alt_outlined, + title: l.hubViewTooOldTitle, + hint: l.hubViewTooOldHint, + action: retry, + ); + case HubLoadIssue.other: + return ChainEmptyState( + icon: Icons.error_outline, + iconColor: theme.colorScheme.error, + title: l.hubViewLoadFailedTitle, + action: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: ChainErrorBox(error: error, isError: true), + ), + if (retry != null) ...[ + const SizedBox(height: 12), + retry, + ], + ], + ), + ); + } + } +} diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index 640271e..dd45bb0 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -11,6 +11,7 @@ export 'chain_delta_mark.dart'; export 'chain_empty_state.dart'; export 'chain_en_badge.dart'; export 'chain_error_box.dart'; +export 'hub_load_error_view.dart'; export 'chain_flow_output.dart'; export 'chain_install_confirm.dart'; export 'chain_log_viewer.dart'; diff --git a/test/a11y_test.dart b/test/a11y_test.dart index 981e52b..7d8af34 100644 --- a/test/a11y_test.dart +++ b/test/a11y_test.dart @@ -24,6 +24,8 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:chain_studio/data/hub.dart'; import 'package:chain_studio/main.dart'; +import 'support/fake_hub.dart'; + const _destinations = [ 'welcome', 'store', @@ -40,6 +42,11 @@ void main() { testWidgets('all pages meet a11y guidelines — ${mode.name}', (tester) async { SharedPreferences.setMockInitialValues({}); + // Hermetic: pages render against the scriptable fake, never a + // hub that happens to listen on the operator's machine (the + // suite's historic pending-timer flake came from real gRPC + // channels arming their 5-minute idle timer mid-test). + installFakeHub(); // Layout correctness across sizes is responsive_test.dart's // job; this suite audits colors and labels at a normal size. tester.view.physicalSize = const Size(1280, 800); @@ -88,19 +95,6 @@ void main() { // !timersPending after the test body. await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(const Duration(minutes: 1)); - // The suite's long-standing pending-timer flake, finally - // caught with a creation stack: when the last gRPC stream - // closes, Http2ClientConnection._handleActiveStateChanged - // arms the channel's 5-minute idleTimeout timer — even on a - // shut-down connection — so a 1-minute drain never covered - // it. Close the channel in real-async space (lets in-flight - // socket callbacks land), then pump PAST the idle timeout so - // the timer fires inside the test body. - await tester.runAsync(() async { - HubService.instance.debugResetChannel(); - await Future.delayed(const Duration(milliseconds: 100)); - }); - await tester.pump(const Duration(minutes: 6)); expect( violations, isEmpty, diff --git a/test/responsive_test.dart b/test/responsive_test.dart index e151849..c80cba3 100644 --- a/test/responsive_test.dart +++ b/test/responsive_test.dart @@ -13,6 +13,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:chain_studio/data/hub.dart'; import 'package:chain_studio/main.dart'; +import 'support/fake_hub.dart'; + const _destinations = [ 'welcome', 'store', @@ -35,6 +37,9 @@ void main() { for (final entry in _sizes.entries) { testWidgets('all pages lay out without overflow — ${entry.key}', (tester) async { + // Hermetic: pages render against the scriptable fake, never a + // hub that happens to listen on the operator's machine. + installFakeHub(); tester.view.physicalSize = entry.value; tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.reset); diff --git a/test/state_matrix_test.dart b/test/state_matrix_test.dart new file mode 100644 index 0000000..ba081c1 --- /dev/null +++ b/test/state_matrix_test.dart @@ -0,0 +1,202 @@ +// State-matrix sweep — every sidebar page against every hub +// condition, with the app-wide invariants that single-page tests +// keep missing: +// +// 1. healthy-empty -> no page may claim "hub not reachable", +// no raw error text anywhere, and the fake +// must cover every HubService member the +// pages touch (hermeticity guarantee). +// 2. hub gone -> the data pages show the honest +// unreachable state; still no raw errors. +// 3. hub too old -> RPCs answer UNIMPLEMENTED: NO page may +// say "not reachable" while the sidebar +// shows connected (the runs-page bug class, +// now pinned for every page). +// 4. feature off -> the detached gate error renders the +// plain-language feature-off empty state. +// +// The suite runs against the scriptable FakeHubService — never a +// real hub — so results cannot depend on the operator's machine. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:chain_studio/data/hub.dart'; +import 'package:chain_studio/main.dart'; + +import 'support/fake_hub.dart'; + +const _destinations = [ + 'welcome', + 'store', + 'doctor', + 'flows', + 'audit', + 'approvals', + 'runs', + 'federation', +]; + +/// Substrings that must NEVER appear in rendered text — they mean +/// a raw thrown object reached the UI instead of a classified, +/// localised state. +const _rawErrorMarkers = [ + 'gRPC Error', + 'UnimplementedError', + 'SocketException', + 'Instance of', + 'StackTrace', +]; + +/// Page-primary read RPCs — the ones an older hub would be +/// missing. Scenario 3 fails exactly these with UNIMPLEMENTED. +const _pageReads = { + 'doctor', + 'searchStore', + 'recentEvents', + 'streamEvents', + 'pendingApprovals', + 'listApprovalsRecords', + 'listDetachedRuns', + 'listSatellites', +}; + +String _renderedText(WidgetTester tester) { + final buf = StringBuffer(); + for (final w in tester.widgetList(find.byType(Text))) { + buf.writeln(w.data ?? w.textSpan?.toPlainText() ?? ''); + } + for (final w in tester.widgetList( + find.byType(SelectableText), + )) { + buf.writeln(w.data ?? ''); + } + return buf.toString(); +} + +Future _boot(WidgetTester tester) async { + SharedPreferences.setMockInitialValues({}); + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + await tester.pumpWidget( + const StudioApp( + initialThemeMode: ThemeModeValue.dark, + initialLocale: Locale('de'), + ), + ); + await tester.pump(const Duration(milliseconds: 100)); +} + +Future _goto(WidgetTester tester, String id) async { + await tester.tap(find.byKey(ValueKey('sidebar-item-$id'))); + await tester.pump(const Duration(milliseconds: 400)); + await tester.pump(const Duration(milliseconds: 400)); +} + +Future _teardownApp(WidgetTester tester) async { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(minutes: 1)); +} + +void main() { + testWidgets('healthy hub: no unreachable claims, no raw errors, ' + 'fake covers all members', (tester) async { + final fake = installFakeHub(); + await _boot(tester); + final violations = []; + for (final id in _destinations) { + await _goto(tester, id); + final text = _renderedText(tester); + if (text.contains('nicht erreichbar')) { + violations.add('page "$id" claims unreachable on a healthy hub'); + } + for (final marker in _rawErrorMarkers) { + if (text.contains(marker)) { + violations.add('page "$id" renders raw error text "$marker"'); + } + } + } + await _teardownApp(tester); + expect( + fake.missing, + isEmpty, + reason: + 'pages touched HubService members the fake does not ' + 'implement — add healthy defaults for: ${fake.missing}', + ); + expect(violations, isEmpty, reason: violations.join('\n')); + }); + + testWidgets('hub gone: data pages say unreachable, never raw errors', ( + tester, + ) async { + final fake = installFakeHub(); + fake.failWith(kUnavailable); + await _boot(tester); + final violations = []; + // Pages that render a full-page load state from the hub. + for (final id in ['store', 'doctor', 'audit', 'approvals', 'runs', + 'federation']) { + await _goto(tester, id); + final text = _renderedText(tester); + if (!text.contains('nicht erreichbar')) { + violations.add('page "$id" hides that the hub is unreachable'); + } + for (final marker in _rawErrorMarkers) { + if (text.contains(marker)) { + violations.add('page "$id" renders raw error text "$marker"'); + } + } + } + await _teardownApp(tester); + expect(violations, isEmpty, reason: violations.join('\n')); + }); + + testWidgets('hub too old (UNIMPLEMENTED): connected is never shown ' + 'as unreachable', (tester) async { + final fake = installFakeHub(); + fake.failWith(kUnimplemented, only: _pageReads); + await _boot(tester); + final violations = []; + for (final id in _destinations) { + await _goto(tester, id); + final text = _renderedText(tester); + // THE invariant: the hub answered, so no page may + // contradict the sidebar's connected state. + if (text.contains('nicht erreichbar')) { + violations.add( + 'page "$id" claims unreachable although the hub answered ' + '(UNIMPLEMENTED)', + ); + } + for (final marker in _rawErrorMarkers) { + if (text.contains(marker)) { + violations.add('page "$id" renders raw error text "$marker"'); + } + } + } + // Spot-check the classified rendition on an affected page. + await _goto(tester, 'doctor'); + expect( + find.textContaining('neuere Hub-Version'), + findsWidgets, + reason: 'the too-old state must say so in plain language', + ); + await _teardownApp(tester); + expect(violations, isEmpty, reason: violations.join('\n')); + }); + + testWidgets('detached gate error renders the feature-off state', ( + tester, + ) async { + final fake = installFakeHub(); + fake.failWith(kDetachedOff, only: {'listDetachedRuns'}); + await _boot(tester); + await _goto(tester, 'runs'); + expect(find.text('Keine Läufe im Hintergrund'), findsOneWidget); + expect(find.textContaining('nicht erreichbar'), findsNothing); + await _teardownApp(tester); + }); +} diff --git a/test/support/fake_hub.dart b/test/support/fake_hub.dart new file mode 100644 index 0000000..08f6903 --- /dev/null +++ b/test/support/fake_hub.dart @@ -0,0 +1,297 @@ +// Scriptable HubService fake — the hermeticity backbone of the +// widget suites. Injected via HubService.debugSetInstance so the +// pages under test never open a real gRPC channel: test results +// must not depend on whether a hub happens to listen on the +// operator's machine (the a11y suite's old flake came exactly +// from that coupling). +// +// Behaviour model: +// * Default: a healthy, empty hub — every read succeeds with +// zero modules / flows / events / approvals / runs. +// * `failWith(error, only: {...})` scripts per-RPC failures — +// e.g. UNIMPLEMENTED for one method to simulate an older hub, +// or `failAll` for a hub that is gone entirely. +// * `FakeGrpcError` mirrors package:grpc's GrpcError shape +// (`.code` / `.message`) because production code duck-types +// on those fields (friendly_error.dart, runs classification). +// +// Any HubService member the fake does not implement lands in +// noSuchMethod, which records the name in [missing] and throws — +// so a sweep test can fail with the exact list of members that +// still need a default instead of silently rendering error states. + +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chain_client_sdk/chain_client_sdk.dart' + show HubEndpoint, HubProbeResult, StoreSource; +import 'package:chain_studio/data/hub.dart'; + +/// Duck-typed stand-in for `GrpcError`: production code reads +/// `.code` (int) and `.message` (String?) off thrown objects. +class FakeGrpcError implements Exception { + final int code; + final String? message; + const FakeGrpcError(this.code, [this.message]); + + @override + String toString() => 'gRPC Error (code: $code, message: $message)'; +} + +/// Shorthands for the codes the scenarios use. +const kUnimplemented = FakeGrpcError(12, 'method not implemented'); +const kUnavailable = FakeGrpcError(14, 'connection refused'); +const kDetachedOff = FakeGrpcError( + 9, + 'detached invocations are not enabled — set detached.enabled: true ' + 'in the operator config', +); + +class FakeHubService extends Fake implements HubService { + /// Error thrown by every RPC-backed method when set. + Object? failAll; + + /// Per-method errors, keyed by method name (e.g. + /// 'listDetachedRuns'). Wins over [failAll] absence; [failAll] + /// wins overall when both are set? No — per-method is checked + /// first so a scenario can single out one RPC. + final Map failures = {}; + + /// Members hit without an implementation (see header). + final Set missing = {}; + + /// Scriptable bits of otherwise-default answers. + bool detachedEnabled = false; + bool probeServing = true; + + void failWith(Object error, {Set? only}) { + if (only == null) { + failAll = error; + } else { + for (final m in only) { + failures[m] = error; + } + } + } + + T _guard(String method, T Function() value) { + final err = failures[method] ?? failAll; + if (err != null) throw err; + return value(); + } + + Future _async(String method, T Function() value) async => + _guard(method, value); + + @override + dynamic noSuchMethod(Invocation invocation) { + missing.add(invocation.memberName.toString()); + return super.noSuchMethod(invocation); + } + + // ── Connection / endpoint plumbing (never fails) ───────────── + + @override + String get endpointLabel => 'http://127.0.0.1:65535'; + + @override + HubEndpoint get currentEndpoint => + HubEndpoint(host: '127.0.0.1', port: 65535, secure: false); + + @override + Future loadPersistedEndpoint() async {} + + @override + Future reconnect( + HubEndpoint endpoint, { + Object? authToken = const Object(), + bool persist = true, + }) async {} + + @override + Future reloadAuthToken() async {} + + @override + Future reloadAuthTokenIfChanged() async => false; + + @override + void debugResetChannel() {} + + @override + Future loadThemeMode() async => ThemeModeValue.system; + + @override + Future saveThemeMode(ThemeModeValue mode) async {} + + @override + Future loadLocale() async => const Locale('de'); + + @override + Future saveLocale(Locale locale) async {} + + @override + String? get connectedChannelName => 'local'; + + // ── Health ─────────────────────────────────────────────────── + + @override + Future healthy() => + _async('healthy', () => probeServing && failAll == null); + + @override + Future probeHealth() async { + if (failAll != null) return HubProbeResult.unreachable; + return probeServing ? HubProbeResult.serving : HubProbeResult.notServing; + } + + // ── Read paths the pages hit (healthy-empty defaults) ──────── + + @override + Future hubVersion() => _async('hubVersion', () => '0.0.0-fake'); + + @override + Future> listModules() => + _async('listModules', () => const []); + + @override + Future> allCapabilities() => + _async('allCapabilities', () => const []); + + @override + Future> listStores() => + _async('listStores', () => const []); + + @override + Future> searchStore({ + String query = '', + String category = '', + String tag = '', + String status = '', + int limit = 50, + }) => _async('searchStore', () => const []); + + @override + Future> installedVersions(String moduleName) => + _async('installedVersions', () => const []); + + @override + Future> listFlows() => _async('listFlows', () => const []); + + @override + Future> listProjects() => + _async('listProjects', () => const []); + + @override + Stream streamEvents({ + int backfill = 0, + List types = const [], + String project = '', + }) { + final err = failures['streamEvents'] ?? failAll; + if (err != null) return Stream.error(err); + return const Stream.empty(); + } + + @override + Future> recentEvents({ + int limit = 50, + List types = const [], + String project = '', + }) => _async('recentEvents', () => const []); + + @override + Future> pendingApprovals({String project = ''}) => + _async('pendingApprovals', () => const []); + + @override + Future> listApprovalsRecords({ + List statuses = const [], + int limit = 200, + String project = '', + }) => _async('listApprovalsRecords', () => const []); + + @override + Future<({List runs, bool enabled})> listDetachedRuns({ + String project = '', + }) => _async('listDetachedRuns', () => (runs: const [], enabled: detachedEnabled)); + + @override + Future> listSatellites() => + _async('listSatellites', () => const []); + + @override + Future systemAiStatus() => _async( + 'systemAiStatus', + () => const SystemAiStatus( + enabled: false, + provider: '', + endpoint: '', + model: '', + privacyMode: 'off', + apiKeyEnv: '', + ), + ); + + @override + Future channelStatus() => _async( + 'channelStatus', + () => const ChannelStatusSnapshot( + active: 'local', + channels: [ + ChannelInfo( + name: 'local', + port: 65535, + running: true, + endpoint: 'http://127.0.0.1:65535', + ), + ], + ), + ); + + @override + Future> listMcpClients() => + _async('listMcpClients', () => const []); + + @override + Future> listN8nEndpoints() => + _async('listN8nEndpoints', () => const []); + + @override + Future doctor() => _async( + 'doctor', + () => const DoctorSnapshot( + moduleCount: 0, + capabilityCount: 0, + pendingApprovals: 0, + eventChainTotal: 0, + eventChainVerified: 0, + eventChainTamperedAt: null, + services: [], + update: UpdateStatus( + channel: 'stable', + localVersion: '0.0.0-fake', + latestVersion: '0.0.0-fake', + updateAvailable: false, + manifestReachable: true, + ), + paths: DaemonPathsSnapshot( + logPath: '', + dbPath: '', + modulesDir: '', + flowsDir: '', + configPath: '', + pidPath: '', + ), + ), + ); +} + +/// Install a [FakeHubService] for the duration of the current +/// test and restore a real instance afterwards. +FakeHubService installFakeHub() { + final fake = FakeHubService(); + HubService.debugSetInstance(fake); + addTearDown(() => HubService.debugSetInstance(null)); + return fake; +}