Compare commits

...

5 commits

Author SHA1 Message Date
flemming-it
adc5fc2311 chore: bump to 0.74.0 + changelog for the doctor/runs round
Some checks failed
Security / Security check (push) Failing after 2s
Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-07-18 18:02:44 +02:00
flemming-it
e67e0c9e73 feat(doctor): deep-link findings to the page where they are actioned
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 <stefan.a.flemming@googlemail.com>
2026-07-18 18:02:04 +02:00
flemming-it
c18bb7f357 fix(test): kill the a11y suite's pending-timer flake at its root
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 <stefan.a.flemming@googlemail.com>
2026-07-18 18:02:03 +02:00
flemming-it
383490027b fix(audit): cancel the live-stream reconnect timer on dispose
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 <stefan.a.flemming@googlemail.com>
2026-07-18 17:33:11 +02:00
flemming-it
ea2cd87b5e fix(runs): stop claiming the hub is unreachable on RPC-level errors
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 <stefan.a.flemming@googlemail.com>
2026-07-18 17:33:11 +02:00
16 changed files with 972 additions and 149 deletions

View file

@ -6,6 +6,31 @@ lockstep.
## Unreleased ## 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 ### Added
- **Full-history audit export.** The audit page's actions menu gains - **Full-history audit export.** The audit page's actions menu gains

View file

@ -4,7 +4,7 @@
/// Studio's own build version. Bump on every UI release so the /// Studio's own build version. Bump on every UI release so the
/// running app self-identifies. /// running app self-identifies.
const String kStudioVersion = '0.73.0'; const String kStudioVersion = '0.74.0';
const String kProductName = 'Ch∆In Studio'; const String kProductName = 'Ch∆In Studio';
const String kVendorName = 'Flemming.AI (F∆I)'; const String kVendorName = 'Flemming.AI (F∆I)';

View file

