feat: live gRPC pages — drop mock data, talk to the real hub

Studio's three MVP pages now pull live data from a running hub
via fai_dart_sdk. Mock fixture removed.

- data/hub.dart: HubService singleton wraps fai_dart_sdk's
  HubClient and exposes UI-friendly types (ModuleSummary,
  AuditEvent, PendingApproval) so pages don't import protobuf.
- pages/modules.dart: FutureBuilder against listModules,
  groups CapabilityEntry rows by module, retry-on-error UI.
- pages/audit.dart: 2s polling Timer, status bar shows
  "live (polling 2s)" or "disconnected — <error>".
- pages/approvals.dart: live listApprovals, Approve sends
  DecideApproval(APPROVE), Reject opens a reason dialog and
  sends DecideApproval(REJECT). Both refresh after success.
- Connection-error states across all pages: cloud_off icon +
  hint to start `fai serve` + Retry button.

When no hub is running the pages render their disconnected
state instead of crashing. When a hub is running, the data is
real — no more mock fixture.

Bumps fai_studio 0.1.0 -> 0.2.0.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-05 16:27:54 +02:00
parent 7c11598846
commit 127c497f73
6 changed files with 602 additions and 354 deletions

156
lib/data/hub.dart Normal file
View file

@ -0,0 +1,156 @@
// HubService singleton wrapper around fai_dart_sdk's HubClient.
//
// Centralises the gRPC connection so each page just imports
// `HubService.instance` instead of constructing its own client.
// Methods return UI-friendly types so pages stay free of
// protobuf imports.
import 'package:fai_dart_sdk/fai_dart_sdk.dart';
class HubService {
HubService._();
static final HubService instance = HubService._();
HubClient _client = HubClient();
/// The endpoint string ("http://host:port") the connection
/// targets. Shown in the navigation rail.
String get endpointLabel => _client.endpoint.toString();
/// Reconnect to a new endpoint. Useful if the user changes
/// the hub URL at runtime (Phase 1+).
Future<void> reconnect(HubEndpoint endpoint) async {
await _client.close();
_client = HubClient(endpoint: endpoint);
}
Future<bool> healthy() => _client.healthy();
Future<List<ModuleSummary>> listModules() async {
final caps = await _client.listCapabilities();
// Group capabilities by module so Studio's UI maps a card
// to a module rather than a capability.
final byModule = <String, List<CapabilityEntry>>{};
for (final c in caps) {
byModule.putIfAbsent(c.moduleName, () => []).add(c);
}
return byModule.entries.map((e) {
final caps = e.value;
return ModuleSummary(
name: e.key,
version: caps.first.moduleVersion,
capabilities: caps
.map((c) => '${c.capability}@${c.version}')
.toList(),
);
}).toList()
..sort((a, b) => a.name.compareTo(b.name));
}
Future<List<AuditEvent>> recentEvents({
int limit = 50,
List<String> types = const [],
}) async {
final events = await _client.eventLog(limit: limit, types: types);
return events
.map(
(e) => AuditEvent(
eventId: e.eventId,
timestamp: DateTime.tryParse(e.timestamp) ?? DateTime.now(),
type: e.eventType,
flowName: e.flowName.isEmpty ? null : e.flowName,
stepId: e.stepId.isEmpty ? null : e.stepId,
moduleName: e.moduleName.isEmpty ? null : e.moduleName,
durationMs: e.durationMs == 0 ? null : e.durationMs.toInt(),
error: e.error.isEmpty ? null : e.error,
),
)
.toList();
}
Future<List<PendingApproval>> pendingApprovals() async {
final entries = await _client.listApprovals(statuses: ['pending']);
return entries
.map(
(e) => PendingApproval(
id: e.approvalId,
flowName: e.flowName,
stepId: e.stepId,
prompt: e.prompt,
showPreview: e.payloadPreview.isEmpty ? null : e.payloadPreview,
createdAt: DateTime.tryParse(e.createdAt) ?? DateTime.now(),
expiresAt: e.expiresAt.isEmpty
? null
: DateTime.tryParse(e.expiresAt),
),
)
.toList();
}
Future<void> approve(String id, String reviewer) =>
_client.approve(approvalId: id, reviewer: reviewer);
Future<void> reject(String id, String reviewer, String reason) =>
_client.reject(
approvalId: id,
reviewer: reviewer,
reason: reason,
);
}
/// UI-side type, decoupled from the proto wire type so pages
/// don't import protobuf packages.
class ModuleSummary {
final String name;
final String version;
final List<String> capabilities;
const ModuleSummary({
required this.name,
required this.version,
required this.capabilities,
});
}
class AuditEvent {
final String eventId;
final DateTime timestamp;
final String type;
final String? flowName;
final String? stepId;
final String? moduleName;
final int? durationMs;
final String? error;
const AuditEvent({
required this.eventId,
required this.timestamp,
required this.type,
this.flowName,
this.stepId,
this.moduleName,
this.durationMs,
this.error,
});
}
class PendingApproval {
final String id;
final String flowName;
final String stepId;
final String prompt;
final String? showPreview;
final DateTime createdAt;
final DateTime? expiresAt;
const PendingApproval({
required this.id,
required this.flowName,
required this.stepId,
required this.prompt,
this.showPreview,
required this.createdAt,
this.expiresAt,
});
}

