Some checks are pending
Security / Security check (push) Waiting to run
Even with the honest install badge the hub error stays reachable (stale store snapshot, race with a store refresh, older hub). The friendly-error mapper now gives it its own headline plus a hint naming the three acquisition paths — local module install, adding the providing store, configuring the MCP/n8n integration — in EN and DE, with the verbatim hub message kept copyable. Matcher unit tests EN+DE guard the classification. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
195 lines
6.5 KiB
Dart
195 lines
6.5 KiB
Dart
// Unit tests for the `friendlyError` mapper. These check the
|
|
// duck-typed shape contract — `GrpcError` from package:grpc is
|
|
// expected to expose `.code` (int), `.message` (String?), and
|
|
// `.codeName` (String) — without taking grpc as a Studio
|
|
// dependency. The fakes here mimic that shape.
|
|
|
|
import 'package:flutter/widgets.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:chain_studio/data/friendly_error.dart';
|
|
import 'package:chain_studio/l10n/app_localizations.dart';
|
|
|
|
class _FakeGrpcError {
|
|
final int code;
|
|
final String? message;
|
|
final String codeName;
|
|
_FakeGrpcError(this.code, this.codeName, [this.message]);
|
|
}
|
|
|
|
Future<AppLocalizations> _loadL10n(Locale locale) async {
|
|
return await AppLocalizations.delegate.load(locale);
|
|
}
|
|
|
|
void main() {
|
|
WidgetsFlutterBinding.ensureInitialized();
|
|
setUpAll(() async {
|
|
// Force loading the gen-l10n delegate before tests so each
|
|
// call to `_loadL10n` is fast.
|
|
await _loadL10n(const Locale('en'));
|
|
});
|
|
|
|
test('UNAVAILABLE maps to hub-not-reachable copy', () async {
|
|
final l = await _loadL10n(const Locale('en'));
|
|
final r = friendlyError(_FakeGrpcError(14, 'UNAVAILABLE', 'connection refused'), l);
|
|
expect(r.headline, l.errUnavailable);
|
|
expect(r.hint, l.errUnavailableHint);
|
|
expect(r.detail, 'connection refused');
|
|
});
|
|
|
|
test('FAILED_PRECONDITION carries module detail in body', () async {
|
|
final l = await _loadL10n(const Locale('en'));
|
|
final r = friendlyError(
|
|
_FakeGrpcError(9, 'FAILED_PRECONDITION',
|
|
'install error: module manifest is invalid'),
|
|
l,
|
|
);
|
|
expect(r.headline, l.errFailedPrecondition);
|
|
expect(r.hint, l.errFailedPreconditionHint);
|
|
expect(r.detail, contains('manifest'));
|
|
});
|
|
|
|
test('NOT_FOUND has a recovery hint', () async {
|
|
final l = await _loadL10n(const Locale('en'));
|
|
final r = friendlyError(_FakeGrpcError(5, 'NOT_FOUND', 'no such module'), l);
|
|
expect(r.headline, l.errNotFound);
|
|
expect(r.hint, isNotNull);
|
|
});
|
|
|
|
test('Unknown code falls back to codeName: message', () async {
|
|
final l = await _loadL10n(const Locale('en'));
|
|
final r = friendlyError(_FakeGrpcError(99, 'CUSTOM_CODE', 'something'), l);
|
|
expect(r.headline, contains('CUSTOM_CODE'));
|
|
expect(r.headline, contains('something'));
|
|
expect(r.hint, isNull);
|
|
});
|
|
|
|
test('Non-gRPC error renders toString as headline', () async {
|
|
final l = await _loadL10n(const Locale('en'));
|
|
final r = friendlyError(FormatException('bad url'), l);
|
|
expect(r.headline, contains('bad url'));
|
|
});
|
|
|
|
test('Locale switches the headline language', () async {
|
|
final lDe = await _loadL10n(const Locale('de'));
|
|
final r =
|
|
friendlyError(_FakeGrpcError(14, 'UNAVAILABLE', 'connection refused'), lDe);
|
|
expect(r.headline, lDe.errUnavailable);
|
|
final lEn = await _loadL10n(const Locale('en'));
|
|
expect(lDe.errUnavailable, isNot(equals(lEn.errUnavailable)));
|
|
});
|
|
|
|
// --- Hub-pattern-specific detection ---
|
|
// The mapper inspects the message DETAIL for hub patterns and
|
|
// returns a more specific FriendlyError than the gRPC code's
|
|
// default. These guards lock the matchers — a regression here
|
|
// would silently fall back to the generic gRPC-code copy.
|
|
|
|
test("approval rejection maps to specific headline", () async {
|
|
final l = await _loadL10n(const Locale('en'));
|
|
final r = friendlyError(
|
|
_FakeGrpcError(
|
|
7,
|
|
'PERMISSION_DENIED',
|
|
"step 'ap1' rejected by alice: needs more detail",
|
|
),
|
|
l,
|
|
);
|
|
expect(r.headline, l.errApprovalRejected);
|
|
expect(r.hint, l.errApprovalRejectedHint);
|
|
});
|
|
|
|
test('approval timeout maps to specific headline', () async {
|
|
final l = await _loadL10n(const Locale('en'));
|
|
final r = friendlyError(
|
|
_FakeGrpcError(
|
|
4,
|
|
'DEADLINE_EXCEEDED',
|
|
"step 'ap1' timed out waiting for approval after 600s",
|
|
),
|
|
l,
|
|
);
|
|
expect(r.headline, l.errApprovalTimedOut);
|
|
});
|
|
|
|
test('output-too-large maps to specific headline', () async {
|
|
final l = await _loadL10n(const Locale('en'));
|
|
final r = friendlyError(
|
|
_FakeGrpcError(
|
|
8,
|
|
'RESOURCE_EXHAUSTED',
|
|
"step 'extract' produced 52428800 bytes of output, "
|
|
"exceeding the 10 MB cap",
|
|
),
|
|
l,
|
|
);
|
|
expect(r.headline, l.errOutputTooLarge);
|
|
});
|
|
|
|
test('host-service-not-declared maps to specific headline', () async {
|
|
final l = await _loadL10n(const Locale('en'));
|
|
final r = friendlyError(
|
|
_FakeGrpcError(
|
|
14,
|
|
'UNAVAILABLE',
|
|
"step 'summarise' requires service 'ollama' which is not declared "
|
|
"in operator config",
|
|
),
|
|
l,
|
|
);
|
|
expect(r.headline, l.errServiceUnavailableForStep);
|
|
});
|
|
|
|
test('missing value maps to specific headline', () async {
|
|
final l = await _loadL10n(const Locale('en'));
|
|
final r = friendlyError(
|
|
_FakeGrpcError(
|
|
3,
|
|
'INVALID_ARGUMENT',
|
|
r"step 'classify' references missing value '$inputs.text'",
|
|
),
|
|
l,
|
|
);
|
|
expect(r.headline, l.errMissingValue);
|
|
});
|
|
|
|
test('no-store-entry maps to the three-path recovery hint', () async {
|
|
// The flow quick-fix dead end: even with the honest badge this
|
|
// error stays reachable (stale store snapshot, refresh race,
|
|
// older hub) — it must never render as the generic
|
|
// FAILED_PRECONDITION copy again.
|
|
final l = await _loadL10n(const Locale('en'));
|
|
final r = friendlyError(
|
|
_FakeGrpcError(
|
|
9,
|
|
'FAILED_PRECONDITION',
|
|
"install error: no store entry for 'example-provider/tool.summarize'"
|
|
' — run `chain store search <term>` to see what\'s available',
|
|
),
|
|
l,
|
|
);
|
|
expect(r.headline, l.errNoStoreEntry);
|
|
expect(r.hint, l.errNoStoreEntryHint);
|
|
// The hint names all three acquisition paths.
|
|
expect(r.hint, contains('chain install --link'));
|
|
expect(r.hint, contains('Stores'));
|
|
expect(r.hint, contains('integration'));
|
|
// The verbatim hub message stays copyable.
|
|
expect(r.detail, contains("example-provider/tool.summarize"));
|
|
});
|
|
|
|
test('no-store-entry is classified in German too', () async {
|
|
final l = await _loadL10n(const Locale('de'));
|
|
final r = friendlyError(
|
|
_FakeGrpcError(
|
|
9,
|
|
'FAILED_PRECONDITION',
|
|
"no store entry for 'x.y' — run `chain store search <term>`",
|
|
),
|
|
l,
|
|
);
|
|
expect(r.headline, l.errNoStoreEntry);
|
|
expect(r.hint, contains('chain install --link'));
|
|
expect(r.hint, contains('Anbindung'));
|
|
});
|
|
}
|
|
|