// Scriptable HubService fake — the hermeticity backbone of the // widget suites. Injected via HubService.debugSetInstance so the // pages under test never open a real gRPC channel: test results // must not depend on whether a hub happens to listen on the // operator's machine (the a11y suite's old flake came exactly // from that coupling). // // Behaviour model: // * Default: a healthy, empty hub — every read succeeds with // zero modules / flows / events / approvals / runs. // * `failWith(error, only: {...})` scripts per-RPC failures — // e.g. UNIMPLEMENTED for one method to simulate an older hub, // or `failAll` for a hub that is gone entirely. // * `FakeGrpcError` mirrors package:grpc's GrpcError shape // (`.code` / `.message`) because production code duck-types // on those fields (friendly_error.dart, runs classification). // // Any HubService member the fake does not implement lands in // noSuchMethod, which records the name in [missing] and throws — // so a sweep test can fail with the exact list of members that // still need a default instead of silently rendering error states. import 'dart:ui'; import 'package:flutter_test/flutter_test.dart'; import 'package:chain_client_sdk/chain_client_sdk.dart' show HubEndpoint, HubProbeResult, StoreSource; import 'package:chain_studio/data/hub.dart'; /// Duck-typed stand-in for `GrpcError`: production code reads /// `.code` (int) and `.message` (String?) off thrown objects. class FakeGrpcError implements Exception { final int code; final String? message; const FakeGrpcError(this.code, [this.message]); @override String toString() => 'gRPC Error (code: $code, message: $message)'; } /// Shorthands for the codes the scenarios use. const kUnimplemented = FakeGrpcError(12, 'method not implemented'); const kUnavailable = FakeGrpcError(14, 'connection refused'); const kDetachedOff = FakeGrpcError( 9, 'detached invocations are not enabled — set detached.enabled: true ' 'in the operator config', ); class FakeHubService extends Fake implements HubService { /// Error thrown by every RPC-backed method when set. Object? failAll; /// Per-method errors, keyed by method name (e.g. /// 'listDetachedRuns'). Wins over [failAll] absence; [failAll] /// wins overall when both are set? No — per-method is checked /// first so a scenario can single out one RPC. final Map failures = {}; /// Members hit without an implementation (see header). final Set missing = {}; /// Scriptable bits of otherwise-default answers. bool detachedEnabled = false; bool probeServing = true; /// Approvals returned by [listApprovalsRecords], filtered by the /// requested statuses so the pending / history tabs each see the /// right slice. Empty by default (healthy-empty inbox). List approvals = const []; void failWith(Object error, {Set? only}) { if (only == null) { failAll = error; } else { for (final m in only) { failures[m] = error; } } } T _guard(String method, T Function() value) { final err = failures[method] ?? failAll; if (err != null) throw err; return value(); } Future _async(String method, T Function() value) async => _guard(method, value); @override dynamic noSuchMethod(Invocation invocation) { missing.add(invocation.memberName.toString()); return super.noSuchMethod(invocation); } // ── Connection / endpoint plumbing (never fails) ───────────── @override String get endpointLabel => 'http://127.0.0.1:65535'; @override HubEndpoint get currentEndpoint => HubEndpoint(host: '127.0.0.1', port: 65535, secure: false); @override Future loadPersistedEndpoint() async {} @override Future reconnect( HubEndpoint endpoint, { Object? authToken = const Object(), bool persist = true, }) async {} @override Future reloadAuthToken() async {} @override Future reloadAuthTokenIfChanged() async => false; @override void debugResetChannel() {} @override Future loadThemeMode() async => ThemeModeValue.system; @override Future saveThemeMode(ThemeModeValue mode) async {} @override Future loadLocale() async => const Locale('de'); @override Future saveLocale(Locale locale) async {} @override String? get connectedChannelName => 'local'; // ── Health ─────────────────────────────────────────────────── @override Future healthy() => _async('healthy', () => probeServing && failAll == null); @override Future probeHealth() async { if (failAll != null) return HubProbeResult.unreachable; return probeServing ? HubProbeResult.serving : HubProbeResult.notServing; } // ── Read paths the pages hit (healthy-empty defaults) ──────── @override Future hubVersion() => _async('hubVersion', () => '0.0.0-fake'); @override Future> listModules() => _async('listModules', () => const []); @override Future> allCapabilities() => _async('allCapabilities', () => const []); @override Future> listStores() => _async('listStores', () => const []); @override Future> searchStore({ String query = '', String category = '', String tag = '', String status = '', int limit = 50, }) => _async('searchStore', () => const []); @override Future> installedVersions(String moduleName) => _async('installedVersions', () => const []); @override Future> listFlows() => _async('listFlows', () => const []); @override Future> listProjects() => _async('listProjects', () => const []); @override Stream streamEvents({ int backfill = 0, List types = const [], String project = '', }) { final err = failures['streamEvents'] ?? failAll; if (err != null) return Stream.error(err); return const Stream.empty(); } @override Future> recentEvents({ int limit = 50, List types = const [], String project = '', }) => _async('recentEvents', () => const []); @override Future> pendingApprovals({String project = ''}) => _async('pendingApprovals', () => const []); @override Future> listApprovalsRecords({ List statuses = const [], int limit = 200, String project = '', }) => _async( 'listApprovalsRecords', () => statuses.isEmpty ? approvals : approvals.where((a) => statuses.contains(a.status)).toList(), ); @override Future<({List runs, bool enabled})> listDetachedRuns({ String project = '', }) => _async('listDetachedRuns', () => (runs: const [], enabled: detachedEnabled)); @override Future> listSatellites() => _async('listSatellites', () => const []); @override Future systemAiStatus() => _async( 'systemAiStatus', () => const SystemAiStatus( enabled: false, provider: '', endpoint: '', model: '', privacyMode: 'off', apiKeyEnv: '', ), ); @override Future channelStatus() => _async( 'channelStatus', () => const ChannelStatusSnapshot( active: 'local', channels: [ ChannelInfo( name: 'local', port: 65535, running: true, endpoint: 'http://127.0.0.1:65535', ), ], ), ); @override Future> listMcpClients() => _async('listMcpClients', () => const []); @override Future> listN8nEndpoints() => _async('listN8nEndpoints', () => const []); /// Scriptable update hint for the shell banner test. UpdateStatus? updateHint; @override Future checkHubUpdate() async => updateHint; @override Future declareService({ required String name, required String endpoint, String healthPath = '', List tags = const [], }) => _async('declareService', () => true); @override Future doctor() => _async( 'doctor', () => const DoctorSnapshot( moduleCount: 0, capabilityCount: 0, pendingApprovals: 0, eventChainTotal: 0, eventChainVerified: 0, eventChainTamperedAt: null, services: [], update: UpdateStatus( channel: 'stable', localVersion: '0.0.0-fake', latestVersion: '0.0.0-fake', updateAvailable: false, manifestReachable: true, ), paths: DaemonPathsSnapshot( logPath: '', dbPath: '', modulesDir: '', flowsDir: '', configPath: '', pidPath: '', ), ), ); } /// Install a [FakeHubService] for the duration of the current /// test and restore a real instance afterwards. FakeHubService installFakeHub() { final fake = FakeHubService(); HubService.debugSetInstance(fake); addTearDown(() => HubService.debugSetInstance(null)); return fake; }