View file

@ -1,165 +0,0 @@
// Mock data for the MVP. Replaced by live gRPC calls via
// fai_dart_sdk in the next iteration.
class ModuleSummary {
final String name;
final String version;
final List<String> capabilities;
final List<String> permissions;
final List<String> requiresServices;
const ModuleSummary({
required this.name,
required this.version,
required this.capabilities,
required this.permissions,
required this.requiresServices,
});
}
class AuditEvent {
final String eventId;
final DateTime timestamp;
final String type;
final String? flowName;
final String? stepId;
final String? moduleName;
final int? durationMs;
final String? error;
const AuditEvent({
required this.eventId,
required this.timestamp,
required this.type,
this.flowName,
this.stepId,
this.moduleName,
this.durationMs,
this.error,
});
}
class PendingApproval {
final String id;
final String flowName;
final String stepId;
final String prompt;
final String? showPreview;
final DateTime createdAt;
final DateTime? expiresAt;
const PendingApproval({
required this.id,
required this.flowName,
required this.stepId,
required this.prompt,
this.showPreview,
required this.createdAt,
this.expiresAt,
});
}
const mockModules = <ModuleSummary>[
ModuleSummary(
name: 'echo',
version: '0.1.0',
capabilities: ['debug.echo@0.1.0'],
permissions: [],
requiresServices: [],
),
ModuleSummary(
name: 'text-extract',
version: '0.1.1',
capabilities: ['text.extract@0.1.0'],
permissions: [],
requiresServices: [],
),
ModuleSummary(
name: 'llm-chat',
version: '0.1.0',
capabilities: ['llm.chat@0.1.0'],
permissions: [
'net: localhost',
'net: 127.0.0.1',
'net: api.openai.com',
'net: api.anthropic.com',
],
requiresServices: ['ollama'],
),
ModuleSummary(
name: 'text-translate',
version: '0.1.0',
capabilities: ['text.translate@0.1.0'],
permissions: [
'net: localhost',
'net: 127.0.0.1',
],
requiresServices: ['ollama'],
),
ModuleSummary(
name: 'text-summarize',
version: '0.1.0',
capabilities: ['text.summarize@0.1.0'],
permissions: [
'net: localhost',
'net: 127.0.0.1',
],
requiresServices: ['ollama'],
),
];
final mockEvents = <AuditEvent>[
AuditEvent(
eventId: '01926f8a-7c34-7b2c-9e4f-a8d3c1f9e2b4',
timestamp: DateTime.now().subtract(const Duration(seconds: 2)),
type: 'flow.completed',
flowName: 'extract-translate-summarize',
durationMs: 8421,
),
AuditEvent(
eventId: '01926f8a-7b00-7000-aaaa-bbbbcccc0042',
timestamp: DateTime.now().subtract(const Duration(seconds: 3)),
type: 'step.completed',
flowName: 'extract-translate-summarize',
stepId: 'summarize',
moduleName: 'text-summarize',
durationMs: 3204,
),
AuditEvent(
eventId: '01926f8a-7a90-7000-aaaa-bbbbcccc0041',
timestamp: DateTime.now().subtract(const Duration(seconds: 9)),
type: 'step.completed',
flowName: 'extract-translate-summarize',
stepId: 'review',
moduleName: 'system.approval',
durationMs: 5102,
),
AuditEvent(
eventId: '01926f8a-7a00-7000-aaaa-bbbbcccc0040',
timestamp: DateTime.now().subtract(const Duration(seconds: 14)),
type: 'step.completed',
flowName: 'extract-translate-summarize',
stepId: 'translate',
moduleName: 'text-translate',
durationMs: 4815,
),
AuditEvent(
eventId: '01926f8a-7800-7000-aaaa-bbbbcccc003f',
timestamp: DateTime.now().subtract(const Duration(seconds: 19)),
type: 'flow.started',
flowName: 'extract-translate-summarize',
),
];
final mockApprovals = <PendingApproval>[
PendingApproval(
id: '01926f8a-9000-7b2c-9e4f-aaaa00000001',
flowName: 'extract-translate-summarize',
stepId: 'review',
prompt: 'Review the German→English translation before summarisation.',
showPreview:
'{ "translated": "The quarterly report shows..." }',
createdAt: DateTime.now().subtract(const Duration(minutes: 2)),
expiresAt: DateTime.now().add(const Duration(minutes: 28)),
),
];

