From ea2cd87b5e2aaade20cc2a2e35314ac7d7ad3f66 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Sat, 18 Jul 2026 17:33:11 +0200 Subject: [PATCH 1/5] fix(runs): stop claiming the hub is unreachable on RPC-level errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runs monitor folded every load failure into 'hub not reachable', contradicting the sidebar's green connected dot whenever the hub answered but the RPC failed — most visibly against a pre-0.22 hub whose version predates the ListInvocations RPC (UNIMPLEMENTED). Classify the failure instead (top-level, unit-tested): - UNIMPLEMENTED -> 'this view needs a newer hub version' with a doctor-page link (the update banner lives there) - FAILED_PRECONDITION from the detached gate -> the regular feature-off empty state with the guide button - UNAVAILABLE / DEADLINE_EXCEEDED / socket-level failures -> the honest 'hub not reachable' state (unchanged) - everything else -> a load-failed state with the friendly error and a copyable detail box The error view is a public callback-driven widget so the tests pump each variant without a live hub. New DE+EN strings for the too-old and load-failed states; grpcCodeOf/grpcMessageOf exposed from the friendly-error mapper instead of duplicating the duck-typing. Signed-off-by: flemming-it --- lib/data/friendly_error.dart | 10 ++ lib/l10n/app_de.arb | 4 + lib/l10n/app_en.arb | 4 + lib/l10n/app_localizations.dart | 24 ++++ lib/l10n/app_localizations_de.dart | 14 ++ lib/l10n/app_localizations_en.dart | 13 ++ lib/pages/runs.dart | 146 ++++++++++++++++++- test/runs_error_classification_test.dart | 172 +++++++++++++++++++++++ 8 files changed, 380 insertions(+), 7 deletions(-) create mode 100644 test/runs_error_classification_test.dart diff --git a/lib/data/friendly_error.dart b/lib/data/friendly_error.dart index d12efce..e415485 100644 --- a/lib/data/friendly_error.dart +++ b/lib/data/friendly_error.dart @@ -217,6 +217,16 @@ FriendlyError? _matchHubPattern(String detail, AppLocalizations l) { return null; } +/// Duck-typed `GrpcError.code` reader — public so pages that +/// classify errors themselves (e.g. the runs monitor separating +/// "hub down" from "hub too old") share one accessor instead of +/// re-implementing the duck-typing. +int? grpcCodeOf(Object error) => _intField(error, 'code'); + +/// Duck-typed `GrpcError.message` reader — companion to +/// [grpcCodeOf]. +String? grpcMessageOf(Object error) => _stringField(error, 'message'); + /// Try to read an `int` field by name off an arbitrary object. /// Returns `null` when the field doesn't exist or has another /// runtime type. Used to duck-type `GrpcError.code` without diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 32357d0..3021d67 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1773,6 +1773,10 @@ "runsEmptyHint": "Hier erscheinen Läufe, die im Hintergrund weiterlaufen, während Sie anderes tun. Diese Funktion ist optional und standardmäßig ausgeschaltet — die Anleitung zeigt Schritt für Schritt, wie sie eingeschaltet wird.", "runsEmptyEnabledHint": "Läufe im Hintergrund sind eingeschaltet — es wurde nur noch keiner gestartet. Starten Sie einen Flow mit der Option „im Hintergrund ausführen“, dann erscheint er hier.", "runsEmptyGuideButton": "Anleitung öffnen", + "runsHubTooOldTitle": "Diese Ansicht braucht eine neuere Hub-Version", + "runsHubTooOldHint": "Der Hub ist verbunden, aber seine Version kennt die Laufübersicht noch nicht. Aktualisieren Sie den Hub, dann erscheinen die Läufe hier.", + "runsHubTooOldButton": "Diagnose öffnen", + "runsLoadFailedTitle": "Läufe konnten nicht geladen werden", "runsCancelButton": "Abbrechen", "runsCancelSignalled": "Abbruch für {flow} ausgelöst.", "@runsCancelSignalled": {"placeholders": {"flow": {"type": "String"}}}, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c08eea7..3b15097 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1812,6 +1812,10 @@ "runsEmptyHint": "Runs that keep working in the background while you do something else appear here. The feature is optional and off by default — the guide shows step by step how to turn it on.", "runsEmptyEnabledHint": "Background runs are switched on — none has been started yet. Start a flow with the \"run in background\" option and it will appear here.", "runsEmptyGuideButton": "Open the guide", + "runsHubTooOldTitle": "This view needs a newer hub version", + "runsHubTooOldHint": "The hub is connected, but its version does not know the runs monitor yet. Update the hub and the runs will appear here.", + "runsHubTooOldButton": "Open Doctor", + "runsLoadFailedTitle": "Runs could not be loaded", "runsCancelButton": "Cancel", "runsCancelSignalled": "Cancel signalled for {flow}.", "@runsCancelSignalled": {"placeholders": {"flow": {"type": "String"}}}, diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index db946a6..07d265b 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -5401,6 +5401,30 @@ abstract class AppLocalizations { /// **'Open the guide'** String get runsEmptyGuideButton; + /// No description provided for @runsHubTooOldTitle. + /// + /// In en, this message translates to: + /// **'This view needs a newer hub version'** + String get runsHubTooOldTitle; + + /// No description provided for @runsHubTooOldHint. + /// + /// In en, this message translates to: + /// **'The hub is connected, but its version does not know the runs monitor yet. Update the hub and the runs will appear here.'** + String get runsHubTooOldHint; + + /// No description provided for @runsHubTooOldButton. + /// + /// In en, this message translates to: + /// **'Open Doctor'** + String get runsHubTooOldButton; + + /// No description provided for @runsLoadFailedTitle. + /// + /// In en, this message translates to: + /// **'Runs could not be loaded'** + String get runsLoadFailedTitle; + /// No description provided for @runsCancelButton. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index f714a53..87f06e3 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -3198,6 +3198,20 @@ class AppLocalizationsDe extends AppLocalizations { @override String get runsEmptyGuideButton => 'Anleitung öffnen'; + @override + String get runsHubTooOldTitle => + 'Diese Ansicht braucht eine neuere Hub-Version'; + + @override + String get runsHubTooOldHint => + 'Der Hub ist verbunden, aber seine Version kennt die Laufübersicht noch nicht. Aktualisieren Sie den Hub, dann erscheinen die Läufe hier.'; + + @override + String get runsHubTooOldButton => 'Diagnose öffnen'; + + @override + String get runsLoadFailedTitle => 'Läufe konnten nicht geladen werden'; + @override String get runsCancelButton => 'Abbrechen'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 2e6ee2c..2a2fa2a 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -3195,6 +3195,19 @@ class AppLocalizationsEn extends AppLocalizations { @override String get runsEmptyGuideButton => 'Open the guide'; + @override + String get runsHubTooOldTitle => 'This view needs a newer hub version'; + + @override + String get runsHubTooOldHint => + 'The hub is connected, but its version does not know the runs monitor yet. Update the hub and the runs will appear here.'; + + @override + String get runsHubTooOldButton => 'Open Doctor'; + + @override + String get runsLoadFailedTitle => 'Runs could not be loaded'; + @override String get runsCancelButton => 'Cancel'; diff --git a/lib/pages/runs.dart b/lib/pages/runs.dart index 6f458c0..64fdf57 100644 --- a/lib/pages/runs.dart +++ b/lib/pages/runs.dart @@ -3,9 +3,11 @@ import 'dart:async'; import 'package:flutter/material.dart'; import '../data/error_presentation.dart'; +import '../data/friendly_error.dart'; import '../data/hub.dart'; import '../data/workspace.dart'; import '../l10n/app_localizations.dart'; +import '../main.dart' show StudioShellState; import '../theme/tokens.dart'; import '../widgets/widgets.dart'; import 'welcome.dart' show showFaiDoc; @@ -22,10 +24,138 @@ class RunsPage extends StatefulWidget { State createState() => _RunsPageState(); } +/// Why the runs monitor could not load its list. The page used to +/// fold every failure into "hub not reachable", which contradicted +/// the sidebar's green "connected" dot whenever the hub answered +/// with an RPC-level error (usertest finding: a 0.21 hub without +/// the ListInvocations RPC). Top-level so the unit test drives the +/// classification directly. +enum RunsLoadIssue { + /// The hub itself cannot be reached (socket-level failure or + /// gRPC UNAVAILABLE / DEADLINE_EXCEEDED). + unreachable, + + /// The hub answered, but its version predates the + /// ListInvocations RPC (gRPC UNIMPLEMENTED) — connected, just + /// too old for this view. + unsupported, + + /// The hub answered and refused because detached invocations + /// are switched off (gRPC FAILED_PRECONDITION from the detached + /// gate) — show the regular feature-off empty state, not a + /// connection error. + featureDisabled, + + /// Anything else — show the friendly error with copyable detail. + other, +} + +/// Classify a [HubService.listDetachedRuns] failure into the view +/// state the page should render. +RunsLoadIssue classifyRunsLoadError(Object error) { + switch (grpcCodeOf(error)) { + case 12: // UNIMPLEMENTED — hub predates the RPC + return RunsLoadIssue.unsupported; + case 9: // FAILED_PRECONDITION — the hub's detached gate + final msg = (grpcMessageOf(error) ?? '').toLowerCase(); + return msg.contains('detached') + ? RunsLoadIssue.featureDisabled + : RunsLoadIssue.other; + case 4: // DEADLINE_EXCEEDED + case 14: // UNAVAILABLE + return RunsLoadIssue.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 RunsLoadIssue.unreachable; + } + return RunsLoadIssue.other; +} + +/// The error rendition of the runs monitor — public + callback-driven +/// so the widget test can pump each variant without a live hub. +class RunsLoadErrorView extends StatelessWidget { + final Object error; + final RunsLoadIssue issue; + + /// Opens the runs guide (the feature-off empty state's CTA). + final VoidCallback onOpenGuide; + + /// Opens the doctor page (the "hub too old" state's CTA, where + /// the update banner lives). Null hides the button. + final VoidCallback? onOpenDoctor; + + const RunsLoadErrorView({ + super.key, + required this.error, + required this.issue, + required this.onOpenGuide, + this.onOpenDoctor, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l = AppLocalizations.of(context)!; + switch (issue) { + case RunsLoadIssue.featureDisabled: + // Same story as the regular feature-off empty state: the + // hub is fine, the operator just has not enabled the + // feature — never claim "not reachable" here. + return ChainEmptyState( + icon: Icons.rocket_launch_outlined, + title: l.runsEmptyTitle, + hint: l.runsEmptyHint, + action: OutlinedButton.icon( + icon: const Icon(Icons.menu_book_outlined, size: 16), + label: Text(l.runsEmptyGuideButton), + onPressed: onOpenGuide, + ), + ); + case RunsLoadIssue.unsupported: + return ChainEmptyState( + icon: Icons.system_update_alt_outlined, + title: l.runsHubTooOldTitle, + hint: l.runsHubTooOldHint, + action: onOpenDoctor == null + ? null + : OutlinedButton.icon( + icon: const Icon(Icons.health_and_safety_outlined, size: 16), + label: Text(l.runsHubTooOldButton), + onPressed: onOpenDoctor, + ), + ); + case RunsLoadIssue.unreachable: + return ChainEmptyState( + icon: Icons.cloud_off_outlined, + iconColor: theme.colorScheme.error, + title: l.hubUnreachable, + hint: l.hubUnreachableHint, + ); + case RunsLoadIssue.other: + return ChainEmptyState( + icon: Icons.error_outline, + iconColor: theme.colorScheme.error, + title: l.runsLoadFailedTitle, + action: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: ChainErrorBox(error: error, isError: true), + ), + ); + } + } +} + class _RunsPageState extends State { List _runs = const []; bool _detachedEnabled = false; - String? _error; + Object? _error; + RunsLoadIssue _issue = RunsLoadIssue.other; bool _loaded = false; Timer? _poll; final Set _cancelling = {}; @@ -63,7 +193,8 @@ class _RunsPageState extends State { } catch (e) { if (!mounted) return; setState(() { - _error = e.toString(); + _error = e; + _issue = classifyRunsLoadError(e); _loaded = true; }); } @@ -118,11 +249,12 @@ class _RunsPageState extends State { body: !_loaded ? const Center(child: CircularProgressIndicator()) : _error != null && _runs.isEmpty - ? ChainEmptyState( - icon: Icons.cloud_off_outlined, - iconColor: theme.colorScheme.error, - title: l.hubUnreachable, - hint: l.hubUnreachableHint, + ? RunsLoadErrorView( + error: _error!, + issue: _issue, + onOpenGuide: () => showFaiDoc(context, 'runs'), + onOpenDoctor: () => + StudioShellState.of(context)?.navigateTo('doctor'), ) : _runs.isEmpty ? ChainEmptyState( diff --git a/test/runs_error_classification_test.dart b/test/runs_error_classification_test.dart new file mode 100644 index 0000000..0249aab --- /dev/null +++ b/test/runs_error_classification_test.dart @@ -0,0 +1,172 @@ +// 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('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); + }); + }); +} From 383490027b1fabe2cdfdcb1fc5c44acbfd44fd39 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Sat, 18 Jul 2026 17:33:11 +0200 Subject: [PATCH 2/5] fix(audit): cancel the live-stream reconnect timer on dispose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The onDone handler armed an anonymous 3 s reconnect timer that nothing could cancel; when the stream closed right before the page was disposed (no hub, connect ends in onDone instead of onError), the timer outlived the tree. The a11y suite caught this as the rare 'Timer is still pending' flake noted in the night log — the failure reason is now captured and the timer lives in a field that dispose() cancels. Signed-off-by: flemming-it --- lib/pages/audit.dart | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/pages/audit.dart b/lib/pages/audit.dart index ce454a3..d9e7645 100644 --- a/lib/pages/audit.dart +++ b/lib/pages/audit.dart @@ -81,6 +81,7 @@ class _AuditPageState extends State { /// filtering, and the hash-chain ordering exactly as before. StreamSubscription? _eventSub; Timer? _nudgeDebounce; + Timer? _reconnect; @override void initState() { @@ -97,6 +98,7 @@ class _AuditPageState extends State { Workspace.instance.removeListener(_onWorkspaceChanged); _poller?.cancel(); _nudgeDebounce?.cancel(); + _reconnect?.cancel(); _eventSub?.cancel(); super.dispose(); } @@ -122,7 +124,12 @@ class _AuditPageState extends State { onDone: () { _eventSub = null; if (mounted) { - Timer(const Duration(seconds: 3), () { + // Held in a field so dispose() can cancel it — an + // anonymous timer here outlives the page when the + // stream closes right before navigation (the a11y + // suite caught this as a pending-timer flake). + _reconnect?.cancel(); + _reconnect = Timer(const Duration(seconds: 3), () { if (mounted && _eventSub == null) _subscribeLive(); }); } From c18bb7f357eced699460091a4670287f1ae84694 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Sat, 18 Jul 2026 18:02:03 +0200 Subject: [PATCH 3/5] fix(test): kill the a11y suite's pending-timer flake at its root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finally caught with a creation stack trace: when the last gRPC stream closes, Http2ClientConnection arms the channel's 5-minute idleTimeout timer — even on a shut-down connection — so the test's 1-minute drain never covered it and the framework's pending-timer invariant tripped whenever the arm landed inside the test window (frequent while a real hub listens on 50051). The suite now closes the channel in real-async space at the end of the body (new @visibleForTesting HubService.debugResetChannel; shutdown is deliberately not awaited — it wedges on a mid-connect socket, but cancels its timers synchronously) and pumps past the idle timeout so the timer fires inside the test. 6 consecutive full-suite runs + 3 isolated runs green; before, roughly 1 in 3 full runs failed. Signed-off-by: flemming-it --- lib/data/hub.dart | 19 +++++++++++++++++++ test/a11y_test.dart | 13 +++++++++++++ 2 files changed, 32 insertions(+) diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 23112c3..d0dc1cd 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -5,6 +5,7 @@ // Methods return UI-friendly types so pages stay free of // protobuf imports. +import 'dart:async' show unawaited; import 'dart:io'; import 'dart:typed_data'; @@ -196,6 +197,24 @@ class HubService { } } + /// Test hook: close the gRPC channel and replace the client with + /// a fresh, not-yet-dialled one (same endpoint + token). Suites + /// that pump the real app against no hub end with this so the + /// channel's internal reconnect/idle timers cannot outlive the + /// test body — a real socket failure landing late otherwise arms + /// a new backoff timer and trips the framework's pending-timer + /// invariant (the a11y suite's long-standing flake). + @visibleForTesting + void debugResetChannel() { + final old = _client; + _client = HubClient(endpoint: old.endpoint, authToken: _lastAuthToken); + // Deliberately not awaited: shutdown() wedges on a socket that + // is mid-connect, but the channel marks itself shut down and + // cancels its timers synchronously at the start — which is all + // this hook needs. + unawaited(old.close().catchError((_) {})); + } + /// Reload the token from disk and reconnect using the current /// endpoint. Called by Settings after the operator pastes or /// clears a token. diff --git a/test/a11y_test.dart b/test/a11y_test.dart index 3ef853a..981e52b 100644 --- a/test/a11y_test.dart +++ b/test/a11y_test.dart @@ -88,6 +88,19 @@ void main() { // !timersPending after the test body. await tester.pumpWidget(const SizedBox.shrink()); 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.delayed(const Duration(milliseconds: 100)); + }); + await tester.pump(const Duration(minutes: 6)); expect( violations, isEmpty, From e67e0c9e7364d94d036cdfe67d0de32d0055b3f8 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Sat, 18 Jul 2026 18:02:04 +0200 Subject: [PATCH 4/5] feat(doctor): deep-link findings to the page where they are actioned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Doctor findings used to be dead-end statements — 'approvals waiting for review' left the operator to find the approvals inbox on their own. Every finding with a dedicated surface is now one tap away from it: - summary tiles: modules -> store, approvals -> approvals inbox, audit chain -> audit log (chevron affordance, tooltip + semantics button; the services tile stays plain — no dedicated page) - modules/approvals panel rows link the same way - the event-log headline opens the audit page next to the existing verify button - host-services empty state gains a 'view the configuration' button opening the in-Studio config viewer the hint refers to - the update banner's release-notes URL is now an underlined, clickable link instead of dead text Tiles and the panel are public callback-driven widgets so the widget tests pump them without a live hub. New DE+EN link labels. Signed-off-by: flemming-it --- lib/l10n/app_de.arb | 5 + lib/l10n/app_en.arb | 5 + lib/l10n/app_localizations.dart | 30 ++ lib/l10n/app_localizations_de.dart | 15 + lib/l10n/app_localizations_en.dart | 15 + lib/pages/doctor.dart | 472 ++++++++++++++++++++--------- test/doctor_deeplinks_test.dart | 122 ++++++++ 7 files changed, 525 insertions(+), 139 deletions(-) create mode 100644 test/doctor_deeplinks_test.dart diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 3021d67..09f7b7d 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1184,6 +1184,11 @@ } }, "doctorSummaryDeclared": "deklariert", + "doctorLinkStore": "Store öffnen", + "doctorLinkApprovals": "Freigaben öffnen", + "doctorLinkAudit": "Protokoll öffnen", + "doctorLinkConfig": "Konfiguration ansehen", + "doctorLinkReleaseNotes": "Versionshinweise im Browser öffnen", "doctorModulesPanelSummary": "{n, plural, =1{1 Modul} other{{n} Module}} · {m, plural, =1{1 Fähigkeit} other{{m} Fähigkeiten}}", "@doctorModulesPanelSummary": { "placeholders": { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 3b15097..8280776 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1208,6 +1208,11 @@ } }, "doctorSummaryDeclared": "declared", + "doctorLinkStore": "Open the store", + "doctorLinkApprovals": "Open approvals", + "doctorLinkAudit": "Open the audit log", + "doctorLinkConfig": "View the configuration", + "doctorLinkReleaseNotes": "Open the release notes in the browser", "doctorModulesPanelSummary": "{n, plural, =1{1 module} other{{n} modules}} · {m, plural, =1{1 capability} other{{m} capabilities}}", "@doctorModulesPanelSummary": { "placeholders": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 07d265b..d2a8e09 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3662,6 +3662,36 @@ abstract class AppLocalizations { /// **'declared'** String get doctorSummaryDeclared; + /// No description provided for @doctorLinkStore. + /// + /// In en, this message translates to: + /// **'Open the store'** + String get doctorLinkStore; + + /// No description provided for @doctorLinkApprovals. + /// + /// In en, this message translates to: + /// **'Open approvals'** + String get doctorLinkApprovals; + + /// No description provided for @doctorLinkAudit. + /// + /// In en, this message translates to: + /// **'Open the audit log'** + String get doctorLinkAudit; + + /// No description provided for @doctorLinkConfig. + /// + /// In en, this message translates to: + /// **'View the configuration'** + String get doctorLinkConfig; + + /// No description provided for @doctorLinkReleaseNotes. + /// + /// In en, this message translates to: + /// **'Open the release notes in the browser'** + String get doctorLinkReleaseNotes; + /// No description provided for @doctorModulesPanelSummary. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 87f06e3..4a56518 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2132,6 +2132,21 @@ class AppLocalizationsDe extends AppLocalizations { @override String get doctorSummaryDeclared => 'deklariert'; + @override + String get doctorLinkStore => 'Store öffnen'; + + @override + String get doctorLinkApprovals => 'Freigaben öffnen'; + + @override + String get doctorLinkAudit => 'Protokoll öffnen'; + + @override + String get doctorLinkConfig => 'Konfiguration ansehen'; + + @override + String get doctorLinkReleaseNotes => 'Versionshinweise im Browser öffnen'; + @override String doctorModulesPanelSummary(int n, int m) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 2a2fa2a..f20b7e0 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2136,6 +2136,21 @@ class AppLocalizationsEn extends AppLocalizations { @override String get doctorSummaryDeclared => 'declared'; + @override + String get doctorLinkStore => 'Open the store'; + + @override + String get doctorLinkApprovals => 'Open approvals'; + + @override + String get doctorLinkAudit => 'Open the audit log'; + + @override + String get doctorLinkConfig => 'View the configuration'; + + @override + String get doctorLinkReleaseNotes => 'Open the release notes in the browser'; + @override String doctorModulesPanelSummary(int n, int m) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/pages/doctor.dart b/lib/pages/doctor.dart index 2079e36..32c6ae8 100644 --- a/lib/pages/doctor.dart +++ b/lib/pages/doctor.dart @@ -7,6 +7,7 @@ import '../data/hub.dart'; import '../data/hub_auth_token.dart'; import '../data/system_actions.dart'; import '../l10n/app_localizations.dart'; +import '../main.dart' show StudioShellState; import '../theme/theme.dart'; import '../theme/tokens.dart'; import '../widgets/widgets.dart'; @@ -75,23 +76,49 @@ class _DoctorPageState extends State { final showUpdate = s.update.updateAvailable || (!s.update.manifestReachable && s.update.localVersion.isNotEmpty); + // Findings that have a dedicated page link straight to it — + // "approvals waiting for review" must be one tap away from + // the approvals inbox, not a dead-end statement. + final shell = StudioShellState.of(context); + final openStore = shell == null + ? null + : () => shell.navigateTo('store'); + final openApprovals = shell == null + ? null + : () => shell.navigateTo('approvals'); + final openAudit = shell == null + ? null + : () => shell.navigateTo('audit'); return ListView( padding: const EdgeInsets.all(ChainSpace.xl), children: [ if (showUpdate) _UpdateBanner(status: s.update), if (showUpdate) const SizedBox(height: ChainSpace.lg), - _SummaryStrip(snapshot: s), + _SummaryStrip( + snapshot: s, + onOpenStore: openStore, + onOpenApprovals: openApprovals, + onOpenAudit: openAudit, + ), const SizedBox(height: ChainSpace.xl), _Section( title: AppLocalizations.of(context)!.doctorEventLogSection, - child: _EventLogPanel(snapshot: s, onRefresh: _refresh), + child: _EventLogPanel( + snapshot: s, + onRefresh: _refresh, + onOpenAudit: openAudit, + ), ), const SizedBox(height: ChainSpace.lg), _Section( title: AppLocalizations.of( context, )!.doctorModulesApprovalsSection, - child: _ModulesPanel(snapshot: s), + child: DoctorModulesPanel( + snapshot: s, + onOpenStore: openStore, + onOpenApprovals: openApprovals, + ), ), const SizedBox(height: ChainSpace.lg), _Section( @@ -150,22 +177,32 @@ class _Section extends StatelessWidget { class _SummaryStrip extends StatelessWidget { final DoctorSnapshot snapshot; + final VoidCallback? onOpenStore; + final VoidCallback? onOpenApprovals; + final VoidCallback? onOpenAudit; - const _SummaryStrip({required this.snapshot}); + const _SummaryStrip({ + required this.snapshot, + this.onOpenStore, + this.onOpenApprovals, + this.onOpenAudit, + }); @override Widget build(BuildContext context) { final l = AppLocalizations.of(context)!; return Row( children: [ - _StatTile( + DoctorStatTile( label: l.doctorSummaryModules, value: snapshot.moduleCount.toString(), subtitle: l.doctorSummaryCapabilities(snapshot.capabilityCount), icon: Icons.extension_outlined, + onTap: onOpenStore, + linkLabel: l.doctorLinkStore, ), const SizedBox(width: ChainSpace.md), - _StatTile( + DoctorStatTile( label: l.doctorSummaryApprovals, value: snapshot.pendingApprovals.toString(), subtitle: l.doctorSummaryPending, @@ -173,9 +210,11 @@ class _SummaryStrip extends StatelessWidget { tone: snapshot.pendingApprovals > 0 ? ChainPillTone.warning : ChainPillTone.neutral, + onTap: onOpenApprovals, + linkLabel: l.doctorLinkApprovals, ), const SizedBox(width: ChainSpace.md), - _StatTile( + DoctorStatTile( label: l.doctorSummaryAudit, value: snapshot.chainHealthy ? '✓' : '⚠', subtitle: l.doctorSummaryChain( @@ -186,9 +225,11 @@ class _SummaryStrip extends StatelessWidget { tone: snapshot.chainHealthy ? ChainPillTone.success : ChainPillTone.danger, + onTap: onOpenAudit, + linkLabel: l.doctorLinkAudit, ), const SizedBox(width: ChainSpace.md), - _StatTile( + DoctorStatTile( label: l.doctorSummaryServices, value: snapshot.services.length.toString(), subtitle: l.doctorSummaryDeclared, @@ -199,19 +240,32 @@ class _SummaryStrip extends StatelessWidget { } } -class _StatTile extends StatelessWidget { +/// One summary tile of the doctor page. Public + callback-driven so +/// the deep-link widget test can pump it directly: when [onTap] is +/// set, the whole tile becomes a button (chevron affordance, +/// tooltip + semantics from [linkLabel]) that jumps to the page +/// where the finding can be acted on. +class DoctorStatTile extends StatelessWidget { final String label; final String value; final String subtitle; final IconData icon; final ChainPillTone tone; + final VoidCallback? onTap; - const _StatTile({ + /// Human-readable tap target ("Open approvals"). Required when + /// [onTap] is set; doubles as tooltip and semantics label. + final String? linkLabel; + + const DoctorStatTile({ + super.key, required this.label, required this.value, required this.subtitle, required this.icon, this.tone = ChainPillTone.neutral, + this.onTap, + this.linkLabel, }); @override @@ -223,49 +277,72 @@ class _StatTile extends StatelessWidget { ChainPillTone.danger => theme.colorScheme.error, _ => theme.colorScheme.primary, }; - return Expanded( - child: ChainCard( - accentLeft: accentColor, - padding: const EdgeInsets.all(ChainSpace.lg), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(icon, size: 14, color: theme.colorScheme.onSurfaceVariant), - const SizedBox(width: ChainSpace.xs), - Flexible( - child: Text( - label.toUpperCase(), - overflow: TextOverflow.ellipsis, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - letterSpacing: 0.5, - fontSize: 10, - ), + Widget card = ChainCard( + accentLeft: accentColor, + padding: const EdgeInsets.all(ChainSpace.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 14, color: theme.colorScheme.onSurfaceVariant), + const SizedBox(width: ChainSpace.xs), + Flexible( + child: Text( + label.toUpperCase(), + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + letterSpacing: 0.5, + fontSize: 10, ), ), + ), + if (onTap != null) ...[ + const Spacer(), + Icon( + Icons.chevron_right, + size: 16, + color: theme.colorScheme.onSurfaceVariant, + ), ], + ], + ), + const SizedBox(height: ChainSpace.sm), + Text( + value, + style: theme.textTheme.displaySmall?.copyWith( + color: accentColor, + fontWeight: FontWeight.w700, ), - const SizedBox(height: ChainSpace.sm), - Text( - value, - style: theme.textTheme.displaySmall?.copyWith( - color: accentColor, - fontWeight: FontWeight.w700, - ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, ), - const SizedBox(height: 2), - Text( - subtitle, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ), + ), + ], ), ); + if (onTap != null) { + // ChainCard is a plain Container, so the InkWell needs its own + // transparent Material to paint the hover/ripple feedback. + card = Tooltip( + message: linkLabel ?? '', + waitDuration: const Duration(milliseconds: 400), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(ChainRadius.md), + child: Semantics(button: true, label: linkLabel, child: card), + ), + ), + ); + } + return Expanded(child: card); } } @@ -277,13 +354,60 @@ class _EventLogPanel extends StatelessWidget { /// after import / restore without restarting the daemon. final VoidCallback onRefresh; - const _EventLogPanel({required this.snapshot, required this.onRefresh}); + /// Jumps to the audit page — the finding's detail view (per-event + /// list, forensic exports) lives there. + final VoidCallback? onOpenAudit; + + const _EventLogPanel({ + required this.snapshot, + required this.onRefresh, + this.onOpenAudit, + }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final healthy = snapshot.chainHealthy; final l = AppLocalizations.of(context)!; + Widget headline = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + healthy ? l.doctorChainIntact : l.doctorChainTampered, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + healthy + ? l.doctorChainIntactDetail(snapshot.eventChainTotal) + : l.doctorChainTamperedDetail( + snapshot.eventChainVerified, + snapshot.eventChainTotal, + snapshot.eventChainTamperedAt ?? '', + ), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ); + if (onOpenAudit != null) { + headline = Tooltip( + message: l.doctorLinkAudit, + waitDuration: const Duration(milliseconds: 400), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onOpenAudit, + borderRadius: BorderRadius.circular(ChainRadius.sm), + child: Semantics(button: true, label: l.doctorLinkAudit, + child: headline), + ), + ), + ); + } return ChainCard( child: Row( children: [ @@ -293,32 +417,7 @@ class _EventLogPanel extends StatelessWidget { color: healthy ? ChainColors.success : theme.colorScheme.error, ), const SizedBox(width: ChainSpace.lg), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - healthy ? l.doctorChainIntact : l.doctorChainTampered, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 2), - Text( - healthy - ? l.doctorChainIntactDetail(snapshot.eventChainTotal) - : l.doctorChainTamperedDetail( - snapshot.eventChainVerified, - snapshot.eventChainTotal, - snapshot.eventChainTamperedAt ?? '', - ), - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), + Expanded(child: headline), OutlinedButton.icon( onPressed: onRefresh, icon: const Icon(Icons.fact_check_outlined, size: 16), @@ -834,10 +933,43 @@ String _sourceKindLabel(String kind) { } } -class _ModulesPanel extends StatelessWidget { +/// The modules + approvals findings card. Public + callback-driven +/// so the deep-link widget test can pump it without a hub: each row +/// that has a dedicated page is a tap target ("approvals waiting +/// for review" jumps to the approvals inbox, the modules line to +/// the store). +class DoctorModulesPanel extends StatelessWidget { final DoctorSnapshot snapshot; + final VoidCallback? onOpenStore; + final VoidCallback? onOpenApprovals; - const _ModulesPanel({required this.snapshot}); + const DoctorModulesPanel({ + super.key, + required this.snapshot, + this.onOpenStore, + this.onOpenApprovals, + }); + + Widget _linkRow( + BuildContext context, { + required Widget child, + required VoidCallback? onTap, + required String linkLabel, + }) { + if (onTap == null) return child; + return Tooltip( + message: linkLabel, + waitDuration: const Duration(milliseconds: 400), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(ChainRadius.sm), + child: Semantics(button: true, label: linkLabel, child: child), + ), + ), + ); + } @override Widget build(BuildContext context) { @@ -847,69 +979,95 @@ class _ModulesPanel extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - Icon( - Icons.extension_outlined, - size: 18, - color: theme.colorScheme.onSurfaceVariant, - ), - const SizedBox(width: ChainSpace.sm), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l.doctorModulesPanelSummary( - snapshot.moduleCount, - snapshot.capabilityCount, - ), - style: theme.textTheme.bodyMedium, - ), - if (snapshot.capabilitiesBySource.isNotEmpty) ...[ - const SizedBox(height: 2), - Text( - _sourceBreakdown(snapshot.capabilitiesBySource), - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ], + _linkRow( + context, + onTap: onOpenStore, + linkLabel: l.doctorLinkStore, + child: Row( + children: [ + Icon( + Icons.extension_outlined, + size: 18, + color: theme.colorScheme.onSurfaceVariant, ), - ), - ChainPill( - label: snapshot.moduleCount > 0 - ? l.doctorPillLoaded - : l.doctorPillEmpty, - tone: snapshot.moduleCount > 0 - ? ChainPillTone.success - : ChainPillTone.neutral, - ), - ], + const SizedBox(width: ChainSpace.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l.doctorModulesPanelSummary( + snapshot.moduleCount, + snapshot.capabilityCount, + ), + style: theme.textTheme.bodyMedium, + ), + if (snapshot.capabilitiesBySource.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + _sourceBreakdown(snapshot.capabilitiesBySource), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + ChainPill( + label: snapshot.moduleCount > 0 + ? l.doctorPillLoaded + : l.doctorPillEmpty, + tone: snapshot.moduleCount > 0 + ? ChainPillTone.success + : ChainPillTone.neutral, + ), + if (onOpenStore != null) ...[ + const SizedBox(width: ChainSpace.xs), + Icon( + Icons.chevron_right, + size: 16, + color: theme.colorScheme.onSurfaceVariant, + ), + ], + ], + ), ), const Divider(height: ChainSpace.xl), - Row( - children: [ - Icon( - Icons.inbox_outlined, - size: 18, - color: theme.colorScheme.onSurfaceVariant, - ), - const SizedBox(width: ChainSpace.sm), - Text( - snapshot.pendingApprovals == 0 - ? l.doctorApprovalsNone - : l.doctorApprovalsCount(snapshot.pendingApprovals), - style: theme.textTheme.bodyMedium, - ), - const Spacer(), - if (snapshot.pendingApprovals > 0) - ChainPill( - label: l.doctorApprovalsAttentionPill, - tone: ChainPillTone.warning, + _linkRow( + context, + onTap: onOpenApprovals, + linkLabel: l.doctorLinkApprovals, + child: Row( + children: [ + Icon( + Icons.inbox_outlined, + size: 18, + color: theme.colorScheme.onSurfaceVariant, ), - ], + const SizedBox(width: ChainSpace.sm), + Text( + snapshot.pendingApprovals == 0 + ? l.doctorApprovalsNone + : l.doctorApprovalsCount(snapshot.pendingApprovals), + style: theme.textTheme.bodyMedium, + ), + const Spacer(), + if (snapshot.pendingApprovals > 0) + ChainPill( + label: l.doctorApprovalsAttentionPill, + tone: ChainPillTone.warning, + ), + if (onOpenApprovals != null) ...[ + const SizedBox(width: ChainSpace.xs), + Icon( + Icons.chevron_right, + size: 16, + color: theme.colorScheme.onSurfaceVariant, + ), + ], + ], + ), ), ], ), @@ -960,6 +1118,21 @@ class _ServicesPanel extends StatelessWidget { color: theme.colorScheme.onSurfaceVariant, ), ), + // The hint names config.yaml — put the file one tap away + // instead of making the operator hunt for it. + if (snapshot.paths.configPath.isNotEmpty) + OutlinedButton.icon( + onPressed: () => showFaiConfigViewer( + context, + path: snapshot.paths.configPath, + title: l.doctorPathConfig, + ), + icon: const Icon(Icons.settings_outlined, size: 14), + label: Text(l.doctorLinkConfig), + style: OutlinedButton.styleFrom( + visualDensity: VisualDensity.compact, + ), + ), ], ), ); @@ -1085,11 +1258,32 @@ class _UpdateBannerState extends State<_UpdateBanner> { ), if (available && status.releaseNotesUrl != null) ...[ const SizedBox(height: 4), - Text( - l.doctorReleaseNotes(status.releaseNotesUrl!), - style: ChainTheme.mono( - size: 11, - color: theme.colorScheme.primary, + // A URL the operator cannot click is a finding + // without a link — open it in the browser. + Tooltip( + message: l.doctorLinkReleaseNotes, + waitDuration: const Duration(milliseconds: 400), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => SystemActions.openInOs( + status.releaseNotesUrl!, + ), + child: Semantics( + button: true, + label: l.doctorLinkReleaseNotes, + child: Text( + l.doctorReleaseNotes(status.releaseNotesUrl!), + style: ChainTheme.mono( + size: 11, + color: theme.colorScheme.primary, + ).copyWith( + decoration: TextDecoration.underline, + decorationColor: theme.colorScheme.primary, + ), + ), + ), + ), ), ), ], diff --git a/test/doctor_deeplinks_test.dart b/test/doctor_deeplinks_test.dart new file mode 100644 index 0000000..1b44597 --- /dev/null +++ b/test/doctor_deeplinks_test.dart @@ -0,0 +1,122 @@ +// Doctor deep links — findings with a dedicated page must be one +// tap away from it (usertest follow-up: "approvals waiting for +// review" was a dead-end statement). The tiles and panel rows are +// public callback-driven widgets so these tests pump each variant +// without a live hub. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chain_studio/data/hub.dart'; +import 'package:chain_studio/l10n/app_localizations.dart'; +import 'package:chain_studio/pages/doctor.dart'; + +const _snapshot = DoctorSnapshot( + moduleCount: 2, + capabilityCount: 3, + pendingApprovals: 1, + eventChainTotal: 5, + eventChainVerified: 5, + eventChainTamperedAt: null, + services: [], + update: UpdateStatus( + channel: 'stable', + localVersion: '0.22.0', + latestVersion: '0.22.0', + updateAvailable: false, + manifestReachable: true, + ), + paths: DaemonPathsSnapshot( + logPath: '', + dbPath: '', + modulesDir: '', + flowsDir: '', + configPath: '', + pidPath: '', + ), +); + +Widget _host(Widget child) => MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('de'), + home: Scaffold(body: child), +); + +void main() { + testWidgets('a linked stat tile is a labelled button that fires its target', ( + tester, + ) async { + var opened = false; + await tester.pumpWidget( + _host( + Row( + children: [ + DoctorStatTile( + label: 'Freigaben', + value: '1', + subtitle: 'warten auf Prüfung', + icon: Icons.inbox_outlined, + onTap: () => opened = true, + linkLabel: 'Freigaben öffnen', + ), + ], + ), + ), + ); + await tester.pumpAndSettle(); + // Affordance: chevron + semantics button with the link label. + expect(find.byIcon(Icons.chevron_right), findsOneWidget); + expect( + find.bySemanticsLabel(RegExp('Freigaben öffnen')), + findsOneWidget, + ); + await tester.tap(find.text('1')); + expect(opened, isTrue); + }); + + testWidgets('an unlinked stat tile stays a plain card', (tester) async { + await tester.pumpWidget( + _host( + const Row( + children: [ + DoctorStatTile( + label: 'Dienste', + value: '0', + subtitle: 'deklariert', + icon: Icons.dns_outlined, + ), + ], + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.chevron_right), findsNothing); + expect(find.byType(InkWell), findsNothing); + }); + + testWidgets( + 'the approvals row links to approvals, the modules row to the store', + (tester) async { + var storeOpened = false; + var approvalsOpened = false; + await tester.pumpWidget( + _host( + DoctorModulesPanel( + snapshot: _snapshot, + onOpenStore: () => storeOpened = true, + onOpenApprovals: () => approvalsOpened = true, + ), + ), + ); + await tester.pumpAndSettle(); + // The pending-approvals finding must be tappable ("1 Freigabe + // wartet auf Prüfung" -> approvals inbox). + await tester.tap(find.textContaining('wartet auf Prüfung')); + expect(approvalsOpened, isTrue); + expect(storeOpened, isFalse); + await tester.tap(find.textContaining('2 Module')); + expect(storeOpened, isTrue); + }, + ); +} From adc5fc231106229289f50465828e608c293f937e Mon Sep 17 00:00:00 2001 From: flemming-it Date: Sat, 18 Jul 2026 18:02:44 +0200 Subject: [PATCH 5/5] chore: bump to 0.74.0 + changelog for the doctor/runs round Signed-off-by: flemming-it --- CHANGELOG.md | 25 +++++++++++++++++++++++++ lib/data/about_info.dart | 2 +- pubspec.yaml | 2 +- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34a16e9..ee59dda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,31 @@ lockstep. ## Unreleased +### Added (0.74.0) + +- **Doctor findings deep-link to their page.** Summary tiles and + panel rows jump to where the finding is actioned: modules → store, + "approvals waiting for review" → approvals inbox, audit chain → + audit log (chevron affordance, tooltip + semantics button). The + host-services empty state gains a "view the configuration" button, + and the update banner's release-notes URL is a real link now. + +### Fixed (0.74.0) + +- **Runs page no longer contradicts the sidebar.** Load failures are + classified instead of all reading "hub not reachable": an older hub + without the ListInvocations RPC gets "this view needs a newer hub + version" with a doctor link, the detached-off refusal shows the + regular feature-off empty state, and unknown errors surface a + copyable detail box. "Not reachable" is reserved for genuine + connection failures. +- **Audit page timer leak.** The live-stream reconnect timer is held + and cancelled on dispose. +- **a11y suite flake killed at the root.** The pending-timer failures + came from the gRPC channel's 5-minute idle timer being armed when + the last stream closes; the suite now closes the channel and pumps + past the idle timeout (6 consecutive full runs green). + ### Added - **Full-history audit export.** The audit page's actions menu gains diff --git a/lib/data/about_info.dart b/lib/data/about_info.dart index 790d978..af79ecf 100644 --- a/lib/data/about_info.dart +++ b/lib/data/about_info.dart @@ -4,7 +4,7 @@ /// Studio's own build version. Bump on every UI release so the /// running app self-identifies. -const String kStudioVersion = '0.73.0'; +const String kStudioVersion = '0.74.0'; const String kProductName = 'Ch∆In Studio'; const String kVendorName = 'Flemming.AI (F∆I)'; diff --git a/pubspec.yaml b/pubspec.yaml index 362f75e..1c8dcbc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: chain_studio description: "Ch∆In Studio — desktop GUI for the Ch∆In hub" publish_to: 'none' -version: 0.73.0 +version: 0.74.0 environment: sdk: ^3.11.0-200.1.beta