From 66b26304fda3ce260ad72296f275dba4d951d495 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Sun, 19 Jul 2026 01:02:51 +0200 Subject: [PATCH 1/2] feat(test): hermetic hub fake + state-matrix sweep across all pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The widget suites used to talk to whatever listens on the real endpoint — results depended on the operator's machine (a running hub fed real data into a11y/responsive runs and its gRPC channel timers caused the historic flake). Hardening round: - HubService.instance is now injectable (debugSetInstance); FakeHubService (test/support/fake_hub.dart) answers every member the pages touch with healthy-empty defaults and scripts per-RPC failures (UNIMPLEMENTED / UNAVAILABLE / detached gate) through a GrpcError-shaped fake. Unimplemented members are recorded and fail the sweep with the exact list. - state_matrix_test.dart pins the app-wide invariants for every sidebar page x hub condition: healthy => no unreachable claims and no raw error text; hub gone => honest unreachable states; UNIMPLEMENTED => never 'not reachable' while the sidebar shows connected; detached gate => plain-language feature-off state. - a11y + responsive sweeps now inject the fake (hermetic); the 6-minute idle-timer drain workaround is gone with the cause. Real bugs the new sweep caught immediately: - every data page (store, doctor, audit, approvals, federation) folded ANY load failure into 'hub not reachable' — the runs-page bug class; they now share HubLoadErrorView, which classifies into unreachable / needs-newer-hub / load-failed-with-copyable- detail (new generic DE+EN strings) - the approvals page's hidden tab had no future listener: a load failure there surfaced as an uncaught async error - the audit status bar rendered the raw gRPC error wall verbatim; it now shows the classified friendly headline (still selectable) Signed-off-by: flemming-it --- lib/data/friendly_error.dart | 41 ++++ lib/data/hub.dart | 12 +- lib/l10n/app_de.arb | 3 + lib/l10n/app_en.arb | 3 + lib/l10n/app_localizations.dart | 18 ++ lib/l10n/app_localizations_de.dart | 12 ++ lib/l10n/app_localizations_en.dart | 10 + lib/pages/approvals.dart | 30 +-- lib/pages/audit.dart | 25 ++- lib/pages/doctor.dart | 12 +- lib/pages/federation.dart | 11 +- lib/pages/runs.dart | 36 ++-- lib/pages/store.dart | 12 +- lib/widgets/hub_load_error_view.dart | 80 ++++++++ lib/widgets/widgets.dart | 1 + test/a11y_test.dart | 20 +- test/responsive_test.dart | 5 + test/state_matrix_test.dart | 202 ++++++++++++++++++ test/support/fake_hub.dart | 297 +++++++++++++++++++++++++++ 19 files changed, 733 insertions(+), 97 deletions(-) create mode 100644 lib/widgets/hub_load_error_view.dart create mode 100644 test/state_matrix_test.dart create mode 100644 test/support/fake_hub.dart 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; +} From bfd58baa75bfd9910e5895604af896079b0d4ad4 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Sun, 19 Jul 2026 02:08:48 +0200 Subject: [PATCH 2/2] =?UTF-8?q?test(skew):=20version-skew=20smoke=20?= =?UTF-8?q?=E2=80=94=20dev=20Studio=20against=20the=20released=20hub?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walks every sidebar page against an older, released chain binary (hermetic fixture hub) and fails when any page claims 'unreachable' although the hub answers, or renders raw error text. Run via the platform repo's scripts/skew-smoke.sh as a Studio release gate. Verified against the real pair hub 0.21.0 vs workspace 0.22.0: all invariants hold. Two timing notes are handled explicitly: real-async settles between navigations and an app teardown before the fixture teardown keep grpc-dart's data-after-cancel race (unguarded _responses.add, call.dart:395) from failing the walk spuriously; the final fake-time pump drains the channel's 5-minute idle timer. Signed-off-by: flemming-it --- CHANGELOG.md | 25 +++++ integration_test/skew_smoke_test.dart | 137 ++++++++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 integration_test/skew_smoke_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index ee59dda..e33bf2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,31 @@ lockstep. ## Unreleased +### Hardening round (test strategy, 2026-07-19) + +- **Hermetic widget suites.** `HubService.instance` is injectable; + a scriptable fake (`test/support/fake_hub.dart`) answers every + member the pages touch and scripts per-RPC failures. The a11y and + responsive sweeps run against it — results no longer depend on + whether a hub happens to listen on the operator's machine (which + was the root of the historic a11y flake). +- **State-matrix sweep** (`state_matrix_test.dart`): every sidebar + page × hub condition (healthy / gone / too old / feature off) with + app-wide invariants — no "unreachable" claims while the hub + answers, no raw error text in the UI, classified states only. +- **Shared load-error view.** Store, Doctor, Audit, Approvals and + Federation no longer fold every load failure into "hub not + reachable"; `HubLoadErrorView` classifies into unreachable / + needs-newer-hub / load-failed-with-copyable-detail (new DE+EN + strings). Also fixed: the approvals page's hidden tab surfaced + load failures as uncaught async errors; the audit status bar + rendered the raw gRPC error wall instead of the friendly headline. +- **Version-skew gate.** `integration_test/skew_smoke_test.dart` + (run via the platform repo's `scripts/skew-smoke.sh`) boots the + released hub binary and walks every page with the same + invariants — the dev-Studio-against-old-hub combination is now a + release gate instead of a live-bug generator. + ### Added (0.74.0) - **Doctor findings deep-link to their page.** Summary tiles and diff --git a/integration_test/skew_smoke_test.dart b/integration_test/skew_smoke_test.dart new file mode 100644 index 0000000..04397d9 --- /dev/null +++ b/integration_test/skew_smoke_test.dart @@ -0,0 +1,137 @@ +// Version-skew smoke — the DEV Studio against an OLDER, released +// hub binary. The two worst live bugs of the project were skew +// bugs (ABI 1.0/1.1, a 0.21 hub without the ListInvocations RPC +// rendered as "hub not reachable"), and no test ever ran the +// combination operators actually have: a Studio that is newer +// than its hub. +// +// Run through the platform repo's scripts/skew-smoke.sh, which +// resolves the old binary and isolates $HOME: +// +// CHAIN_BIN= \ +// flutter test integration_test/skew_smoke_test.dart -d macos +// +// Invariants per page (the hub IS running and healthy): +// * never "nicht erreichbar" — a reachable hub must never be +// rendered as unreachable, whatever RPCs it is missing +// * never raw error text (gRPC walls, UnimplementedError, ...) +// — missing RPCs must surface as classified, localised states +// ("braucht eine neuere Hub-Version", feature-off, ...) + +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:chain_client_sdk/chain_client_sdk.dart' show HubEndpoint; +import 'package:chain_studio/data/hub.dart'; +import 'package:chain_studio/main.dart'; + +import '../test/integration/hub_fixture.dart'; + +const _destinations = [ + 'welcome', + 'store', + 'doctor', + 'flows', + 'audit', + 'approvals', + 'runs', + 'federation', +]; + +const _rawErrorMarkers = [ + 'gRPC Error', + 'UnimplementedError', + 'SocketException', + 'Instance of', + 'StackTrace', +]; + +Future _pumpFrames(WidgetTester tester, [int frames = 20]) async { + for (var i = 0; i < frames; i++) { + await tester.pump(const Duration(milliseconds: 100)); + } +} + +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(); +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('dev Studio against the released hub: reachable is never ' + 'rendered unreachable, no raw errors', (tester) async { + final fixture = await HubFixture.start(skipIfBinaryMissing: false); + addTearDown(fixture!.dispose); + + final binary = (await HubFixture.binaryPath())!; + final version = await Process.run(binary, ['--version']); + // Surfaced in the runner output so the log states which skew + // pair was actually exercised. + // ignore: avoid_print + print('skew-smoke: hub binary = ${(version.stdout as String).trim()}'); + + SharedPreferences.setMockInitialValues({}); + await tester.pumpWidget( + const StudioApp( + initialThemeMode: ThemeModeValue.dark, + initialLocale: Locale('de'), + ), + ); + await HubService.instance.reconnect( + HubEndpoint(host: '127.0.0.1', port: fixture.port), + authToken: null, + persist: false, + ); + await _pumpFrames(tester, 30); + + final shell = tester.state(find.byType(StudioShell)); + final violations = []; + for (final id in _destinations) { + shell.navigateTo(id); + await _pumpFrames(tester, 25); + // Let in-flight gRPC frames land in REAL async before the next + // navigation disposes the page: grpc-dart 4.2 adds incoming + // data to an already-closed controller when a cancel races the + // response (call.dart:395, unguarded `_responses.add`) — an + // upstream bug that would fail the walk spuriously. + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 150)), + ); + final text = _renderedText(tester); + if (text.contains('nicht erreichbar')) { + violations.add( + 'page "$id" claims the hub is unreachable although the ' + 'fixture hub is healthy', + ); + } + for (final marker in _rawErrorMarkers) { + if (text.contains(marker)) { + violations.add('page "$id" renders raw error text "$marker"'); + } + } + } + // Tear the app down BEFORE the fixture teardown closes the hub: + // page subscriptions cancel while the channel is still alive, so + // the shutdown cannot race live streams into add-after-close. + await tester.pumpWidget(const SizedBox.shrink()); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 200)), + ); + await tester.pump(const Duration(minutes: 6)); + expect(violations, isEmpty, reason: violations.join('\n')); + }); +}