View file

@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import '../data/mock.dart';
import '../data/hub.dart';
class ApprovalsPage extends StatefulWidget {
const ApprovalsPage({super.key});
@ -10,20 +10,121 @@ class ApprovalsPage extends StatefulWidget {
}
class _ApprovalsPageState extends State<ApprovalsPage> {
final _decided = <String>{};
late Future<List<PendingApproval>> _future;
// Hard-coded reviewer for the MVP; Phase 1+ wires this to an
// authenticated session.
final String _reviewer = 'studio-mvp';
@override
void initState() {
super.initState();
_future = HubService.instance.pendingApprovals();
}
void _refresh() => setState(() {
_future = HubService.instance.pendingApprovals();
});
Future<void> _approve(PendingApproval a) async {
try {
await HubService.instance.approve(a.id, _reviewer);
_refresh();
} catch (e) {
_showError('approve failed: $e');
}
}
Future<void> _reject(PendingApproval a) async {
final reason = await _promptReason(context);
if (reason == null || reason.isEmpty) return;
try {
await HubService.instance.reject(a.id, _reviewer, reason);
_refresh();
} catch (e) {
_showError('reject failed: $e');
}
}
void _showError(String msg) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
}
Future<String?> _promptReason(BuildContext context) async {
final controller = TextEditingController();
return showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Reject approval'),
content: TextField(
controller: controller,
autofocus: true,
decoration: const InputDecoration(
labelText: 'Reason (recorded in audit log)',
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, null),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, controller.text),
child: const Text('Reject'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final pending =
mockApprovals.where((a) => !_decided.contains(a.id)).toList();
return Scaffold(
appBar: AppBar(
title: const Text('Pending Approvals'),
centerTitle: false,
actions: [
IconButton(
icon: const Icon(Icons.refresh),
tooltip: 'Reload',
onPressed: _refresh,
),
body: pending.isEmpty
? Center(
],
),
body: FutureBuilder<List<PendingApproval>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.cloud_off_outlined,
size: 48,
color: theme.colorScheme.error,
),
const SizedBox(height: 16),
Text('Hub unreachable: ${snapshot.error}'),
const SizedBox(height: 16),
FilledButton.tonal(
onPressed: _refresh,
child: const Text('Retry'),
),
],
),
),
);
}
final pending = snapshot.data ?? [];
if (pending.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
@ -32,7 +133,8 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
Icon(
Icons.check_circle_outline,
size: 64,
color: theme.colorScheme.primary.withValues(alpha: 0.5),
color: theme.colorScheme.primary
.withValues(alpha: 0.5),
),
const SizedBox(height: 16),
Text(
@ -44,8 +146,9 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
],
),
),
)
: ListView.separated(
);
}
return ListView.separated(
padding: const EdgeInsets.all(24),
itemCount: pending.length,
separatorBuilder: (_, _) => const SizedBox(height: 16),
@ -53,10 +156,10 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
final a = pending[i];
return _ApprovalCard(
approval: a,
onApprove: () =>
setState(() => _decided.add(a.id)),
onReject: () =>
setState(() => _decided.add(a.id)),
onApprove: () => _approve(a),
onReject: () => _reject(a),
);
},
);
},
),

View file

