chain-studio/test/support/fake_hub.dart
flemming-it 64c2a77dc9 feat(workspace): one global switcher anchor in the shell sidebar
The switcher used to be embedded per page (Flows/Runs/Audit/
Approvals) — invisible on the other five pages and sitting in a
different corner depending on the page (persona review 2026-08-27,
consensus finding). It now lives ONCE in the sidebar, above the
destinations: active project/area always visible, opens the same
menu everywhere, Cmd+P from anywhere. The shell listens to the
workspace, so the sidebar endpoint label can no longer lag a
sealed switch until the next health tick.

Also in this rebuild:

* Stopped sealed areas ask before starting ("Start area X?") —
  a context switch must never boot a hub daemon as a click
  side-effect; running areas keep switching with one click.
* The switcher tooltip told a wrong scope ("filters this view") —
  it now says the choice applies everywhere and stamps new runs.
* The aggregated sealed row explains itself in place (names can
  reveal client identities) and links to the Settings toggle
  (Settings dialog gained an initialCategory jump).
* The active entry carries a checkmark in the menu.
* The Cmd+K palette knows projects and areas, ranked by recent
  use; sealed names honour the privacy setting — while hidden,
  the palette offers the guarded picker instead of the names.
* The runs empty state names the active project filter as the
  cause ("No runs in project X" + show-all action) instead of
  claiming the feature is off.

Tests updated to the anchor and made hermetic (scriptable
projects on the fake hub, sealed-area fake); new coverage for the
checkmark, the why-line, and the start confirmation.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-08-28 00:00:06 +02:00

375 lines
11 KiB
Dart

