Compare commits
2 commits
adc5fc2311
...
bfd58baa75
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfd58baa75 | ||
|
|
66b26304fd |
21 changed files with 895 additions and 97 deletions
25
CHANGELOG.md
25
CHANGELOG.md
|
|
@ -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
|
||||||
|
|
|
||||||
137
integration_test/skew_smoke_test.dart
Normal file
137
integration_test/skew_smoke_test.dart
Normal 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'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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.';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
case 9: // FAILED_PRECONDITION — the hub's detached gate
|
|
||||||
final msg = (grpcMessageOf(error) ?? '').toLowerCase();
|
final msg = (grpcMessageOf(error) ?? '').toLowerCase();
|
||||||
return msg.contains('detached')
|
return msg.contains('detached')
|
||||||
? RunsLoadIssue.featureDisabled
|
? RunsLoadIssue.featureDisabled
|
||||||
: RunsLoadIssue.other;
|
: 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
|
||||||
|
|
|
||||||
|
|
@ -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>[];
|
||||||
|
|
|
||||||
80
lib/widgets/hub_load_error_view.dart
Normal file
80
lib/widgets/hub_load_error_view.dart
Normal 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,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
202
test/state_matrix_test.dart
Normal 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
297
test/support/fake_hub.dart
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue