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>
This commit is contained in:
parent
adc5fc2311
commit
66b26304fd
19 changed files with 733 additions and 97 deletions
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);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue