Approvals: the pending card now shows its full origin — flow, step, run id (previously dropped at the Dart mapping layer), project, and requested-at — under an ORIGIN heading, led by a one-line intro strip that says what the inbox is and what Approve/Reject do. Approve/Reject buttons carry tooltips; the history dialog gains project + run id. Fixes the approvals doc drift (title/details/reviewer -> prompt/show/timeout_seconds). Guard: approvals_origin_test renders the card via the hermetic fake hub and pins every origin fact. Runs: the "hub too old" state now leads with an in-place update button (same `chain update apply` path as the Diagnose page), the Diagnose deeplink demoted to secondary, with a CLI-absent fallback. Guard: two new RunsLoadErrorView widget tests. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
321 lines
9.2 KiB
Dart
321 lines
9.2 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 []);
|
|
|
|
@override
|
|
Future<List<ProjectRef>> listProjects() =>
|
|
_async('listProjects', () => const []);
|
|
|
|
@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 []);
|
|
|
|
@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;
|
|
}
|