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>
202 lines
6.5 KiB
Dart
202 lines
6.5 KiB
Dart
// 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);
|
|
});
|
|
}
|