Compare commits

..

No commits in common. "bfd58baa75bfd9910e5895604af896079b0d4ad4" and "adc5fc231106229289f50465828e608c293f937e" have entirely different histories.

21 changed files with 97 additions and 895 deletions

View file

@ -6,31 +6,6 @@ lockstep.
## Unreleased ## 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) ### Added (0.74.0)
- **Doctor findings deep-link to their page.** Summary tiles and - **Doctor findings deep-link to their page.** Summary tiles and

View file

@ -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=<old released chain> \
// 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 = <String>[
'welcome',
'store',
'doctor',
'flows',
'audit',
'approvals',
'runs',
'federation',
];
const _rawErrorMarkers = <String>[
'gRPC Error',
'UnimplementedError',
'SocketException',
'Instance of',
'StackTrace',
];
Future<void> _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<Text>(find.byType(Text))) {
buf.writeln(w.data ?? w.textSpan?.toPlainText() ?? '');
}
for (final w in tester.widgetList<SelectableText>(
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<StudioShellState>(find.byType(StudioShell));
final violations = <String>[];
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<void>.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<void>.delayed(const Duration(milliseconds: 200)),
);
await tester.pump(const Duration(minutes: 6));
expect(violations, isEmpty, reason: violations.join('\n'));
});
}

View file