// 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<String, Object> failures = {};
/// Members hit without an implementation (see header).
final Set<String> 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<ApprovalRecord> approvals = const [];
void failWith(Object error, {Set<String>? only}) {
if (only == null) {
failAll = error;
} else {
for (final m in only) {
failures[m] = error;
}
}
}
T _guard<T>(String method, T Function() value) {
final err = failures[method] ?? failAll;
if (err != null) throw err;
return value();
}
Future<T> _async<T>(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<void> loadPersistedEndpoint() async {}
@override
Future<void> reconnect(
HubEndpoint endpoint, {
Object? authToken = const Object(),
bool persist = true,
}) async {}
@override
Future<void> reloadAuthToken() async {}
@override
Future<bool> reloadAuthTokenIfChanged() async => false;
@override
void debugResetChannel() {}
@override
Future<ThemeModeValue> loadThemeMode() async => ThemeModeValue.system;
@override
Future<void> saveThemeMode(ThemeModeValue mode) async {}
@override
Future<Locale> loadLocale() async => const Locale('de');
@override
Future<void> saveLocale(Locale locale) async {}
@override
String? get connectedChannelName => 'local';
// ── Health ───────────────────────────────────────────────────
@override
Future<bool> healthy() =>
_async('healthy', () => probeServing && failAll == null);
@override
Future<HubProbeResult> probeHealth() async {
if (failAll != null) return HubProbeResult.unreachable;
return probeServing ? HubProbeResult.serving : HubProbeResult.notServing;
}
// ── Read paths the pages hit (healthy-empty defaults) ────────
@override
Future<String> hubVersion() => _async('hubVersion', () => '0.0.0-fake');
@override
Future<List<ModuleSummary>> listModules() =>
_async('listModules', () => const []);
@override
Future<List<CapabilityInfo>> allCapabilities() =>
_async('allCapabilities', () => const []);
@override
Future<List<StoreSource>> listStores() =>
_async('listStores', () => const []);
@override
Future<List<StoreItem>> searchStore({
String query = '',
String category = '',
String tag = '',
String status = '',
int limit = 50,
}) => _async('searchStore', () => const []);
@override
Future<List<String>> installedVersions(String moduleName) =>
_async('installedVersions', () => const []);
@override
Future<List<SavedFlow>> listFlows() => _async('listFlows', () => const []);
/// Registry projects [listProjects] answers with — scriptable so
/// workspace suites can keep their seed across the switcher's
/// open-menu refresh.
List<ProjectRef> projects = const [];
@override
Future<List<ProjectRef>> listProjects() =>
_async('listProjects', () => projects);
@override
Stream<AuditEvent> streamEvents({
int backfill = 0,
List<String> types = const [],
String project = '',
}) {
final err = failures['streamEvents'] ?? failAll;
if (err != null) return Stream.error(err);
return const Stream.empty();
}
@override
Future<List<AuditEvent>> recentEvents({
int limit = 50,
List<String> types = const [],
String project = '',
}) => _async('recentEvents', () => const []);
@override
Future<List<PendingApproval>> pendingApprovals({String project = ''}) =>
_async('pendingApprovals', () => const []);
@override
Future<List<ApprovalRecord>> listApprovalsRecords({
List<String> statuses = const [],
int limit = 200,
String project = '',
}) => _async(
'listApprovalsRecords',
() => statuses.isEmpty
? approvals
: approvals.where((a) => statuses.contains(a.status)).toList(),
);
@override
Future<({List<DetachedRun> runs, bool enabled})> listDetachedRuns({
String project = '',
}) => _async('listDetachedRuns', () => (runs: const <DetachedRun>[], enabled: detachedEnabled));
@override
Future<List<Satellite>> listSatellites() =>
_async('listSatellites', () => const []);
/// Auth policy the pages read (approvals derives from it what a
/// reviewer attribution is worth). Default: the local-dev hub —
/// static validator, anonymous calls allowed. Set to `null` to
/// script a hub whose policy Studio may not read; `authStatus`
/// then fails like a PermissionDenied.
HubAuthPolicy? authPolicy = const HubAuthPolicy(
validator: 'static',
anonymousAllowed: true,
tokens: [],
);
@override
Future<HubAuthPolicy> authStatus() => _async('authStatus', () {
final policy = authPolicy;
if (policy == null) {
throw const FakeGrpcError(7, 'admin scope required');
}
return policy;
});
/// Every decision that reached the wire, in order — the guard for
/// "Studio never claims an identity it cannot prove" inspects the
/// reviewer string exactly as the hub would store it.
final List<({String id, String reviewer, String? reason})> decisions = [];
/// Reviewer + reason handed to the audit-log wipe, same purpose.
final List<({String reviewer, String reason})> clears = [];
@override
Future<({int purged, String channel})> clearEventLog({
required String reviewer,
required String reason,
}) => _async('clearEventLog', () {
clears.add((reviewer: reviewer, reason: reason));
return (purged: 0, channel: 'local');
});
@override
Future<void> approve(String id, String reviewer) => _async(
'approve',
() => decisions.add((id: id, reviewer: reviewer, reason: null)),
);
@override
Future<void> reject(String id, String reviewer, String reason) => _async(
'reject',
() => decisions.add((id: id, reviewer: reviewer, reason: reason)),
);
@override
Future<SystemAiStatus> systemAiStatus() => _async(
'systemAiStatus',
() => const SystemAiStatus(
enabled: false,
provider: '',
endpoint: '',
model: '',
privacyMode: 'off',
apiKeyEnv: '',
),
);
@override
Future<ChannelStatusSnapshot> channelStatus() => _async(
'channelStatus',
() => const ChannelStatusSnapshot(
active: 'local',
channels: [
ChannelInfo(
name: 'local',
port: 65535,
running: true,
endpoint: 'http://127.0.0.1:65535',
),
],
),
);
@override
Future<List<McpClientInfo>> listMcpClients() =>
_async('listMcpClients', () => const []);
@override
Future<List<N8nEndpointInfo>> listN8nEndpoints() =>
_async('listN8nEndpoints', () => const []);
/// Scriptable update hint for the shell banner test.
UpdateStatus? updateHint;
@override
Future<UpdateStatus?> checkHubUpdate() async => updateHint;
@override
Future<bool> declareService({
required String name,
required String endpoint,
String healthPath = '',
List<String> tags = const [],
}) => _async('declareService', () => true);
@override
Future<DoctorSnapshot> 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;
}