@ -217,6 +217,16 @@ FriendlyError? _matchHubPattern(String detail, AppLocalizations l) {
return null; 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. /// Try to read an `int` field by name off an arbitrary object.
/// Returns `null` when the field doesn't exist or has another /// Returns `null` when the field doesn't exist or has another
/// runtime type. Used to duck-type `GrpcError.code` without /// runtime type. Used to duck-type `GrpcError.code` without

View file

@ -5,6 +5,7 @@
// Methods return UI-friendly types so pages stay free of // Methods return UI-friendly types so pages stay free of
// protobuf imports. // protobuf imports.
import 'dart:async' show unawaited;
import 'dart:io'; import 'dart:io';
import 'dart:typed_data'; 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 /// Reload the token from disk and reconnect using the current
/// endpoint. Called by Settings after the operator pastes or /// endpoint. Called by Settings after the operator pastes or
/// clears a token. /// clears a token.

View file

@ -1184,6 +1184,11 @@
} }
}, },
"doctorSummaryDeclared": "deklariert", "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": "{n, plural, =1{1 Modul} other{{n} Module}} · {m, plural, =1{1 Fähigkeit} other{{m} Fähigkeiten}}",
"@doctorModulesPanelSummary": { "@doctorModulesPanelSummary": {
"placeholders": { "placeholders": {
@ -1773,6 +1778,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.", "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.", "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", "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", "runsCancelButton": "Abbrechen",
"runsCancelSignalled": "Abbruch für {flow} ausgelöst.", "runsCancelSignalled": "Abbruch für {flow} ausgelöst.",
"@runsCancelSignalled": {"placeholders": {"flow": {"type": "String"}}}, "@runsCancelSignalled": {"placeholders": {"flow": {"type": "String"}}},

View file

@ -1208,6 +1208,11 @@
} }
}, },
"doctorSummaryDeclared": "declared", "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": "{n, plural, =1{1 module} other{{n} modules}} · {m, plural, =1{1 capability} other{{m} capabilities}}",
"@doctorModulesPanelSummary": { "@doctorModulesPanelSummary": {
"placeholders": { "placeholders": {
@ -1812,6 +1817,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.", "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.", "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", "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", "runsCancelButton": "Cancel",
"runsCancelSignalled": "Cancel signalled for {flow}.", "runsCancelSignalled": "Cancel signalled for {flow}.",
"@runsCancelSignalled": {"placeholders": {"flow": {"type": "String"}}}, "@runsCancelSignalled": {"placeholders": {"flow": {"type": "String"}}},

View file

@ -3662,6 +3662,36 @@ abstract class AppLocalizations {
/// **'declared'** /// **'declared'**
String get doctorSummaryDeclared; 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. /// No description provided for @doctorModulesPanelSummary.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -5401,6 +5431,30 @@ abstract class AppLocalizations {
/// **'Open the guide'** /// **'Open the guide'**
String get runsEmptyGuideButton; 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. /// No description provided for @runsCancelButton.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -2132,6 +2132,21 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get doctorSummaryDeclared => 'deklariert'; 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 @override
String doctorModulesPanelSummary(int n, int m) { String doctorModulesPanelSummary(int n, int m) {
String _temp0 = intl.Intl.pluralLogic( String _temp0 = intl.Intl.pluralLogic(
@ -3198,6 +3213,20 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get runsEmptyGuideButton => 'Anleitung öffnen'; 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 @override
String get runsCancelButton => 'Abbrechen'; String get runsCancelButton => 'Abbrechen';

View file

@ -2136,6 +2136,21 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get doctorSummaryDeclared => 'declared'; 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 @override
String doctorModulesPanelSummary(int n, int m) { String doctorModulesPanelSummary(int n, int m) {
String _temp0 = intl.Intl.pluralLogic( String _temp0 = intl.Intl.pluralLogic(
@ -3195,6 +3210,19 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get runsEmptyGuideButton => 'Open the guide'; 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 @override
String get runsCancelButton => 'Cancel'; String get runsCancelButton => 'Cancel';

View file

@ -81,6 +81,7 @@ class _AuditPageState extends State<AuditPage> {
/// filtering, and the hash-chain ordering exactly as before. /// filtering, and the hash-chain ordering exactly as before.
StreamSubscription<AuditEvent>? _eventSub; StreamSubscription<AuditEvent>? _eventSub;
Timer? _nudgeDebounce; Timer? _nudgeDebounce;
Timer? _reconnect;
@override @override
void initState() { void initState() {
@ -97,6 +98,7 @@ class _AuditPageState extends State<AuditPage> {
Workspace.instance.removeListener(_onWorkspaceChanged); Workspace.instance.removeListener(_onWorkspaceChanged);
_poller?.cancel(); _poller?.cancel();
_nudgeDebounce?.cancel(); _nudgeDebounce?.cancel();
_reconnect?.cancel();
_eventSub?.cancel(); _eventSub?.cancel();
super.dispose(); super.dispose();
} }
@ -122,7 +124,12 @@ class _AuditPageState extends State<AuditPage> {
onDone: () { onDone: () {
_eventSub = null; _eventSub = null;
if (mounted) { 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(); if (mounted && _eventSub == null) _subscribeLive();
}); });
} }

View file

@ -7,6 +7,7 @@ import '../data/hub.dart';
import '../data/hub_auth_token.dart'; import '../data/hub_auth_token.dart';
import '../data/system_actions.dart'; import '../data/system_actions.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../main.dart' show StudioShellState;
import '../theme/theme.dart'; import '../theme/theme.dart';
import '../theme/tokens.dart'; import '../theme/tokens.dart';
import '../widgets/widgets.dart'; import '../widgets/widgets.dart';
@ -75,23 +76,49 @@ class _DoctorPageState extends State<DoctorPage> {
final showUpdate = final showUpdate =
s.update.updateAvailable || s.update.updateAvailable ||
(!s.update.manifestReachable && s.update.localVersion.isNotEmpty); (!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( return ListView(
padding: const EdgeInsets.all(ChainSpace.xl), padding: const EdgeInsets.all(ChainSpace.xl),
children: [ children: [
if (showUpdate) _UpdateBanner(status: s.update), if (showUpdate) _UpdateBanner(status: s.update),
if (showUpdate) const SizedBox(height: ChainSpace.lg), if (showUpdate) const SizedBox(height: ChainSpace.lg),
_SummaryStrip(snapshot: s), _SummaryStrip(
snapshot: s,
onOpenStore: openStore,
onOpenApprovals: openApprovals,
onOpenAudit: openAudit,
),
const SizedBox(height: ChainSpace.xl), const SizedBox(height: ChainSpace.xl),
_Section( _Section(
title: AppLocalizations.of(context)!.doctorEventLogSection, title: AppLocalizations.of(context)!.doctorEventLogSection,
child: _EventLogPanel(snapshot: s, onRefresh: _refresh), child: _EventLogPanel(
snapshot: s,
onRefresh: _refresh,
onOpenAudit: openAudit,
),
), ),
const SizedBox(height: ChainSpace.lg), const SizedBox(height: ChainSpace.lg),
_Section( _Section(
title: AppLocalizations.of( title: AppLocalizations.of(
context, context,
)!.doctorModulesApprovalsSection, )!.doctorModulesApprovalsSection,
child: _ModulesPanel(snapshot: s), child: DoctorModulesPanel(
snapshot: s,
onOpenStore: openStore,
onOpenApprovals: openApprovals,
),
), ),
const SizedBox(height: ChainSpace.lg), const SizedBox(height: ChainSpace.lg),
_Section( _Section(
@ -150,22 +177,32 @@ class _Section extends StatelessWidget {
class _SummaryStrip extends StatelessWidget { class _SummaryStrip extends StatelessWidget {
final DoctorSnapshot snapshot; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
return Row( return Row(
children: [ children: [
_StatTile( DoctorStatTile(
label: l.doctorSummaryModules, label: l.doctorSummaryModules,
value: snapshot.moduleCount.toString(), value: snapshot.moduleCount.toString(),
subtitle: l.doctorSummaryCapabilities(snapshot.capabilityCount), subtitle: l.doctorSummaryCapabilities(snapshot.capabilityCount),
icon: Icons.extension_outlined, icon: Icons.extension_outlined,
onTap: onOpenStore,
linkLabel: l.doctorLinkStore,
), ),
const SizedBox(width: ChainSpace.md), const SizedBox(width: ChainSpace.md),
_StatTile( DoctorStatTile(
label: l.doctorSummaryApprovals, label: l.doctorSummaryApprovals,
value: snapshot.pendingApprovals.toString(), value: snapshot.pendingApprovals.toString(),
subtitle: l.doctorSummaryPending, subtitle: l.doctorSummaryPending,
@ -173,9 +210,11 @@ class _SummaryStrip extends StatelessWidget {
tone: snapshot.pendingApprovals > 0 tone: snapshot.pendingApprovals > 0
? ChainPillTone.warning ? ChainPillTone.warning
: ChainPillTone.neutral, : ChainPillTone.neutral,
onTap: onOpenApprovals,
linkLabel: l.doctorLinkApprovals,
), ),
const SizedBox(width: ChainSpace.md), const SizedBox(width: ChainSpace.md),
_StatTile( DoctorStatTile(
label: l.doctorSummaryAudit, label: l.doctorSummaryAudit,
value: snapshot.chainHealthy ? '' : '', value: snapshot.chainHealthy ? '' : '',
subtitle: l.doctorSummaryChain( subtitle: l.doctorSummaryChain(
@ -186,9 +225,11 @@ class _SummaryStrip extends StatelessWidget {
tone: snapshot.chainHealthy tone: snapshot.chainHealthy
? ChainPillTone.success ? ChainPillTone.success
: ChainPillTone.danger, : ChainPillTone.danger,
onTap: onOpenAudit,
linkLabel: l.doctorLinkAudit,
), ),
const SizedBox(width: ChainSpace.md), const SizedBox(width: ChainSpace.md),
_StatTile( DoctorStatTile(
label: l.doctorSummaryServices, label: l.doctorSummaryServices,
value: snapshot.services.length.toString(), value: snapshot.services.length.toString(),
subtitle: l.doctorSummaryDeclared, 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 label;
final String value; final String value;
final String subtitle; final String subtitle;
final IconData icon; final IconData icon;
final ChainPillTone tone; 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.label,
required this.value, required this.value,
required this.subtitle, required this.subtitle,
required this.icon, required this.icon,
this.tone = ChainPillTone.neutral, this.tone = ChainPillTone.neutral,
this.onTap,
this.linkLabel,
}); });
@override @override
@ -223,8 +277,7 @@ class _StatTile extends StatelessWidget {
ChainPillTone.danger => theme.colorScheme.error, ChainPillTone.danger => theme.colorScheme.error,
_ => theme.colorScheme.primary, _ => theme.colorScheme.primary,
}; };
return Expanded( Widget card = ChainCard(
child: ChainCard(
accentLeft: accentColor, accentLeft: accentColor,
padding: const EdgeInsets.all(ChainSpace.lg), padding: const EdgeInsets.all(ChainSpace.lg),
child: Column( child: Column(
@ -245,6 +298,14 @@ class _StatTile extends StatelessWidget {
), ),
), ),
), ),
if (onTap != null) ...[
const Spacer(),
Icon(
Icons.chevron_right,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
),
],
], ],
), ),
const SizedBox(height: ChainSpace.sm), const SizedBox(height: ChainSpace.sm),
@ -264,9 +325,25 @@ class _StatTile extends StatelessWidget {
), ),
], ],
), ),
);
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);
}
} }
class _EventLogPanel extends StatelessWidget { class _EventLogPanel extends StatelessWidget {
@ -277,24 +354,22 @@ class _EventLogPanel extends StatelessWidget {
/// after import / restore without restarting the daemon. /// after import / restore without restarting the daemon.
final VoidCallback onRefresh; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final healthy = snapshot.chainHealthy; final healthy = snapshot.chainHealthy;
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
return ChainCard( Widget headline = Column(
child: Row(
children: [
Icon(
healthy ? Icons.verified_outlined : Icons.gpp_bad_outlined,
size: 24,
color: healthy ? ChainColors.success : theme.colorScheme.error,
),
const SizedBox(width: ChainSpace.lg),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
@ -317,8 +392,32 @@ class _EventLogPanel extends StatelessWidget {
), ),
), ),
], ],
);
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: [
Icon(
healthy ? Icons.verified_outlined : Icons.gpp_bad_outlined,
size: 24,
color: healthy ? ChainColors.success : theme.colorScheme.error,
),
const SizedBox(width: ChainSpace.lg),
Expanded(child: headline),
OutlinedButton.icon( OutlinedButton.icon(
onPressed: onRefresh, onPressed: onRefresh,
icon: const Icon(Icons.fact_check_outlined, size: 16), 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 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -847,7 +979,11 @@ class _ModulesPanel extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( _linkRow(
context,
onTap: onOpenStore,
linkLabel: l.doctorLinkStore,
child: Row(
children: [ children: [
Icon( Icon(
Icons.extension_outlined, Icons.extension_outlined,
@ -886,10 +1022,23 @@ class _ModulesPanel extends StatelessWidget {
? ChainPillTone.success ? ChainPillTone.success
: ChainPillTone.neutral, : 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), const Divider(height: ChainSpace.xl),
Row( _linkRow(
context,
onTap: onOpenApprovals,
linkLabel: l.doctorLinkApprovals,
child: Row(
children: [ children: [
Icon( Icon(
Icons.inbox_outlined, Icons.inbox_outlined,
@ -909,7 +1058,16 @@ class _ModulesPanel extends StatelessWidget {
label: l.doctorApprovalsAttentionPill, label: l.doctorApprovalsAttentionPill,
tone: ChainPillTone.warning, 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, 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) ...[ if (available && status.releaseNotesUrl != null) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
Text( // 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!), l.doctorReleaseNotes(status.releaseNotesUrl!),
style: ChainTheme.mono( style: ChainTheme.mono(
size: 11, size: 11,
color: theme.colorScheme.primary, color: theme.colorScheme.primary,
).copyWith(
decoration: TextDecoration.underline,
decorationColor: theme.colorScheme.primary,
),
),
),
),
), ),
), ),
], ],

View file

@ -3,9 +3,11 @@ import 'dart:async';
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';
import '../main.dart' show StudioShellState;
import '../theme/tokens.dart'; import '../theme/tokens.dart';
import '../widgets/widgets.dart'; import '../widgets/widgets.dart';
import 'welcome.dart' show showFaiDoc; import 'welcome.dart' show showFaiDoc;
@ -22,10 +24,138 @@ class RunsPage extends StatefulWidget {
State<RunsPage> createState() => _RunsPageState(); State<RunsPage> 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<RunsPage> { class _RunsPageState extends State<RunsPage> {
List<DetachedRun> _runs = const []; List<DetachedRun> _runs = const [];
bool _detachedEnabled = false; bool _detachedEnabled = false;
String? _error; Object? _error;
RunsLoadIssue _issue = RunsLoadIssue.other;
bool _loaded = false; bool _loaded = false;
Timer? _poll; Timer? _poll;
final Set<String> _cancelling = <String>{}; final Set<String> _cancelling = <String>{};
@ -63,7 +193,8 @@ class _RunsPageState extends State<RunsPage> {
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_error = e.toString(); _error = e;
_issue = classifyRunsLoadError(e);
_loaded = true; _loaded = true;
}); });
} }
@ -118,11 +249,12 @@ class _RunsPageState extends State<RunsPage> {
body: !_loaded body: !_loaded
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: _error != null && _runs.isEmpty : _error != null && _runs.isEmpty
? ChainEmptyState( ? RunsLoadErrorView(
icon: Icons.cloud_off_outlined, error: _error!,
iconColor: theme.colorScheme.error, issue: _issue,
title: l.hubUnreachable, onOpenGuide: () => showFaiDoc(context, 'runs'),
hint: l.hubUnreachableHint, onOpenDoctor: () =>
StudioShellState.of(context)?.navigateTo('doctor'),
) )
: _runs.isEmpty : _runs.isEmpty
? ChainEmptyState( ? ChainEmptyState(

View file

@ -1,7 +1,7 @@
name: chain_studio name: chain_studio
description: "Ch∆In Studio — desktop GUI for the Ch∆In hub" description: "Ch∆In Studio — desktop GUI for the Ch∆In hub"
publish_to: 'none' publish_to: 'none'
version: 0.73.0 version: 0.74.0
environment: environment:
sdk: ^3.11.0-200.1.beta sdk: ^3.11.0-200.1.beta

View file

@ -88,6 +88,19 @@ 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,

View file

@ -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);
},
);
}

View file

@ -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);
});
});
}