@ -217,47 +217,6 @@ FriendlyError? _matchHubPattern(String detail, AppLocalizations l) {
return null; 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 /// Duck-typed `GrpcError.code` reader public so pages that
/// classify errors themselves (e.g. the runs monitor separating /// classify errors themselves (e.g. the runs monitor separating
/// "hub down" from "hub too old") share one accessor instead of /// "hub down" from "hub too old") share one accessor instead of

View file

@ -20,17 +20,7 @@ export 'flow_output.dart';
class HubService { class HubService {
HubService._(); HubService._();
static HubService instance = HubService._(); static final 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(); HubClient _client = HubClient();

View file

@ -1357,9 +1357,6 @@
"n8nHint": "Aktive n8n-Workflows erscheinen als n8n.<endpoint>.<slug> Fähigkeiten. Einmal konfigurieren, im Store sichtbar.", "n8nHint": "Aktive n8n-Workflows erscheinen als n8n.<endpoint>.<slug> Fähigkeiten. Einmal konfigurieren, im Store sichtbar.",
"n8nEmpty": "Keine n8n-Endpunkte konfiguriert. + klicken zum Hinzufügen.", "n8nEmpty": "Keine n8n-Endpunkte konfiguriert. + klicken zum Hinzufügen.",
"hubUnreachable": "Hub nicht erreichbar", "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.", "hubUnreachableHint": "Hub starten mit chain serve.",
"languageEnglish": "English", "languageEnglish": "English",
"languageGerman": "Deutsch", "languageGerman": "Deutsch",

View file

@ -1381,9 +1381,6 @@
"n8nHint": "Active n8n workflows surface as n8n.<endpoint>.<slug> capabilities. Configure once, see them in the Store.", "n8nHint": "Active n8n workflows surface as n8n.<endpoint>.<slug> capabilities. Configure once, see them in the Store.",
"n8nEmpty": "No n8n endpoints configured. Click + to add one.", "n8nEmpty": "No n8n endpoints configured. Click + to add one.",
"hubUnreachable": "Hub unreachable", "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.", "hubUnreachableHint": "Start the hub with chain serve.",
"languageEnglish": "English", "languageEnglish": "English",
"languageGerman": "Deutsch", "languageGerman": "Deutsch",

View file

@ -4076,24 +4076,6 @@ abstract class AppLocalizations {
/// **'Hub unreachable'** /// **'Hub unreachable'**
String get hubUnreachable; 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. /// No description provided for @hubUnreachableHint.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -2399,18 +2399,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get hubUnreachable => 'Hub nicht erreichbar'; 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 @override
String get hubUnreachableHint => 'Hub starten mit chain serve.'; String get hubUnreachableHint => 'Hub starten mit chain serve.';

View file

@ -2400,16 +2400,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get hubUnreachable => 'Hub unreachable'; 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 @override
String get hubUnreachableHint => 'Start the hub with chain serve.'; String get hubUnreachableHint => 'Start the hub with chain serve.';

View file

@ -89,12 +89,6 @@ class _ApprovalsPageState extends State<ApprovalsPage>
limit: 200, limit: 200,
project: project, 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
return FutureBuilder<List<ApprovalRecord>>( return FutureBuilder<List<ApprovalRecord>>(
future: future, future: future,
@ -345,7 +340,16 @@ class _PendingList extends StatelessWidget {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
if (snapshot.hasError) { 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 ?? []; final pending = snapshot.data ?? [];
if (pending.isEmpty) { if (pending.isEmpty) {
@ -509,6 +513,7 @@ class _HistoryList extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
return FutureBuilder<List<ApprovalRecord>>( return FutureBuilder<List<ApprovalRecord>>(
future: future, future: future,
@ -517,7 +522,16 @@ class _HistoryList extends StatelessWidget {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
if (snapshot.hasError) { 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 ?? []; final decided = snapshot.data ?? [];
if (decided.isEmpty) { if (decided.isEmpty) {

View file

@ -6,7 +6,6 @@ import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../data/error_presentation.dart'; import '../data/error_presentation.dart';
import '../data/friendly_error.dart';
import '../data/hub.dart'; import '../data/hub.dart';
import '../data/workspace.dart'; import '../data/workspace.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
@ -70,7 +69,7 @@ class _AuditPageState extends State<AuditPage> {
String _typeFilter = 'all'; String _typeFilter = 'all';
String _search = ''; String _search = '';
List<AuditEvent> _events = const []; List<AuditEvent> _events = const [];
Object? _error; String? _error;
bool _initialLoaded = false; bool _initialLoaded = false;
Timer? _poller; Timer? _poller;
@ -275,7 +274,7 @@ class _AuditPageState extends State<AuditPage> {
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_error = e; _error = e.toString();
_initialLoaded = true; _initialLoaded = true;
}); });
} }
@ -364,18 +363,7 @@ class _AuditPageState extends State<AuditPage> {
), ),
body: Column( body: Column(
children: [ children: [
// The status bar shows the CLASSIFIED one-liner, never the _LiveStatusBar(eventCount: filtered.length, error: _error),
// 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(
padding: const EdgeInsets.fromLTRB( padding: const EdgeInsets.fromLTRB(
ChainSpace.xl, ChainSpace.xl,
@ -400,7 +388,12 @@ class _AuditPageState extends State<AuditPage> {
child: !_initialLoaded child: !_initialLoaded
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: _error != null && _events.isEmpty : _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 : filtered.isEmpty
? ChainEmptyState( ? ChainEmptyState(
icon: Icons.timeline_outlined, icon: Icons.timeline_outlined,

View file

@ -60,7 +60,17 @@ class _DoctorPageState extends State<DoctorPage> {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
if (snapshot.hasError) { 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 s = snapshot.data!;
final showUpdate = final showUpdate =

View file

@ -126,7 +126,16 @@ class _FederationPageState extends State<FederationPage> {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
if (snapshot.hasError) { 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 ?? []; final sats = snapshot.data ?? [];
if (sats.isEmpty) { if (sats.isEmpty) {

View file

@ -51,22 +51,30 @@ enum RunsLoadIssue {
} }
/// Classify a [HubService.listDetachedRuns] failure into the view /// Classify a [HubService.listDetachedRuns] failure into the view
/// state the page should render. The runs-specific part is the /// state the page should render.
/// detached feature gate; everything else delegates to the shared
/// page-load classification.
RunsLoadIssue classifyRunsLoadError(Object error) { RunsLoadIssue classifyRunsLoadError(Object error) {
if (grpcCodeOf(error) == 9) { switch (grpcCodeOf(error)) {
// FAILED_PRECONDITION the hub's detached gate. case 12: // UNIMPLEMENTED hub predates the RPC
final msg = (grpcMessageOf(error) ?? '').toLowerCase(); return RunsLoadIssue.unsupported;
return msg.contains('detached') case 9: // FAILED_PRECONDITION the hub's detached gate
? RunsLoadIssue.featureDisabled final msg = (grpcMessageOf(error) ?? '').toLowerCase();
: RunsLoadIssue.other; return msg.contains('detached')
? RunsLoadIssue.featureDisabled
: RunsLoadIssue.other;
case 4: // DEADLINE_EXCEEDED
case 14: // UNAVAILABLE
return RunsLoadIssue.unreachable;
} }
return switch (classifyHubLoadError(error)) { // Non-gRPC failures: only clear socket-level shapes count as
HubLoadIssue.unreachable => RunsLoadIssue.unreachable, // "unreachable"; everything else keeps its real story.
HubLoadIssue.unsupported => RunsLoadIssue.unsupported, final s = error.toString().toLowerCase();
HubLoadIssue.other => RunsLoadIssue.other, 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 /// The error rendition of the runs monitor public + callback-driven

View file

@ -288,9 +288,15 @@ class _StorePageState extends State<StorePage> {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
if (snap.hasError) { if (snap.hasError) {
return HubLoadErrorView( return ChainEmptyState(
error: snap.error!, icon: Icons.cloud_off_outlined,
onRetry: _runSearch, iconColor: theme.colorScheme.error,
title: l.hubUnreachable,
hint: l.hubUnreachableHint,
action: FilledButton.tonal(
onPressed: _runSearch,
child: Text(l.buttonRetry),
),
); );
} }
final raw = snap.data ?? const <StoreItem>[]; final raw = snap.data ?? const <StoreItem>[];

View file

@ -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,
],
],
),
);
}
}
}

View file

@ -11,7 +11,6 @@ export 'chain_delta_mark.dart';
export 'chain_empty_state.dart'; export 'chain_empty_state.dart';
export 'chain_en_badge.dart'; export 'chain_en_badge.dart';
export 'chain_error_box.dart'; export 'chain_error_box.dart';
export 'hub_load_error_view.dart';
export 'chain_flow_output.dart'; export 'chain_flow_output.dart';
export 'chain_install_confirm.dart'; export 'chain_install_confirm.dart';
export 'chain_log_viewer.dart'; export 'chain_log_viewer.dart';

View file

@ -24,8 +24,6 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:chain_studio/data/hub.dart'; import 'package:chain_studio/data/hub.dart';
import 'package:chain_studio/main.dart'; import 'package:chain_studio/main.dart';
import 'support/fake_hub.dart';
const _destinations = <String>[ const _destinations = <String>[
'welcome', 'welcome',
'store', 'store',
@ -42,11 +40,6 @@ void main() {
testWidgets('all pages meet a11y guidelines — ${mode.name}', testWidgets('all pages meet a11y guidelines — ${mode.name}',
(tester) async { (tester) async {
SharedPreferences.setMockInitialValues({}); 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 // Layout correctness across sizes is responsive_test.dart's
// job; this suite audits colors and labels at a normal size. // job; this suite audits colors and labels at a normal size.
tester.view.physicalSize = const Size(1280, 800); tester.view.physicalSize = const Size(1280, 800);
@ -95,6 +88,19 @@ void main() {
// !timersPending after the test body. // !timersPending after the test body.
await tester.pumpWidget(const SizedBox.shrink()); await tester.pumpWidget(const SizedBox.shrink());
await tester.pump(const Duration(minutes: 1)); 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<void>.delayed(const Duration(milliseconds: 100));
});
await tester.pump(const Duration(minutes: 6));
expect( expect(
violations, violations,
isEmpty, isEmpty,

View file

@ -13,8 +13,6 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:chain_studio/data/hub.dart'; import 'package:chain_studio/data/hub.dart';
import 'package:chain_studio/main.dart'; import 'package:chain_studio/main.dart';
import 'support/fake_hub.dart';
const _destinations = <String>[ const _destinations = <String>[
'welcome', 'welcome',
'store', 'store',
@ -37,9 +35,6 @@ void main() {
for (final entry in _sizes.entries) { for (final entry in _sizes.entries) {
testWidgets('all pages lay out without overflow — ${entry.key}', testWidgets('all pages lay out without overflow — ${entry.key}',
(tester) async { (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.physicalSize = entry.value;
tester.view.devicePixelRatio = 1.0; tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset); addTearDown(tester.view.reset);

View file

@ -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 = <String>[
'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 = <String>[
'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 = <String>{
'doctor',
'searchStore',
'recentEvents',
'streamEvents',
'pendingApprovals',
'listApprovalsRecords',
'listDetachedRuns',
'listSatellites',
};
String _renderedText(WidgetTester tester) {
final buf = StringBuffer();
for (final w in tester.widgetList<Text>(find.byType(Text))) {
buf.writeln(w.data ?? w.textSpan?.toPlainText() ?? '');
}
for (final w in tester.widgetList<SelectableText>(
find.byType(SelectableText),
)) {
buf.writeln(w.data ?? '');
}
return buf.toString();
}
Future<void> _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<void> _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<void> _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 = <String>[];
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 = <String>[];
// 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 = <String>[];
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);
});
}

View file

@ -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<String, Object> failures = {};
/// Members hit without an implementation (see header).
final Set<String> missing = {};
/// Scriptable bits of otherwise-default answers.
bool detachedEnabled = false;
bool probeServing = true;
void failWith(Object error, {Set<String>? only}) {
if (only == null) {
failAll = error;
} else {
for (final m in only) {
failures[m] = error;
}
}
}
T _guard<T>(String method, T Function() value) {
final err = failures[method] ?? failAll;
if (err != null) throw err;
return value();
}
Future<T> _async<T>(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<void> loadPersistedEndpoint() async {}
@override
Future<void> reconnect(
HubEndpoint endpoint, {
Object? authToken = const Object(),
bool persist = true,
}) async {}
@override
Future<void> reloadAuthToken() async {}
@override
Future<bool> reloadAuthTokenIfChanged() async => false;
@override
void debugResetChannel() {}
@override
Future<ThemeModeValue> loadThemeMode() async => ThemeModeValue.system;
@override
Future<void> saveThemeMode(ThemeModeValue mode) async {}
@override
Future<Locale> loadLocale() async => const Locale('de');
@override
Future<void> saveLocale(Locale locale) async {}
@override
String? get connectedChannelName => 'local';
// Health
@override
Future<bool> healthy() =>
_async('healthy', () => probeServing && failAll == null);
@override
Future<HubProbeResult> probeHealth() async {
if (failAll != null) return HubProbeResult.unreachable;
return probeServing ? HubProbeResult.serving : HubProbeResult.notServing;
}
// Read paths the pages hit (healthy-empty defaults)
@override
Future<String> hubVersion() => _async('hubVersion', () => '0.0.0-fake');
@override
Future<List<ModuleSummary>> listModules() =>
_async('listModules', () => const []);
@override
Future<List<CapabilityInfo>> allCapabilities() =>
_async('allCapabilities', () => const []);
@override
Future<List<StoreSource>> listStores() =>
_async('listStores', () => const []);
@override
Future<List<StoreItem>> searchStore({
String query = '',
String category = '',
String tag = '',
String status = '',
int limit = 50,
}) => _async('searchStore', () => const []);
@override
Future<List<String>> installedVersions(String moduleName) =>
_async('installedVersions', () => const []);
@override
Future<List<SavedFlow>> listFlows() => _async('listFlows', () => const []);
@override
Future<List<ProjectRef>> listProjects() =>
_async('listProjects', () => const []);
@override
Stream<AuditEvent> streamEvents({
int backfill = 0,
List<String> types = const [],
String project = '',
}) {
final err = failures['streamEvents'] ?? failAll;
if (err != null) return Stream.error(err);
return const Stream.empty();
}
@override
Future<List<AuditEvent>> recentEvents({
int limit = 50,
List<String> types = const [],
String project = '',
}) => _async('recentEvents', () => const []);
@override
Future<List<PendingApproval>> pendingApprovals({String project = ''}) =>
_async('pendingApprovals', () => const []);
@override
Future<List<ApprovalRecord>> listApprovalsRecords({
List<String> statuses = const [],
int limit = 200,
String project = '',
}) => _async('listApprovalsRecords', () => const []);
@override
Future<({List<DetachedRun> runs, bool enabled})> listDetachedRuns({
String project = '',
}) => _async('listDetachedRuns', () => (runs: const <DetachedRun>[], enabled: detachedEnabled));
@override
Future<List<Satellite>> listSatellites() =>
_async('listSatellites', () => const []);
@override
Future<SystemAiStatus> systemAiStatus() => _async(
'systemAiStatus',
() => const SystemAiStatus(
enabled: false,
provider: '',
endpoint: '',
model: '',
privacyMode: 'off',
apiKeyEnv: '',
),
);
@override
Future<ChannelStatusSnapshot> channelStatus() => _async(
'channelStatus',
() => const ChannelStatusSnapshot(
active: 'local',
channels: [
ChannelInfo(
name: 'local',
port: 65535,
running: true,
endpoint: 'http://127.0.0.1:65535',
),
],
),
);
@override
Future<List<McpClientInfo>> listMcpClients() =>
_async('listMcpClients', () => const []);
@override
Future<List<N8nEndpointInfo>> listN8nEndpoints() =>
_async('listN8nEndpoints', () => const []);
@override
Future<DoctorSnapshot> 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;
}