@ -1,6 +1,8 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../data/mock.dart';
import '../data/hub.dart';
class AuditPage extends StatefulWidget {
const AuditPage({super.key});
@ -11,13 +13,57 @@ class AuditPage extends StatefulWidget {
class _AuditPageState extends State<AuditPage> {
String _typeFilter = 'all';
List<AuditEvent> _events = const [];
String? _error;
bool _initialLoaded = false;
Timer? _poller;
@override
void initState() {
super.initState();
_refresh();
_poller = Timer.periodic(
const Duration(seconds: 2),
(_) => _refresh(),
);
}
@override
void dispose() {
_poller?.cancel();
super.dispose();
}
Future<void> _refresh() async {
final types = _typeFilter == 'all'
? <String>[]
: <String>[]; // server-side filter not yet clientside below
try {
final events = await HubService.instance.recentEvents(
limit: 100,
types: types,
);
if (!mounted) return;
setState(() {
_events = events;
_error = null;
_initialLoaded = true;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_initialLoaded = true;
});
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final filtered = _typeFilter == 'all'
? mockEvents
: mockEvents.where((e) => e.type.startsWith(_typeFilter)).toList();
? _events
: _events.where((e) => e.type.startsWith(_typeFilter)).toList();
return Scaffold(
appBar: AppBar(
@ -41,18 +87,41 @@ class _AuditPageState extends State<AuditPage> {
),
body: Column(
children: [
_StatusBar(eventCount: filtered.length),
_StatusBar(eventCount: filtered.length, error: _error),
Expanded(
child: ListView.separated(
child: !_initialLoaded
? const Center(child: CircularProgressIndicator())
: _error != null && _events.isEmpty
? Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Text(
'Hub unreachable: $_error\n\n'
'Start the hub with `fai serve`.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium,
),
),
)
: filtered.isEmpty
? Center(
child: Text(
'(no events yet)',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
)
: ListView.separated(
padding: const EdgeInsets.all(24),
itemCount: filtered.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
separatorBuilder: (_, _) =>
const SizedBox(height: 8),
itemBuilder: (context, i) {
final e = filtered[i];
final ts = e.timestamp;
final tsStr =
'${ts.hour.toString().padLeft(2, '0')}:${ts.minute.toString().padLeft(2, '0')}:${ts.second.toString().padLeft(2, '0')}';
final tone = _toneFor(e.type);
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
@ -72,7 +141,7 @@ class _AuditPageState extends State<AuditPage> {
width: 4,
height: 32,
decoration: BoxDecoration(
color: tone,
color: _toneFor(e.type, theme),
borderRadius: BorderRadius.circular(2),
),
),
@ -81,9 +150,11 @@ class _AuditPageState extends State<AuditPage> {
width: 90,
child: Text(
tsStr,
style: theme.textTheme.bodySmall?.copyWith(
style:
theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
color: theme.colorScheme.onSurfaceVariant,
color: theme
.colorScheme.onSurfaceVariant,
),
),
),
@ -91,7 +162,8 @@ class _AuditPageState extends State<AuditPage> {
width: 180,
child: Text(
e.type,
style: theme.textTheme.bodyMedium?.copyWith(
style:
theme.textTheme.bodyMedium?.copyWith(
fontFamily: 'monospace',
fontWeight: FontWeight.w500,
),
@ -100,17 +172,21 @@ class _AuditPageState extends State<AuditPage> {
Expanded(
child: Text(
_contextLine(e),
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
style:
theme.textTheme.bodyMedium?.copyWith(
color: theme
.colorScheme.onSurfaceVariant,
),
),
),
if (e.durationMs != null)
Text(
'${e.durationMs}ms',
style: theme.textTheme.bodySmall?.copyWith(
style:
theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
color: theme.colorScheme.onSurfaceVariant,
color: theme
.colorScheme.onSurfaceVariant,
),
),
],
@ -134,23 +210,24 @@ class _AuditPageState extends State<AuditPage> {
return parts.join('');
}
Color _toneFor(String type) {
final scheme = Theme.of(context).colorScheme;
if (type.endsWith('.failed')) return scheme.error;
if (type.endsWith('.completed')) return scheme.primary;
if (type.endsWith('.started')) return scheme.tertiary;
return scheme.outline;
Color _toneFor(String type, ThemeData theme) {
if (type.endsWith('.failed')) return theme.colorScheme.error;
if (type.endsWith('.completed')) return theme.colorScheme.primary;
if (type.endsWith('.started')) return theme.colorScheme.tertiary;
return theme.colorScheme.outline;
}
}
class _StatusBar extends StatelessWidget {
final int eventCount;
final String? error;
const _StatusBar({required this.eventCount});
const _StatusBar({required this.eventCount, required this.error});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final live = error == null;
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
@ -160,20 +237,15 @@ class _StatusBar extends StatelessWidget {
Icon(
Icons.fiber_manual_record,
size: 12,
color: theme.colorScheme.tertiary,
color: live ? theme.colorScheme.tertiary : theme.colorScheme.error,
),
const SizedBox(width: 8),
Text(
'live (mock data) — $eventCount events',
live
? 'live (polling 2s) — $eventCount events'
: 'disconnected — $error',
style: theme.textTheme.bodySmall,
),
const Spacer(),
Text(
'hash chain: verified',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.primary,
),
),
],
),
);

View file

@ -1,10 +1,27 @@
import 'package:flutter/material.dart';
import '../data/mock.dart';
import '../data/hub.dart';
class ModulesPage extends StatelessWidget {
class ModulesPage extends StatefulWidget {
const ModulesPage({super.key});
@override
State<ModulesPage> createState() => _ModulesPageState();
}
class _ModulesPageState extends State<ModulesPage> {
late Future<List<ModuleSummary>> _future;
@override
void initState() {
super.initState();
_future = HubService.instance.listModules();
}
void _refresh() => setState(() {
_future = HubService.instance.listModules();
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@ -12,13 +29,48 @@ class ModulesPage extends StatelessWidget {
appBar: AppBar(
title: const Text('Installed Modules'),
centerTitle: false,
actions: [
IconButton(
icon: const Icon(Icons.refresh),
tooltip: 'Reload',
onPressed: _refresh,
),
body: ListView.separated(
],
),
body: FutureBuilder<List<ModuleSummary>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return _ConnectionError(
message: 'Hub unreachable: ${snapshot.error}',
onRetry: _refresh,
);
}
final modules = snapshot.data ?? [];
if (modules.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Text(
'No modules installed.\n'
'Run `fai install <capability-name>` or check ~/.fai/modules/.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
);
}
return ListView.separated(
padding: const EdgeInsets.all(24),
itemCount: mockModules.length,
itemCount: modules.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (context, i) {
final m = mockModules[i];
final m = modules[i];
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
@ -58,33 +110,24 @@ class ModulesPage extends StatelessWidget {
],
),
const SizedBox(height: 8),
_Row(label: 'Capabilities', items: m.capabilities),
if (m.permissions.isNotEmpty) ...[
const SizedBox(height: 6),
_Row(label: 'Permissions', items: m.permissions),
],
if (m.requiresServices.isNotEmpty) ...[
const SizedBox(height: 6),
_Row(
label: 'Requires services',
items: m.requiresServices,
),
],
_ChipRow(label: 'Capabilities', items: m.capabilities),
],
),
),
);
},
);
},
),
);
}
}
class _Row extends StatelessWidget {
class _ChipRow extends StatelessWidget {
final String label;
final List<String> items;
const _Row({required this.label, required this.items});
const _ChipRow({required this.label, required this.items});
@override
Widget build(BuildContext context) {
@ -131,3 +174,48 @@ class _Row extends StatelessWidget {
);
}
}
class _ConnectionError extends StatelessWidget {
final String message;
final VoidCallback onRetry;
const _ConnectionError({required this.message, required this.onRetry});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.cloud_off_outlined,
size: 48,
color: theme.colorScheme.error,
),
const SizedBox(height: 16),
Text(
message,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 8),
Text(
'Start the hub with `fai serve`.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
FilledButton.tonal(
onPressed: onRetry,
child: const Text('Retry'),
),
],
),
),
);
}
}

View file

@ -1,5 +1,10 @@
// Smoke test: app boots, navigation rail shows the three MVP
// pages, and the Modules page renders the mocked module list.
// Smoke test: app boots without crashing and shows the three
// MVP destinations in the navigation rail.
//
// Pages themselves now make live gRPC calls. We pump only one
// frame so they enter the loading state without actually
// awaiting the connection (which would fail without a running
// hub in CI).
import 'package:flutter_test/flutter_test.dart';
import 'package:fai_studio/main.dart';
@ -8,21 +13,10 @@ void main() {
testWidgets('Studio shell shows the three MVP destinations',
(tester) async {
await tester.pumpWidget(const StudioApp());
await tester.pump();
expect(find.text('F∆I Studio'), findsOneWidget);
expect(find.text('Modules'), findsWidgets);
expect(find.text('Audit'), findsOneWidget);
expect(find.text('Approvals'), findsOneWidget);
});
testWidgets('Modules page lists the mocked modules',
(tester) async {
await tester.pumpWidget(const StudioApp());
await tester.pump();
expect(find.text('echo'), findsOneWidget);
expect(find.text('text-extract'), findsOneWidget);
expect(find.text('llm-chat'), findsOneWidget);
});
}