Approvals: the pending card now shows its full origin — flow, step, run id (previously dropped at the Dart mapping layer), project, and requested-at — under an ORIGIN heading, led by a one-line intro strip that says what the inbox is and what Approve/Reject do. Approve/Reject buttons carry tooltips; the history dialog gains project + run id. Fixes the approvals doc drift (title/details/reviewer -> prompt/show/timeout_seconds). Guard: approvals_origin_test renders the card via the hermetic fake hub and pins every origin fact. Runs: the "hub too old" state now leads with an in-place update button (same `chain update apply` path as the Diagnose page), the Diagnose deeplink demoted to secondary, with a CLI-absent fallback. Guard: two new RunsLoadErrorView widget tests. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
219 lines
7.1 KiB
Dart
219 lines
7.1 KiB
Dart
// Runs-monitor error classification — the page used to render
|
|
// every load failure as "hub not reachable", contradicting the
|
|
// sidebar's green "connected" dot whenever the hub answered but
|
|
// the RPC failed (usertest finding: a 0.21 hub without the
|
|
// ListInvocations RPC answered UNIMPLEMENTED and the page claimed
|
|
// the hub was down). These tests pin the classification and the
|
|
// widget rendition of each state.
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
import 'package:chain_studio/l10n/app_localizations.dart';
|
|
import 'package:chain_studio/pages/runs.dart';
|
|
|
|
/// Duck-typed stand-in for `GrpcError` — the classifier reads
|
|
/// `.code` and `.message` off whatever object arrives, exactly
|
|
/// like the friendly-error mapper does.
|
|
class _FakeGrpcError {
|
|
final int code;
|
|
final String? message;
|
|
const _FakeGrpcError(this.code, [this.message]);
|
|
|
|
@override
|
|
String toString() => 'gRPC Error (code: $code, message: $message)';
|
|
}
|
|
|
|
Widget _host(Widget child) => MaterialApp(
|
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
|
supportedLocales: AppLocalizations.supportedLocales,
|
|
locale: const Locale('de'),
|
|
home: Scaffold(body: child),
|
|
);
|
|
|
|
void main() {
|
|
group('classifyRunsLoadError', () {
|
|
test('UNIMPLEMENTED means the hub is too old, not unreachable', () {
|
|
expect(
|
|
classifyRunsLoadError(
|
|
const _FakeGrpcError(12, 'grpc.Hub/ListInvocations unimplemented'),
|
|
),
|
|
RunsLoadIssue.unsupported,
|
|
);
|
|
});
|
|
|
|
test('FAILED_PRECONDITION from the detached gate is feature-off', () {
|
|
expect(
|
|
classifyRunsLoadError(
|
|
const _FakeGrpcError(
|
|
9,
|
|
'detached invocations are not enabled — set detached.enabled: '
|
|
'true in the operator config',
|
|
),
|
|
),
|
|
RunsLoadIssue.featureDisabled,
|
|
);
|
|
});
|
|
|
|
test('other FAILED_PRECONDITION errors keep their own story', () {
|
|
expect(
|
|
classifyRunsLoadError(const _FakeGrpcError(9, 'store busy')),
|
|
RunsLoadIssue.other,
|
|
);
|
|
});
|
|
|
|
test('UNAVAILABLE and DEADLINE_EXCEEDED are unreachable', () {
|
|
expect(
|
|
classifyRunsLoadError(const _FakeGrpcError(14, 'connection refused')),
|
|
RunsLoadIssue.unreachable,
|
|
);
|
|
expect(
|
|
classifyRunsLoadError(const _FakeGrpcError(4, 'deadline exceeded')),
|
|
RunsLoadIssue.unreachable,
|
|
);
|
|
});
|
|
|
|
test('socket-level failures without a gRPC code are unreachable', () {
|
|
expect(
|
|
classifyRunsLoadError(
|
|
Exception('SocketException: Connection refused (port 50051)'),
|
|
),
|
|
RunsLoadIssue.unreachable,
|
|
);
|
|
});
|
|
|
|
test('arbitrary errors fall through to other', () {
|
|
expect(
|
|
classifyRunsLoadError(const FormatException('bad payload')),
|
|
RunsLoadIssue.other,
|
|
);
|
|
});
|
|
});
|
|
|
|
group('RunsLoadErrorView', () {
|
|
testWidgets('feature-off renders the plain-language empty state, '
|
|
'never "not reachable"', (tester) async {
|
|
await tester.pumpWidget(
|
|
_host(
|
|
RunsLoadErrorView(
|
|
error: const _FakeGrpcError(
|
|
9,
|
|
'detached invocations are not enabled',
|
|
),
|
|
issue: RunsLoadIssue.featureDisabled,
|
|
onOpenGuide: () {},
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('Keine Läufe im Hintergrund'), findsOneWidget);
|
|
expect(find.text('Anleitung öffnen'), findsOneWidget);
|
|
expect(find.text('Hub nicht erreichbar'), findsNothing);
|
|
});
|
|
|
|
testWidgets('too-old hub says so and links to the doctor page', (
|
|
tester,
|
|
) async {
|
|
var doctorOpened = false;
|
|
await tester.pumpWidget(
|
|
_host(
|
|
RunsLoadErrorView(
|
|
error: const _FakeGrpcError(12, 'unimplemented'),
|
|
issue: RunsLoadIssue.unsupported,
|
|
onOpenGuide: () {},
|
|
onOpenDoctor: () => doctorOpened = true,
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
expect(
|
|
find.text('Diese Ansicht braucht eine neuere Hub-Version'),
|
|
findsOneWidget,
|
|
);
|
|
expect(find.text('Hub nicht erreichbar'), findsNothing);
|
|
await tester.tap(find.text('Diagnose öffnen'));
|
|
expect(doctorOpened, isTrue);
|
|
});
|
|
|
|
testWidgets('too-old hub leads with an in-place update button, '
|
|
'doctor demoted to secondary', (tester) async {
|
|
var updateTriggered = false;
|
|
await tester.pumpWidget(
|
|
_host(
|
|
RunsLoadErrorView(
|
|
error: const _FakeGrpcError(12, 'unimplemented'),
|
|
issue: RunsLoadIssue.unsupported,
|
|
onOpenGuide: () {},
|
|
onOpenDoctor: () {},
|
|
onUpdateHub: () => updateTriggered = true,
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
// Primary CTA is the fix, not the detour.
|
|
expect(find.text('Hub jetzt aktualisieren'), findsOneWidget);
|
|
expect(find.text('Diagnose öffnen'), findsOneWidget);
|
|
await tester.tap(find.text('Hub jetzt aktualisieren'));
|
|
expect(updateTriggered, isTrue);
|
|
});
|
|
|
|
testWidgets('while updating, the button shows progress and is disabled', (
|
|
tester,
|
|
) async {
|
|
var updateTriggered = false;
|
|
await tester.pumpWidget(
|
|
_host(
|
|
RunsLoadErrorView(
|
|
error: const _FakeGrpcError(12, 'unimplemented'),
|
|
issue: RunsLoadIssue.unsupported,
|
|
onOpenGuide: () {},
|
|
onOpenDoctor: () {},
|
|
onUpdateHub: () => updateTriggered = true,
|
|
updating: true,
|
|
),
|
|
),
|
|
);
|
|
// The spinner animates forever, so pump one frame rather than
|
|
// pumpAndSettle (which would time out waiting for it to stop).
|
|
await tester.pump();
|
|
expect(find.text('Hub wird aktualisiert…'), findsOneWidget);
|
|
// Disabled while in flight — a tap must not re-fire.
|
|
await tester.tap(find.byType(FilledButton), warnIfMissed: false);
|
|
expect(updateTriggered, isFalse);
|
|
});
|
|
|
|
testWidgets('a genuinely unreachable hub still says unreachable', (
|
|
tester,
|
|
) async {
|
|
await tester.pumpWidget(
|
|
_host(
|
|
RunsLoadErrorView(
|
|
error: const _FakeGrpcError(14, 'connection refused'),
|
|
issue: RunsLoadIssue.unreachable,
|
|
onOpenGuide: () {},
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('Hub nicht erreichbar'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('other errors surface a copyable detail box', (tester) async {
|
|
await tester.pumpWidget(
|
|
_host(
|
|
RunsLoadErrorView(
|
|
error: const _FakeGrpcError(13, 'internal boom'),
|
|
issue: RunsLoadIssue.other,
|
|
onOpenGuide: () {},
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('Läufe konnten nicht geladen werden'), findsOneWidget);
|
|
expect(find.text('Hub nicht erreichbar'), findsNothing);
|
|
// The copy affordance is the hard rule: the detail box must
|
|
// be present so the operator can copy the real message.
|
|
expect(find.byIcon(Icons.content_copy), findsWidgets);
|
|
});
|
|
});
|
|
}
|