Compare commits

..

2 commits

Author SHA1 Message Date
flemming-it
bfd58baa75 test(skew): version-skew smoke — dev Studio against the released hub
Some checks failed
Security / Security check (push) Failing after 2s
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 <stefan.a.flemming@googlemail.com>
2026-07-19 02:08:48 +02:00
flemming-it
66b26304fd feat(test): hermetic hub fake + state-matrix sweep across all pages
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 <stefan.a.flemming@googlemail.com>
2026-07-19 01:02:51 +02:00
21 changed files with 895 additions and 97 deletions

View file

@ -6,6 +6,31 @@ 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

@ -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=<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,6 +217,47 @@ 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,7 +20,17 @@ export 'flow_output.dart';
class HubService { class HubService {
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(); HubClient _client = HubClient();

View file

@ -1357,6 +1357,9 @@
"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,6 +1381,9 @@
"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,6 +4076,24 @@ 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,6 +2399,18 @@ 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,6 +2400,16 @@ 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,6 +89,12 @@ 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: (_) {});
}); });
} }
@ -331,7 +337,6 @@ 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,
@ -340,16 +345,7 @@ class _PendingList extends StatelessWidget {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
if (snapshot.hasError) { if (snapshot.hasError) {
return ChainEmptyState( return HubLoadErrorView(error: snapshot.error!, onRetry: onRetry);
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) {
@ -513,7 +509,6 @@ 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,
@ -522,16 +517,7 @@ class _HistoryList extends StatelessWidget {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
if (snapshot.hasError) { if (snapshot.hasError) {
return ChainEmptyState( return HubLoadErrorView(error: snapshot.error!, onRetry: onRetry);
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,6 +6,7 @@ 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';
@ -69,7 +70,7 @@ class _AuditPageState extends State<AuditPage> {
String _typeFilter = 'all'; String _typeFilter = 'all';
String _search = ''; String _search = '';
List<AuditEvent> _events = const []; List<AuditEvent> _events = const [];
String? _error; Object? _error;
bool _initialLoaded = false; bool _initialLoaded = false;
Timer? _poller; Timer? _poller;
@ -274,7 +275,7 @@ class _AuditPageState extends State<AuditPage> {
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_error = e.toString(); _error = e;
_initialLoaded = true; _initialLoaded = true;
}); });
} }
@ -363,7 +364,18 @@ class _AuditPageState extends State<AuditPage> {
), ),
body: Column( body: Column(
children: [ 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(
padding: const EdgeInsets.fromLTRB( padding: const EdgeInsets.fromLTRB(
ChainSpace.xl, ChainSpace.xl,
@ -388,12 +400,7 @@ 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
? ChainEmptyState( ? HubLoadErrorView(error: _error!, onRetry: _refresh)
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,17 +60,7 @@ class _DoctorPageState extends State<DoctorPage> {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
if (snapshot.hasError) { if (snapshot.hasError) {
final l = AppLocalizations.of(context)!; return HubLoadErrorView(error: snapshot.error!, onRetry: _refresh);
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,16 +126,7 @@ class _FederationPageState extends State<FederationPage> {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
if (snapshot.hasError) { if (snapshot.hasError) {
return ChainEmptyState( return HubLoadErrorView(error: snapshot.error!, onRetry: _refresh);
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,30 +51,22 @@ enum RunsLoadIssue {
} }
/// Classify a [HubService.listDetachedRuns] failure into the view /// 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) { RunsLoadIssue classifyRunsLoadError(Object error) {
switch (grpcCodeOf(error)) { if (grpcCodeOf(error) == 9) {
case 12: // UNIMPLEMENTED hub predates the RPC // FAILED_PRECONDITION the hub's detached gate.
return RunsLoadIssue.unsupported; final msg = (grpcMessageOf(error) ?? '').toLowerCase();
case 9: // FAILED_PRECONDITION the hub's detached gate return msg.contains('detached')
final msg = (grpcMessageOf(error) ?? '').toLowerCase(); ? RunsLoadIssue.featureDisabled
return msg.contains('detached') : RunsLoadIssue.other;
? RunsLoadIssue.featureDisabled
: RunsLoadIssue.other;
case 4: // DEADLINE_EXCEEDED
case 14: // UNAVAILABLE
return RunsLoadIssue.unreachable;
} }
// Non-gRPC failures: only clear socket-level shapes count as return switch (classifyHubLoadError(error)) {
// "unreachable"; everything else keeps its real story. HubLoadIssue.unreachable => RunsLoadIssue.unreachable,
final s = error.toString().toLowerCase(); HubLoadIssue.unsupported => RunsLoadIssue.unsupported,
if (s.contains('socketexception') || HubLoadIssue.other => RunsLoadIssue.other,
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,15 +288,9 @@ class _StorePageState extends State<StorePage> {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
if (snap.hasError) { if (snap.hasError) {
return ChainEmptyState( return HubLoadErrorView(
icon: Icons.cloud_off_outlined, error: snap.error!,
iconColor: theme.colorScheme.error, onRetry: _runSearch,
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

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

View file

@ -11,6 +11,7 @@ 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,6 +24,8 @@ 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',
@ -40,6 +42,11 @@ 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);
@ -88,19 +95,6 @@ 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,6 +13,8 @@ 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',
@ -35,6 +37,9 @@ 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);

202
test/state_matrix_test.dart Normal file
View file

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

297
test/support/fake_hub.dart Normal file
View file

@ -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<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;
}