diff --git a/CHANGELOG.md b/CHANGELOG.md index e33bf2a..ee59dda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,31 +6,6 @@ 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 deleted file mode 100644 index 04397d9..0000000 --- a/integration_test/skew_smoke_test.dart +++ /dev/null @@ -1,137 +0,0 @@ -// 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')); - }); -} diff --git a/lib/data/friendly_error.dart b/lib/data/friendly_error.dart index a375b2a..e415485 100644 --- a/lib/data/friendly_error.dart +++ b/lib/data/friendly_error.dart @@ -217,47 +217,6 @@ 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 8446677..d0dc1cd 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -20,17 +20,7 @@ export 'flow_output.dart'; class HubService { 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._(); - } + static final HubService instance = HubService._(); HubClient _client = HubClient(); diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index b682023..09f7b7d 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1357,9 +1357,6 @@ "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 fddce37..8280776 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1381,9 +1381,6 @@ "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 ed87ba2..d2a8e09 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -4076,24 +4076,6 @@ 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 256e206..4a56518 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2399,18 +2399,6 @@ 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 f9ad6e4..f20b7e0 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2400,16 +2400,6 @@ 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 59bacdb..c98f5ed 100644 --- a/lib/pages/approvals.dart +++ b/lib/pages/approvals.dart @@ -89,12 +89,6 @@ 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: (_) {}); }); } @@ -337,6 +331,7 @@ class _PendingList extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = Theme.of(context); final l = AppLocalizations.of(context)!; return FutureBuilder>( future: future, @@ -345,7 +340,16 @@ class _PendingList extends StatelessWidget { return const Center(child: CircularProgressIndicator()); } if (snapshot.hasError) { - return HubLoadErrorView(error: snapshot.error!, onRetry: onRetry); + 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), + ), + ); } final pending = snapshot.data ?? []; if (pending.isEmpty) { @@ -509,6 +513,7 @@ class _HistoryList extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = Theme.of(context); final l = AppLocalizations.of(context)!; return FutureBuilder>( future: future, @@ -517,7 +522,16 @@ class _HistoryList extends StatelessWidget { return const Center(child: CircularProgressIndicator()); } if (snapshot.hasError) { - return HubLoadErrorView(error: snapshot.error!, onRetry: onRetry); + 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), + ), + ); } final decided = snapshot.data ?? []; if (decided.isEmpty) { diff --git a/lib/pages/audit.dart b/lib/pages/audit.dart index 0163978..d9e7645 100644 --- a/lib/pages/audit.dart +++ b/lib/pages/audit.dart @@ -6,7 +6,6 @@ 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'; @@ -70,7 +69,7 @@ class _AuditPageState extends State { String _typeFilter = 'all'; String _search = ''; List _events = const []; - Object? _error; + String? _error; bool _initialLoaded = false; Timer? _poller; @@ -275,7 +274,7 @@ class _AuditPageState extends State { } catch (e) { if (!mounted) return; setState(() { - _error = e; + _error = e.toString(); _initialLoaded = true; }); } @@ -364,18 +363,7 @@ class _AuditPageState extends State { ), body: Column( children: [ - // 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, - ), + _LiveStatusBar(eventCount: filtered.length, error: _error), Padding( padding: const EdgeInsets.fromLTRB( ChainSpace.xl, @@ -400,7 +388,12 @@ class _AuditPageState extends State { child: !_initialLoaded ? const Center(child: CircularProgressIndicator()) : _error != null && _events.isEmpty - ? HubLoadErrorView(error: _error!, onRetry: _refresh) + ? ChainEmptyState( + icon: Icons.cloud_off_outlined, + iconColor: theme.colorScheme.error, + title: AppLocalizations.of(context)!.hubUnreachable, + hint: AppLocalizations.of(context)!.hubUnreachableHint, + ) : filtered.isEmpty ? ChainEmptyState( icon: Icons.timeline_outlined, diff --git a/lib/pages/doctor.dart b/lib/pages/doctor.dart index 84a07e3..32c6ae8 100644 --- a/lib/pages/doctor.dart +++ b/lib/pages/doctor.dart @@ -60,7 +60,17 @@ class _DoctorPageState extends State { return const Center(child: CircularProgressIndicator()); } if (snapshot.hasError) { - return HubLoadErrorView(error: snapshot.error!, onRetry: _refresh); + 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), + ), + ); } final s = snapshot.data!; final showUpdate = diff --git a/lib/pages/federation.dart b/lib/pages/federation.dart index d9cdaf7..fa5210d 100644 --- a/lib/pages/federation.dart +++ b/lib/pages/federation.dart @@ -126,7 +126,16 @@ class _FederationPageState extends State { return const Center(child: CircularProgressIndicator()); } if (snapshot.hasError) { - return HubLoadErrorView(error: snapshot.error!, onRetry: _refresh); + 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), + ), + ); } final sats = snapshot.data ?? []; if (sats.isEmpty) { diff --git a/lib/pages/runs.dart b/lib/pages/runs.dart index 8f0ec5c..64fdf57 100644 --- a/lib/pages/runs.dart +++ b/lib/pages/runs.dart @@ -51,22 +51,30 @@ enum RunsLoadIssue { } /// Classify a [HubService.listDetachedRuns] failure into the view -/// state the page should render. The runs-specific part is the -/// detached feature gate; everything else delegates to the shared -/// page-load classification. +/// state the page should render. RunsLoadIssue classifyRunsLoadError(Object error) { - if (grpcCodeOf(error) == 9) { - // FAILED_PRECONDITION — the hub's detached gate. - final msg = (grpcMessageOf(error) ?? '').toLowerCase(); - return msg.contains('detached') - ? RunsLoadIssue.featureDisabled - : RunsLoadIssue.other; + 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; } - return switch (classifyHubLoadError(error)) { - HubLoadIssue.unreachable => RunsLoadIssue.unreachable, - HubLoadIssue.unsupported => RunsLoadIssue.unsupported, - HubLoadIssue.other => 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; } /// The error rendition of the runs monitor — public + callback-driven diff --git a/lib/pages/store.dart b/lib/pages/store.dart index 0aead7d..f2dba77 100644 --- a/lib/pages/store.dart +++ b/lib/pages/store.dart @@ -288,9 +288,15 @@ class _StorePageState extends State { return const Center(child: CircularProgressIndicator()); } if (snap.hasError) { - return HubLoadErrorView( - error: snap.error!, - onRetry: _runSearch, + 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), + ), ); } final raw = snap.data ?? const []; diff --git a/lib/widgets/hub_load_error_view.dart b/lib/widgets/hub_load_error_view.dart deleted file mode 100644 index ebea618..0000000 --- a/lib/widgets/hub_load_error_view.dart +++ /dev/null @@ -1,80 +0,0 @@ -// 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 dd45bb0..640271e 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -11,7 +11,6 @@ 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 7d8af34..981e52b 100644 --- a/test/a11y_test.dart +++ b/test/a11y_test.dart @@ -24,8 +24,6 @@ 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', @@ -42,11 +40,6 @@ 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); @@ -95,6 +88,19 @@ 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 c80cba3..e151849 100644 --- a/test/responsive_test.dart +++ b/test/responsive_test.dart @@ -13,8 +13,6 @@ 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', @@ -37,9 +35,6 @@ 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 deleted file mode 100644 index ba081c1..0000000 --- a/test/state_matrix_test.dart +++ /dev/null @@ -1,202 +0,0 @@ -// 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 deleted file mode 100644 index 08f6903..0000000 --- a/test/support/fake_hub.dart +++ /dev/null @@ -1,297 +0,0 @@ -// 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; -}